Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ classifiers = [
"Topic :: Office/Business :: Financial",
]
dependencies = [
"mcp>=1.0.0,<2.0.0", # 2.0 移除了 Server 的 list_tools/call_tool decorator API(issue #16)
"mcp>=2.0.0",
"httpx>=0.27",
"pydantic>=2.0",
]
Expand Down
2 changes: 1 addition & 1 deletion src/finmind_mcp/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def resource_definitions() -> list[Resource]:
uri=f"finmind://{suffix}",
name=suffix,
description=f"FinMind knowledge pack: {filename}",
mimeType="text/markdown",
mime_type="text/markdown",
)
)
return resources
Expand Down
73 changes: 51 additions & 22 deletions src/finmind_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
shared knowledge pack. Designed to be launched by an MCP host such as
Claude Desktop, Claude Code, Cursor, Windsurf, or Gemini CLI.

Handlers are passed to the `Server` constructor: mcp 2.0 removed the 1.x
`@app.list_tools()` decorators in favour of `on_*` keyword arguments, and
handlers now take `(ctx, params)` and return full result models.

Run via:
finmind-mcp
or:
Expand All @@ -15,36 +19,61 @@
import asyncio
import logging

import mcp.types as types
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Resource, TextContent, Tool

from . import knowledge, tools

logger = logging.getLogger(__name__)
app: Server = Server("finmind")


@app.list_tools()
async def list_tools() -> list[Tool]:
return tools.tool_definitions()


@app.call_tool()
async def call_tool(name: str, arguments: dict | None) -> list[TextContent]:
result = await tools.dispatch(name, arguments or {})
return [TextContent(type="text", text=result)]


@app.list_resources()
async def list_resources() -> list[Resource]:
return knowledge.resource_definitions()


@app.read_resource()
async def read_resource(uri) -> str:
# `uri` is a pydantic AnyUrl in current MCP SDK; convert to string.
return knowledge.read(str(uri))
async def on_list_tools(ctx, params) -> types.ListToolsResult:
return types.ListToolsResult(tools=tools.tool_definitions())


async def on_call_tool(ctx, params) -> types.CallToolResult:
# mcp 1.x turned a raising handler into `isError`; mcp 2.0 lets the
# exception become a JSON-RPC protocol error, which hosts report as a
# server failure instead of showing the model something it can act on.
try:
result = await tools.dispatch(params.name, params.arguments or {})
except Exception as exc: # noqa: BLE001 — surfaced to the model, not swallowed
logger.exception("tool %s failed", getattr(params, "name", "?"))
return types.CallToolResult(
content=[types.TextContent(type="text", text=str(exc))],
is_error=True,
)
return types.CallToolResult(
content=[types.TextContent(type="text", text=result)]
)


async def on_list_resources(ctx, params) -> types.ListResourcesResult:
return types.ListResourcesResult(resources=knowledge.resource_definitions())


async def on_read_resource(ctx, params) -> types.ReadResourceResult:
# `params.uri` is a pydantic AnyUrl; knowledge.read wants a plain string.
uri = str(params.uri)
return types.ReadResourceResult(
contents=[
types.TextResourceContents(
uri=uri,
mime_type="text/markdown",
text=knowledge.read(uri),
)
]
)


app: Server = Server(
"finmind",
on_list_tools=on_list_tools,
on_call_tool=on_call_tool,
on_list_resources=on_list_resources,
on_read_resource=on_read_resource,
)


