From 4394284020a56ed85fda539e665feac59deff552 Mon Sep 17 00:00:00 2001 From: guptaishaan Date: Sun, 2 Aug 2026 15:55:32 -0700 Subject: [PATCH] fix: support MCP Python SDK v2 in the SDK MCP bridge create_sdk_mcp_server() registered its handlers with the v1 decorators @server.list_tools() / @server.call_tool(), and Query._handle_sdk_mcp_request dispatched by indexing server.request_handlers by request type. MCP SDK v2 removed both: handlers are registered by method name and looked up with get_request_handler(). v2 also renamed the result model fields to snake_case, keeping the camelCase wire names as pydantic aliases, so the bridge's reads of tool.inputSchema, item.mimeType and result.isError were wrong there as well. The handler bodies are unchanged. They are now registered with the decorators on v1 and with add_request_handler on v2, and dispatch goes through one helper that knows both lookup shapes and unwraps v1's ServerResult. Where the bridge built wire payloads from camelCase attributes it now dumps by alias, which gives the same output on both versions. The v2 tools/call wrapper turns handler exceptions into an isError result, which is what v1's decorator did. The mcp bound is relaxed to >=1.23.0,<3.0.0. Two gaps remain on v2. maxResultSizeChars is passed as an unknown extra field on mcp.types.ToolAnnotations, which is extra="allow" on v1 but not on v2, so pydantic drops it in the caller's own constructor before the SDK sees it; its test now skips when ToolAnnotations rejects extras. And v1's call_tool decorator validated arguments against inputSchema, which the v2 registration path does not do. Verified with mcp 1.29.0 and mcp 2.0.0 on Python 3.13: full suite, ruff and mypy clean under both. --- pyproject.toml | 2 +- src/claude_agent_sdk/__init__.py | 55 +++++- src/claude_agent_sdk/_internal/query.py | 70 +++---- tests/test_sdk_mcp_integration.py | 237 ++++++++++++++---------- 4 files changed, 219 insertions(+), 145 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index efa47ae7c..1d1269365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "anyio>=4.0.0", "sniffio>=1.0.0", "typing_extensions>=4.0.0; python_version<'3.11'", - "mcp>=1.23.0,<2.0.0", + "mcp>=1.23.0,<3.0.0", ] [project.optional-dependencies] diff --git a/src/claude_agent_sdk/__init__.py b/src/claude_agent_sdk/__init__.py index 50d5b9fe6..5d7000a41 100644 --- a/src/claude_agent_sdk/__init__.py +++ b/src/claude_agent_sdk/__init__.py @@ -384,9 +384,12 @@ def create_sdk_mcp_server( from mcp.server import Server from mcp.types import ( AudioContent, + CallToolRequestParams, CallToolResult, EmbeddedResource, ImageContent, + ListToolsResult, + PaginatedRequestParams, ResourceLink, TextContent, Tool, @@ -446,14 +449,10 @@ def _build_meta(tool_def: "SdkMcpTool[Any]") -> dict[str, Any] | None: for tool_def in tools ] - # Register list_tools handler to expose available tools - @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator] async def list_tools() -> list[Tool]: """Return the list of available tools.""" return cached_tool_list - # Register call_tool handler to execute tools - @server.call_tool() # type: ignore[untyped-decorator] async def call_tool(name: str, arguments: dict[str, Any]) -> Any: """Execute a tool by name with given arguments.""" if name not in tool_map: @@ -477,11 +476,15 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: if item_type == "text": content.append(TextContent(type="text", text=item["text"])) elif item_type == "image": + # Built from the wire names: MCP SDK v2 renamed the + # field to mime_type and kept mimeType as its alias. content.append( - ImageContent( - type="image", - data=item["data"], - mimeType=item["mimeType"], + ImageContent.model_validate( + { + "type": "image", + "data": item["data"], + "mimeType": item["mimeType"], + } ) ) elif item_type == "resource_link": @@ -517,8 +520,40 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any: item_type, ) - return CallToolResult( - content=content, isError=result.get("is_error", False) + return CallToolResult.model_validate( + {"content": content, "isError": result.get("is_error", False)} + ) + + # Register the handlers. MCP SDK v1 uses decorators; v2 registers by + # method name and passes a request context plus parsed params. Only one + # of the two APIs exists on any given install, so go through Any. + registry: Any = server + if hasattr(server, "list_tools"): + registry.list_tools()(list_tools) + registry.call_tool()(call_tool) + else: + + async def on_list_tools(ctx: Any, params: Any) -> Any: + return ListToolsResult(tools=await list_tools()) + + async def on_call_tool(ctx: Any, params: Any) -> Any: + # v1's call_tool decorator reports handler exceptions as an + # error result rather than a protocol error; keep that here. + try: + return await call_tool(params.name, params.arguments or {}) + except Exception as e: + return CallToolResult.model_validate( + { + "content": [TextContent(type="text", text=str(e))], + "isError": True, + } + ) + + registry.add_request_handler( + "tools/list", PaginatedRequestParams, on_list_tools + ) + registry.add_request_handler( + "tools/call", CallToolRequestParams, on_call_tool ) # Return SDK server configuration diff --git a/src/claude_agent_sdk/_internal/query.py b/src/claude_agent_sdk/_internal/query.py index 66f10f06e..322b5bd8c 100644 --- a/src/claude_agent_sdk/_internal/query.py +++ b/src/claude_agent_sdk/_internal/query.py @@ -56,6 +56,23 @@ DEFERRING_TASK_TYPES = frozenset({"local_agent", "local_workflow"}) +async def _call_sdk_mcp_handler(server: Any, request: Any) -> Any: + """Invoke an SDK MCP server's handler for a request, or None if unhandled. + + MCP SDK v1 keys handlers by request type and wraps the result in a + ServerResult; v2 keys them by method name and returns the result directly. + """ + handlers = getattr(server, "request_handlers", None) + if handlers is not None: + handler = handlers.get(type(request)) + return (await handler(request)).root if handler else None + entry = server.get_request_handler(request.method) + if entry is None: + return None + # Handlers registered by create_sdk_mcp_server() ignore the context. + return await entry.handler(None, request.params) + + def _convert_hook_output_for_cli(hook_output: dict[str, Any]) -> dict[str, Any]: """Convert Python-safe field names to CLI-expected field names. @@ -646,30 +663,15 @@ async def _handle_sdk_mcp_request( elif method == "tools/list": request = ListToolsRequest(method=method) - handler = server.request_handlers.get(ListToolsRequest) - if handler: - result = await handler(request) - # Convert MCP result to JSONRPC response - tools_data = [] - for tool in result.root.tools: # type: ignore[union-attr] - tool_data: dict[str, Any] = { - "name": tool.name, - "description": tool.description, - "inputSchema": ( - tool.inputSchema.model_dump() - if hasattr(tool.inputSchema, "model_dump") - else tool.inputSchema - ) - if tool.inputSchema - else {}, - } - if tool.annotations: - tool_data["annotations"] = tool.annotations.model_dump( - exclude_none=True - ) - if tool.meta: - tool_data["_meta"] = tool.meta - tools_data.append(tool_data) + result = await _call_sdk_mcp_handler(server, request) + if result is not None: + # Convert MCP result to JSONRPC response. Dumping by alias + # yields the wire names (inputSchema, _meta, ...) on both + # MCP SDK v1 and v2, which renamed the fields to snake_case. + tools_data = [ + tool.model_dump(by_alias=True, exclude_none=True, mode="json") + for tool in result.tools + ] return { "jsonrpc": "2.0", "id": message.get("id"), @@ -683,12 +685,11 @@ async def _handle_sdk_mcp_request( name=params.get("name"), arguments=params.get("arguments", {}) ), ) - handler = server.request_handlers.get(CallToolRequest) - if handler: - result = await handler(call_request) + result = await _call_sdk_mcp_handler(server, call_request) + if result is not None: # Convert MCP result to JSONRPC response content = [] - for item in result.root.content: # type: ignore[union-attr] + for item in result.content: item_type = getattr(item, "type", None) if item_type == "text": content.append( @@ -696,11 +697,9 @@ async def _handle_sdk_mcp_request( ) elif item_type == "image": content.append( - { - "type": "image", - "data": getattr(item, "data", ""), - "mimeType": getattr(item, "mimeType", ""), - } + item.model_dump( + by_alias=True, exclude_none=True, mode="json" + ) ) elif item_type == "resource_link": parts = [] @@ -736,7 +735,10 @@ async def _handle_sdk_mcp_request( ) response_data = {"content": content} - if hasattr(result.root, "isError") and result.root.isError: + # MCP SDK v2 renamed isError to is_error. + if getattr(result, "isError", None) or getattr( + result, "is_error", None + ): response_data["isError"] = True # type: ignore[assignment] return { diff --git a/tests/test_sdk_mcp_integration.py b/tests/test_sdk_mcp_integration.py index 1e456ad52..18932bdfb 100644 --- a/tests/test_sdk_mcp_integration.py +++ b/tests/test_sdk_mcp_integration.py @@ -23,6 +23,16 @@ from claude_agent_sdk import ( _typeddict_to_json_schema as typeddict_to_json_schema, ) +from claude_agent_sdk._internal.query import _call_sdk_mcp_handler + + +def wire(model: Any) -> dict[str, Any]: + """Dump an MCP model under its wire names. + + MCP SDK v2 renamed the model fields to snake_case and kept the camelCase + wire names as aliases, so assertions go through the aliases. + """ + return model.model_dump(by_alias=True, exclude_none=True) @pytest.mark.anyio @@ -58,24 +68,18 @@ async def add_numbers(args: dict[str, Any]) -> dict[str, Any]: # Import the request types to check handlers from mcp.types import CallToolRequest, ListToolsRequest - # Verify handlers are registered - assert ListToolsRequest in server.request_handlers - assert CallToolRequest in server.request_handlers - - # Test list_tools handler - the decorator wraps our function - list_handler = server.request_handlers[ListToolsRequest] + # Test list_tools handler request = ListToolsRequest(method="tools/list") - response = await list_handler(request) - # Response is ServerResult with nested ListToolsResult - assert len(response.root.tools) == 2 + response = await _call_sdk_mcp_handler(server, request) + assert response is not None + assert len(response.tools) == 2 # Check tool definitions - tool_names = [t.name for t in response.root.tools] + tool_names = [t.name for t in response.tools] assert "greet_user" in tool_names assert "add_numbers" in tool_names # Test call_tool handler - call_handler = server.request_handlers[CallToolRequest] # Call greet_user - CallToolRequest wraps the call from mcp.types import CallToolRequestParams @@ -84,9 +88,8 @@ async def add_numbers(args: dict[str, Any]) -> dict[str, Any]: method="tools/call", params=CallToolRequestParams(name="greet_user", arguments={"name": "Alice"}), ) - result = await call_handler(greet_request) - # Response is ServerResult with nested CallToolResult - assert result.root.content[0].text == "Hello, Alice!" + result = await _call_sdk_mcp_handler(server, greet_request) + assert result.content[0].text == "Hello, Alice!" assert len(tool_executions) == 1 assert tool_executions[0]["name"] == "greet_user" assert tool_executions[0]["args"]["name"] == "Alice" @@ -96,14 +99,59 @@ async def add_numbers(args: dict[str, Any]) -> dict[str, Any]: method="tools/call", params=CallToolRequestParams(name="add_numbers", arguments={"a": 5, "b": 3}), ) - result = await call_handler(add_request) - assert "8" in result.root.content[0].text + result = await _call_sdk_mcp_handler(server, add_request) + assert "8" in result.content[0].text assert len(tool_executions) == 2 assert tool_executions[1]["name"] == "add_numbers" assert tool_executions[1]["args"]["a"] == 5 assert tool_executions[1]["args"]["b"] == 3 +@pytest.mark.anyio +async def test_jsonrpc_bridge_round_trip(): + """The bridge lists and calls a tool on whichever MCP SDK is installed. + + v1 and v2 register and dispatch handlers differently and v2 renamed the + model fields to snake_case, so this exercises both ends on the wire. + """ + from claude_agent_sdk._internal.query import Query + + @tool("echo", "Echo text", {"text": str}) + async def echo(args: dict[str, Any]) -> dict[str, Any]: + return {"content": [{"type": "text", "text": args["text"]}]} + + server_config = create_sdk_mcp_server(name="demo", tools=[echo]) + + query_instance = Query.__new__(Query) + query_instance.sdk_mcp_servers = {"demo": server_config["instance"]} + + listed = await query_instance._handle_sdk_mcp_request( + "demo", {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} + ) + assert listed["result"]["tools"] == [ + { + "name": "echo", + "description": "Echo text", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + } + ] + + called = await query_instance._handle_sdk_mcp_request( + "demo", + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "echo", "arguments": {"text": "hi"}}, + }, + ) + assert called["result"] == {"content": [{"type": "text", "text": "hi"}]} + + @pytest.mark.anyio async def test_tool_creation(): """Test that tools can be created with proper schemas.""" @@ -139,20 +187,16 @@ async def fail_tool(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="error-test", tools=[fail_tool]) server = server_config["instance"] - from mcp.types import CallToolRequest - - call_handler = server.request_handlers[CallToolRequest] - # The handler should return an error result, not raise - from mcp.types import CallToolRequestParams + from mcp.types import CallToolRequest, CallToolRequestParams fail_request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="fail", arguments={}) ) - result = await call_handler(fail_request) + result = await _call_sdk_mcp_handler(server, fail_request) # MCP SDK catches exceptions and returns error results - assert result.root.isError - assert "Expected error" in str(result.root.content[0].text) + assert wire(result)["isError"] + assert "Expected error" in str(result.content[0].text) @pytest.mark.anyio @@ -170,25 +214,24 @@ async def divide(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="error-flag-test", tools=[divide]) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] # Test error case — is_error: True should be propagated error_request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="divide", arguments={"a": 1, "b": 0}), ) - result = await call_handler(error_request) - assert result.root.isError is True - assert result.root.content[0].text == "Division by zero" + result = await _call_sdk_mcp_handler(server, error_request) + assert wire(result)["isError"] is True + assert result.content[0].text == "Division by zero" # Test success case — is_error should default to False success_request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="divide", arguments={"a": 6, "b": 3}), ) - result = await call_handler(success_request) - assert result.root.isError is not True - assert "2.0" in result.root.content[0].text + result = await _call_sdk_mcp_handler(server, success_request) + assert wire(result).get("isError") is not True + assert "2.0" in result.content[0].text @pytest.mark.anyio @@ -236,7 +279,10 @@ async def test_server_creation(): from mcp.types import ListToolsRequest # When no tools are provided, the handlers are not registered - assert ListToolsRequest not in instance.request_handlers + assert ( + await _call_sdk_mcp_handler(instance, ListToolsRequest(method="tools/list")) + is None + ) @pytest.mark.anyio @@ -278,8 +324,6 @@ async def generate_chart(args: dict[str, Any]) -> dict[str, Any]: # Get the server instance server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] - # Call the chart generation tool chart_request = CallToolRequest( method="tools/call", @@ -287,21 +331,21 @@ async def generate_chart(args: dict[str, Any]) -> dict[str, Any]: name="generate_chart", arguments={"title": "Sales Report"} ), ) - result = await call_handler(chart_request) + result = await _call_sdk_mcp_handler(server, chart_request) # Verify the result contains both text and image content - assert len(result.root.content) == 2 + assert len(result.content) == 2 # Check text content - text_content = result.root.content[0] + text_content = result.content[0] assert text_content.type == "text" assert text_content.text == "Generated chart: Sales Report" # Check image content - image_content = result.root.content[1] + image_content = result.content[1] assert image_content.type == "image" assert image_content.data == png_data - assert image_content.mimeType == "image/png" + assert wire(image_content)["mimeType"] == "image/png" # Verify the tool was executed correctly assert len(tool_executions) == 1 @@ -345,13 +389,12 @@ async def no_annotations(args: dict[str, Any]) -> dict[str, Any]: return {"content": [{"type": "text", "text": args["x"]}]} # Verify annotations stored on SdkMcpTool - assert read_data.annotations is not None - assert read_data.annotations.readOnlyHint is True - assert delete_item.annotations is not None - assert delete_item.annotations.destructiveHint is True - assert delete_item.annotations.idempotentHint is True - assert search.annotations is not None - assert search.annotations.openWorldHint is True + assert wire(read_data.annotations) == {"readOnlyHint": True} + assert wire(delete_item.annotations) == { + "destructiveHint": True, + "idempotentHint": True, + } + assert wire(search.annotations) == {"openWorldHint": True} assert no_annotations.annotations is None # Verify annotations flow through list_tools handler @@ -363,19 +406,18 @@ async def no_annotations(args: dict[str, Any]) -> dict[str, Any]: from mcp.types import ListToolsRequest - list_handler = server.request_handlers[ListToolsRequest] request = ListToolsRequest(method="tools/list") - response = await list_handler(request) - - tools_by_name = {t.name: t for t in response.root.tools} + response = await _call_sdk_mcp_handler(server, request) - assert tools_by_name["read_data"].annotations is not None - assert tools_by_name["read_data"].annotations.readOnlyHint is True - assert tools_by_name["delete_item"].annotations is not None - assert tools_by_name["delete_item"].annotations.destructiveHint is True - assert tools_by_name["delete_item"].annotations.idempotentHint is True - assert tools_by_name["search"].annotations is not None - assert tools_by_name["search"].annotations.openWorldHint is True + assert response is not None + tools_by_name = {t.name: t for t in response.tools} + + assert wire(tools_by_name["read_data"].annotations) == {"readOnlyHint": True} + assert wire(tools_by_name["delete_item"].annotations) == { + "destructiveHint": True, + "idempotentHint": True, + } + assert wire(tools_by_name["search"].annotations) == {"openWorldHint": True} assert tools_by_name["no_annotations"].annotations is None @@ -424,6 +466,11 @@ async def plain_tool(args: dict[str, Any]) -> dict[str, Any]: assert "annotations" not in tools_by_name["plain_tool"] +@pytest.mark.skipif( + ToolAnnotations.model_config.get("extra") != "allow", + reason="MCP SDK v2 ToolAnnotations drops unknown fields, so maxResultSizeChars " + "cannot be carried on annotations", +) def test_max_result_size_chars_annotation_flows_to_cli(): """maxResultSizeChars annotation reaches the CLI via the tools/list JSONRPC response. @@ -514,7 +561,6 @@ async def get_resource(args: dict[str, Any]) -> dict[str, Any]: name="resource-link-test", tools=[get_resource] ) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] request = CallToolRequest( method="tools/call", @@ -523,13 +569,13 @@ async def get_resource(args: dict[str, Any]) -> dict[str, Any]: arguments={"url": "https://example.com/doc.pdf"}, ), ) - result = await call_handler(request) + result = await _call_sdk_mcp_handler(server, request) - assert len(result.root.content) == 1 - assert result.root.content[0].type == "text" - assert "My Document" in result.root.content[0].text - assert "https://example.com/doc.pdf" in result.root.content[0].text - assert "A test document" in result.root.content[0].text + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert "My Document" in result.content[0].text + assert "https://example.com/doc.pdf" in result.content[0].text + assert "A test document" in result.content[0].text @pytest.mark.anyio @@ -555,17 +601,16 @@ async def get_embedded(args: dict[str, Any]) -> dict[str, Any]: name="embedded-resource-test", tools=[get_embedded] ) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="get_embedded", arguments={}), ) - result = await call_handler(request) + result = await _call_sdk_mcp_handler(server, request) - assert len(result.root.content) == 1 - assert result.root.content[0].type == "text" - assert result.root.content[0].text == "File contents here" + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "File contents here" @pytest.mark.anyio @@ -593,16 +638,15 @@ async def get_binary(args: dict[str, Any]) -> dict[str, Any]: name="binary-resource-test", tools=[get_binary] ) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="get_binary", arguments={}), ) with caplog.at_level(logging.WARNING): - result = await call_handler(request) + result = await _call_sdk_mcp_handler(server, request) - assert len(result.root.content) == 0 + assert len(result.content) == 0 assert "Binary embedded resource" in caplog.text @@ -622,16 +666,15 @@ async def get_unknown(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="unknown-type-test", tools=[get_unknown]) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="get_unknown", arguments={}), ) with caplog.at_level(logging.WARNING): - result = await call_handler(request) + result = await _call_sdk_mcp_handler(server, request) - assert len(result.root.content) == 0 + assert len(result.content) == 0 assert "Unsupported content type" in caplog.text assert "custom_widget" in caplog.text @@ -658,20 +701,19 @@ async def get_mixed(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="mixed-content-test", tools=[get_mixed]) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="get_mixed", arguments={}), ) - result = await call_handler(request) + result = await _call_sdk_mcp_handler(server, request) - assert len(result.root.content) == 3 - assert result.root.content[0].type == "text" - assert result.root.content[0].text == "Here is the document:" - assert result.root.content[1].type == "image" - assert result.root.content[2].type == "text" - assert "Report" in result.root.content[2].text + assert len(result.content) == 3 + assert result.content[0].type == "text" + assert result.content[0].text == "Here is the document:" + assert result.content[1].type == "image" + assert result.content[2].type == "text" + assert "Report" in result.content[2].text @pytest.mark.anyio @@ -998,13 +1040,12 @@ async def search(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="typeddict-test", tools=[search]) server = server_config["instance"] - list_handler = server.request_handlers[ListToolsRequest] request = ListToolsRequest(method="tools/list") - response = await list_handler(request) + response = await _call_sdk_mcp_handler(server, request) - tools = response.root.tools + tools = response.tools assert len(tools) == 1 - schema = tools[0].inputSchema + schema = wire(tools[0])["inputSchema"] assert schema["type"] == "object" assert schema["properties"]["query"] == {"type": "string"} assert schema["properties"]["max_results"] == {"type": "integer"} @@ -1027,14 +1068,13 @@ async def multiply(args: dict[str, Any]) -> dict[str, Any]: name="typeddict-call-test", tools=[multiply] ) server = server_config["instance"] - call_handler = server.request_handlers[CallToolRequest] request = CallToolRequest( method="tools/call", params=CallToolRequestParams(name="multiply", arguments={"a": 6, "b": 7}), ) - result = await call_handler(request) - assert "42" in result.root.content[0].text + result = await _call_sdk_mcp_handler(server, request) + assert "42" in result.content[0].text @pytest.mark.anyio async def test_dict_schema_still_works(self) -> None: @@ -1044,11 +1084,10 @@ async def echo(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="dict-schema-test", tools=[echo]) server = server_config["instance"] - list_handler = server.request_handlers[ListToolsRequest] request = ListToolsRequest(method="tools/list") - response = await list_handler(request) + response = await _call_sdk_mcp_handler(server, request) - schema = response.root.tools[0].inputSchema + schema = wire(response.tools[0])["inputSchema"] assert schema["type"] == "object" assert schema["properties"]["message"] == {"type": "string"} assert schema["required"] == ["message"] @@ -1070,11 +1109,10 @@ async def validate(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="passthrough-test", tools=[validate]) server = server_config["instance"] - list_handler = server.request_handlers[ListToolsRequest] request = ListToolsRequest(method="tools/list") - response = await list_handler(request) + response = await _call_sdk_mcp_handler(server, request) - schema = response.root.tools[0].inputSchema + schema = wire(response.tools[0])["inputSchema"] assert schema == json_schema @pytest.mark.anyio @@ -1085,9 +1123,8 @@ async def cached(args: dict[str, Any]) -> dict[str, Any]: server_config = create_sdk_mcp_server(name="cache-test", tools=[cached]) server = server_config["instance"] - list_handler = server.request_handlers[ListToolsRequest] request = ListToolsRequest(method="tools/list") - response1 = await list_handler(request) - response2 = await list_handler(request) - assert response1.root.tools == response2.root.tools + response1 = await _call_sdk_mcp_handler(server, request) + response2 = await _call_sdk_mcp_handler(server, request) + assert response1.tools == response2.tools