async def _run() -> None:
Expand Down
8 changes: 4 additions & 4 deletions src/finmind_mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def tool_definitions() -> list[Tool]:
" start_date(YYYY-MM-DD),回傳 markdown 表格。超過 500 列會截斷"
"並標註總列數。"
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"dataset": {
Expand Down Expand Up @@ -82,15 +82,15 @@ def tool_definitions() -> list[Tool]:
"列出 FinMind 支援的所有 dataset(讀取內建知識庫,不需連線)。"
"依分類回傳 dataset 名稱、會員層級與說明的 markdown 條列。"
),
inputSchema={"type": "object", "properties": {}},
input_schema={"type": "object", "properties": {}},
),
Tool(
name="get_stock_info",
description=(
"查詢台股代號 / 中文名 / 產業別總覽(呼叫 TaiwanStockInfo)。"
"可選 stock_id 指定單一標的;未提供時回傳全市場清單(會截斷)。"
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"stock_id": {
Expand All @@ -107,7 +107,7 @@ def tool_definitions() -> list[Tool]:
"此 dataset 走專屬 endpoint 不在 /api/v4/data 通用路徑:必填股票代號 data_id"
"與單一日期 date(非區間)。需要 Sponsor 等級。"
),
inputSchema={
input_schema={
"type": "object",
"properties": {
"data_id": {
Expand Down
105 changes: 105 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Tests for the MCP protocol handlers in server.py.

Since the mcp 2.0 migration the handlers are plain async functions registered
on the `Server` constructor, so they can be called directly — no subprocess,
no transport. `smoke.py` and `regression/runner.py` still cover the real
stdio handshake end to end.
"""

import mcp.types as types
import pytest

from finmind_mcp import server, tools


class _Params:
"""Stand-in for the pydantic params models the runner passes in.

The handlers only read attributes off params, so a namespace is enough.
"""

def __init__(self, **kwargs):
self.__dict__.update(kwargs)


async def test_list_tools_returns_the_four_tools():
result = await server.on_list_tools(None, None)

assert isinstance(result, types.ListToolsResult)
assert [t.name for t in result.tools] == [
"query_dataset",
"list_datasets",
"get_stock_info",
"query_trading_daily_report",
]


async def test_call_tool_wraps_dispatch_output_as_text_content(monkeypatch):
async def fake_dispatch(name, arguments):
return f"dispatched {name} {arguments}"

monkeypatch.setattr(tools, "dispatch", fake_dispatch)

result = await server.on_call_tool(
None, _Params(name="query_dataset", arguments={"dataset": "TaiwanStockPrice"})
)

assert isinstance(result, types.CallToolResult)
assert not result.is_error
assert result.content[0].text == (
"dispatched query_dataset {'dataset': 'TaiwanStockPrice'}"
)


async def test_call_tool_handles_omitted_arguments(monkeypatch):
seen = {}

async def fake_dispatch(name, arguments):
seen["arguments"] = arguments
return "ok"

monkeypatch.setattr(tools, "dispatch", fake_dispatch)

await server.on_call_tool(None, _Params(name="list_datasets", arguments=None))

assert seen["arguments"] == {}


async def test_call_tool_reports_errors_as_tool_results_not_protocol_errors():
"""A raising dispatch must come back as isError, not as an exception.

mcp 1.x wrapped call_tool handlers and turned exceptions into
`CallToolResult(isError=True)`; mcp 2.0 lets them escape and become
JSON-RPC protocol errors, which hosts surface as a server failure rather
than feeding back to the model. server.py restores the old behaviour, and
this test is what keeps it restored.
"""
result = await server.on_call_tool(None, _Params(name="no_such_tool", arguments={}))

assert isinstance(result, types.CallToolResult)
assert result.is_error
assert "no_such_tool" in result.content[0].text


async def test_list_resources_exposes_the_knowledge_pack():
result = await server.on_list_resources(None, None)

assert isinstance(result, types.ListResourcesResult)
assert result.resources, "expected at least one bundled knowledge resource"
assert all(str(r.uri).startswith("finmind://") for r in result.resources)


async def test_read_resource_returns_markdown_contents():
uri = str((await server.on_list_resources(None, None)).resources[0].uri)

result = await server.on_read_resource(None, _Params(uri=uri))

assert isinstance(result, types.ReadResourceResult)
contents = result.contents[0]
assert contents.mime_type == "text/markdown"
assert contents.text.strip()


async def test_read_resource_rejects_unknown_uri():
with pytest.raises(ValueError):
await server.on_read_resource(None, _Params(uri="finmind://nope"))
4 changes: 2 additions & 2 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ def test_tool_definitions_returns_four_tools():
]
for d in defs:
assert d.description
assert d.inputSchema is not None
assert d.inputSchema.get("type") == "object"
assert d.input_schema is not None
assert d.input_schema.get("type") == "object"


@pytest.mark.asyncio
Expand Down
Loading
Loading