fix: restore dict-at-boundary MCP source API for SDK v2

This commit is contained in:
lda
2026-09-06 11:43:24 +07:00 Verified
parent 28017648f6
commit 01e6be5d11
7 changed files with 120 additions and 142 deletions
+16 -9
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
from mcp import ClientResult
from mcp.types import ( from mcp.types import (
CallToolResult, CallToolResult,
ClientNotification, ClientNotification,
@@ -13,8 +12,12 @@ from mcp.types import (
ListResourcesResult, ListResourcesResult,
ListToolsResult, ListToolsResult,
ReadResourceResult, ReadResourceResult,
ServerResult,
client_notification_adapter,
client_request_adapter,
server_result_adapter,
) )
from pydantic import AnyUrl from pydantic import TypeAdapter
from wf_sources_mcp.connections import McpSourceConnection from wf_sources_mcp.connections import McpSourceConnection
@@ -43,7 +46,7 @@ class McpClientSession(Protocol):
async def list_prompts(self) -> ListPromptsResult: ... async def list_prompts(self) -> ListPromptsResult: ...
async def read_resource(self, uri: AnyUrl) -> ReadResourceResult: ... async def read_resource(self, uri: str) -> ReadResourceResult: ...
async def get_prompt( async def get_prompt(
self, self,
@@ -56,8 +59,8 @@ class McpClientSession(Protocol):
async def send_request( async def send_request(
self, self,
request: ClientRequest, request: ClientRequest,
result_type: type[ClientResult], result_type: type[ServerResult] | TypeAdapter[ServerResult],
) -> ClientResult: ... ) -> ServerResult: ...
async def send_notification(self, notification: ClientNotification) -> None: ... async def send_notification(self, notification: ClientNotification) -> None: ...
@@ -118,7 +121,7 @@ class McpSourceClient:
} }
async def read_resource(self, uri: str) -> dict[str, Any]: async def read_resource(self, uri: str) -> dict[str, Any]:
result = await self.session.read_resource(AnyUrl(uri)) result = await self.session.read_resource(str(uri))
return result.model_dump(by_alias=True, mode="json", exclude_none=True) return result.model_dump(by_alias=True, mode="json", exclude_none=True)
async def get_prompt( async def get_prompt(
@@ -135,8 +138,10 @@ class McpSourceClient:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
result = await self.session.send_request( result = await self.session.send_request(
ClientRequest.model_validate({"method": method, "params": params}), client_request_adapter.validate_python(
ClientResult, {"method": method, "params": params}
),
server_result_adapter,
) )
return result.model_dump(by_alias=True, mode="json", exclude_none=True) return result.model_dump(by_alias=True, mode="json", exclude_none=True)
@@ -146,7 +151,9 @@ class McpSourceClient:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
) -> None: ) -> None:
await self.session.send_notification( await self.session.send_notification(
ClientNotification.model_validate({"method": method, "params": params}) client_notification_adapter.validate_python(
{"method": method, "params": params}
)
) )
async def call_tool( async def call_tool(
+10 -8
View File
@@ -5,7 +5,6 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from pydantic import AnyUrl
from wf_sources_mcp.auth import AuthRecord from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
@@ -66,7 +65,7 @@ class PersistentMcpSession:
if self.read_resource_callback is not None: if self.read_resource_callback is not None:
return await self.read_resource_callback(uri) return await self.read_resource_callback(uri)
if self.client is not None: if self.client is not None:
result = await self.client.read_resource(AnyUrl(uri)) result = await self.client.read_resource(str(uri))
return result.model_dump(by_alias=True, mode="json", exclude_none=True) return result.model_dump(by_alias=True, mode="json", exclude_none=True)
raise RuntimeError("persistent MCP session has no resource read transport") raise RuntimeError("persistent MCP session has no resource read transport")
@@ -135,12 +134,13 @@ class PersistentMcpSession:
if self.invoke_method_callback is not None: if self.invoke_method_callback is not None:
return await self.invoke_method_callback(method, params) return await self.invoke_method_callback(method, params)
if self.client is not None: if self.client is not None:
from mcp import ClientResult from mcp.types import client_request_adapter, server_result_adapter
from mcp.types import ClientRequest
result = await self.client.send_request( result = await self.client.send_request(
ClientRequest.model_validate({"method": method, "params": params}), client_request_adapter.validate_python(
ClientResult, {"method": method, "params": params}
),
server_result_adapter,
) )
return result.model_dump(by_alias=True, mode="json", exclude_none=True) return result.model_dump(by_alias=True, mode="json", exclude_none=True)
raise RuntimeError("persistent MCP session has no method invoke transport") raise RuntimeError("persistent MCP session has no method invoke transport")
@@ -155,10 +155,12 @@ class PersistentMcpSession:
await self.send_notification_callback(method, params) await self.send_notification_callback(method, params)
return return
if self.client is not None: if self.client is not None:
from mcp.types import ClientNotification from mcp.types import client_notification_adapter
await self.client.send_notification( await self.client.send_notification(
ClientNotification.model_validate({"method": method, "params": params}) client_notification_adapter.validate_python(
{"method": method, "params": params}
)
) )
return return
raise RuntimeError("persistent MCP session has no notification send transport") raise RuntimeError("persistent MCP session has no notification send transport")
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest import pytest
from mcp import McpError from mcp import MCPError
from mcp.types import ErrorData from mcp.types import ErrorData
from wf_mcp.broker import WfMcpService from wf_mcp.broker import WfMcpService
@@ -33,10 +33,10 @@ class _ToolsOnlyAdapter:
] ]
async def list_resources(self, connection, auth): async def list_resources(self, connection, auth):
raise McpError(ErrorData(code=-32601, message="Method not found")) raise MCPError.from_error_data(ErrorData(code=-32601, message="Method not found"))
async def list_prompts(self, connection, auth): async def list_prompts(self, connection, auth):
raise McpError(ErrorData(code=-32601, message="Method not found")) raise MCPError.from_error_data(ErrorData(code=-32601, message="Method not found"))
async def get_connection_metadata(self, connection, auth): async def get_connection_metadata(self, connection, auth):
return { return {
@@ -65,7 +65,7 @@ class _WrappedToolsOnlyAdapter(_ToolsOnlyAdapter):
async def list_resources(self, connection, auth): async def list_resources(self, connection, auth):
raise ExceptionGroup( raise ExceptionGroup(
"unhandled errors in a TaskGroup", "unhandled errors in a TaskGroup",
[McpError(ErrorData(code=-32601, message="Method not found"))], [MCPError.from_error_data(ErrorData(code=-32601, message="Method not found"))],
) )
+4
View File
@@ -50,10 +50,14 @@ def finalize_tool(
@deprecated("Use pytests tmp_path fixture instead") @deprecated("Use pytests tmp_path fixture instead")
@overload @overload
def local_temp_root() -> Path: ... def local_temp_root() -> Path: ...
@overload @overload
def local_temp_root(root_path: Path) -> Path: ... def local_temp_root(root_path: Path) -> Path: ...
+23 -39
View File
@@ -5,7 +5,7 @@ from contextlib import AsyncExitStack
from typing import Any from typing import Any
import pytest import pytest
from mcp import ClientResult from mcp import GetPromptResult, ReadResourceResult, ServerResult
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.types import CallToolResult as RawCallToolResult from mcp.types import CallToolResult as RawCallToolResult
from mcp.types import ( from mcp.types import (
@@ -18,8 +18,9 @@ from mcp.types import (
Resource, Resource,
TextContent, TextContent,
Tool, Tool,
server_result_adapter,
) )
from pydantic import AnyUrl from pydantic import AnyUrl, TypeAdapter
from wf_sources_mcp.auth import AuthRecord from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.connections import McpSourceConnection from wf_sources_mcp.connections import McpSourceConnection
@@ -96,7 +97,7 @@ class _FakeFactory(PersistentSessionFactory):
self.calls.append((tool_name, payload)) self.calls.append((tool_name, payload))
return RawCallToolResult( return RawCallToolResult(
content=[TextContent(type="text", text="ok")], content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": payload["text"]}, structured_content={"echoed": payload["text"]},
) )
async def _create_with_stack( async def _create_with_stack(
@@ -115,26 +116,17 @@ class _FakeFactory(PersistentSessionFactory):
return await factory._call_tool(tool_name, payload) return await factory._call_tool(tool_name, payload)
async def read_resource(self, uri: AnyUrl): async def read_resource(self, uri: AnyUrl):
return type( return ReadResourceResult.model_validate(
"ReadResourceResult", {"contents": [{"uri": str(uri), "text": "resource text"}]}
(), )
{
"model_dump": lambda _self, **_kwargs: {
"contents": [{"uri": str(uri), "text": "resource text"}]
}
},
)()
async def get_prompt( async def get_prompt(
self, self,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
): ) -> GetPromptResult:
return type( return GetPromptResult.model_validate(
"GetPromptResult",
(),
{ {
"model_dump": lambda _self, **_kwargs: {
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@@ -145,18 +137,17 @@ class _FakeFactory(PersistentSessionFactory):
} }
] ]
} }
}, )
)()
async def list_resources(self) -> ListResourcesResult: async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult( return ListResourcesResult(
resources=[ resources=[
Resource( Resource(
uri=AnyUrl("fixture://docs/runtime"), uri=("fixture://docs/runtime"),
name="resource.runtime", name="resource.runtime",
title="Runtime Resource", title="Runtime Resource",
description="Runtime-scoped resource.", description="Runtime-scoped resource.",
mimeType="text/plain", mime_type="text/plain",
) )
] ]
) )
@@ -180,7 +171,7 @@ class _FakeFactory(PersistentSessionFactory):
name="tool.runtime", name="tool.runtime",
title="Runtime Tool", title="Runtime Tool",
description="Runtime-scoped tool.", description="Runtime-scoped tool.",
inputSchema={"type": "object"}, input_schema={"type": "object"},
) )
] ]
) )
@@ -205,9 +196,9 @@ class _FakeFactory(PersistentSessionFactory):
async def send_request( async def send_request(
self, self,
request: ClientRequest, request: ClientRequest,
result_type: type[ClientResult], result_type: type[ServerResult],
) -> ClientResult: ) -> ServerResult:
return ClientResult.model_validate( return server_result_adapter.validate_python(
{"jsonrpc": "2.0", "id": 1, "result": {}} {"jsonrpc": "2.0", "id": 1, "result": {}}
) )
@@ -360,11 +351,9 @@ async def test_persistent_session_factory_routes_resource_reads_through_owner()
assert factory.created_connections == [connection] assert factory.created_connections == [connection]
assert factory.calls == [("echo", {"text": "one"})] assert factory.calls == [("echo", {"text": "one"})]
assert resource_payload == { assert resource_payload["contents"] == [
"contents": [ {"uri": "fixture://docs/welcome", "text": "resource text"}
{"uri": "fixture://docs/welcome", "text": "resource text"},
] ]
}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -564,7 +553,7 @@ async def test_persistent_session_list_tools_client_fallback() -> None:
Tool( Tool(
name="client_tool", name="client_tool",
description="Client tool", description="Client tool",
inputSchema={"type": "object"}, input_schema={"type": "object"},
) )
] ]
) )
@@ -582,17 +571,13 @@ async def test_persistent_session_list_tools_client_fallback() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_persistent_session_invoke_method_client_fallback() -> None: async def test_persistent_session_invoke_method_client_fallback() -> None:
from mcp import ClientResult
class _MinimalClient: class _MinimalClient:
async def send_request( async def send_request(
self, self,
request: ClientRequest, request: ClientRequest,
result_type: type[ClientResult], result_type: type[ServerResult] | TypeAdapter[ServerResult],
) -> ClientResult: ) -> ServerResult:
return ClientResult.model_validate( return server_result_adapter.validate_python({"tools": []})
{"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
)
session = PersistentMcpSession( session = PersistentMcpSession(
connection=_connection(), connection=_connection(),
@@ -601,8 +586,7 @@ async def test_persistent_session_invoke_method_client_fallback() -> None:
) )
result = await session.invoke_method("tools/list") result = await session.invoke_method("tools/list")
assert result["tools"] == []
assert result["result"]["tools"] == []
@pytest.mark.asyncio @pytest.mark.asyncio
+16 -26
View File
@@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
from typing import Any from typing import Any
import pytest import pytest
from mcp import ClientResult from mcp import GetPromptResult, ReadResourceResult, ServerResult
from mcp.types import ( from mcp.types import (
CallToolResult, CallToolResult,
ClientNotification, ClientNotification,
@@ -18,7 +18,7 @@ from mcp.types import (
TextContent, TextContent,
Tool, Tool,
) )
from pydantic import AnyUrl from pydantic import TypeAdapter
from wf_sources_mcp.client import McpSourceClient from wf_sources_mcp.client import McpSourceClient
from wf_sources_mcp.connections import McpSourceConnection from wf_sources_mcp.connections import McpSourceConnection
@@ -47,7 +47,7 @@ class _FakeSession:
name="echo", name="echo",
title="Echo", title="Echo",
description="Echo text.", description="Echo text.",
inputSchema={"type": "object", "properties": {}}, input_schema={"type": "object", "properties": {}},
) )
] ]
) )
@@ -56,11 +56,11 @@ class _FakeSession:
return ListResourcesResult( return ListResourcesResult(
resources=[ resources=[
Resource( Resource(
uri=AnyUrl("fixture://docs/welcome"), uri="fixture://docs/welcome",
name="resource.welcome", name="resource.welcome",
title="Welcome", title="Welcome",
description="Welcome resource.", description="Welcome resource.",
mimeType="text/plain", mime_type="text/plain",
) )
] ]
) )
@@ -77,27 +77,18 @@ class _FakeSession:
] ]
) )
async def read_resource(self, uri: AnyUrl) -> Any: async def read_resource(self, uri: str) -> ReadResourceResult:
return type( return ReadResourceResult.model_validate(
"ReadResourceResult", {"contents": [{"uri": str(uri), "text": "hello"}]}
(), )
{
"model_dump": lambda _self, **_kwargs: {
"contents": [{"uri": str(uri), "text": "hello"}]
}
},
)()
async def get_prompt( async def get_prompt(
self, self,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
) -> Any: ) -> GetPromptResult:
return type( return GetPromptResult.model_validate(
"GetPromptResult",
(),
{ {
"model_dump": lambda _self, **_kwargs: {
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@@ -108,18 +99,17 @@ class _FakeSession:
} }
] ]
} }
}, )
)()
async def send_request( async def send_request(
self, self,
request: ClientRequest, request: ClientRequest,
result_type: type[ClientResult], result_type: type[ServerResult] | TypeAdapter[ServerResult],
) -> Any: ) -> Any:
assert result_type is ClientResult assert isinstance(result_type, TypeAdapter)
self.requests.append(request) self.requests.append(request)
return type( return type(
"ClientResultModel", "ServerResultModel",
(), (),
{"model_dump": lambda _self, **_kwargs: {"ok": True}}, {"model_dump": lambda _self, **_kwargs: {"ok": True}},
)() )()
@@ -134,7 +124,7 @@ class _FakeSession:
) -> CallToolResult: ) -> CallToolResult:
return CallToolResult( return CallToolResult(
content=[TextContent(type="text", text="ok")], content=[TextContent(type="text", text="ok")],
structuredContent={"tool": tool_name, "payload": payload}, structured_content={"tool": tool_name, "payload": payload},
) )
+16 -25
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Any from typing import Any
import pytest import pytest
from mcp import ClientResult from mcp import GetPromptResult, ReadResourceResult, ServerResult
from mcp.types import ( from mcp.types import (
CallToolResult, CallToolResult,
ClientNotification, ClientNotification,
@@ -16,7 +16,7 @@ from mcp.types import (
TextContent, TextContent,
Tool, Tool,
) )
from pydantic import AnyUrl from pydantic import TypeAdapter
from wf_sources_mcp.client import McpSourceClient from wf_sources_mcp.client import McpSourceClient
from wf_sources_mcp.connections import McpSourceConnection from wf_sources_mcp.connections import McpSourceConnection
@@ -44,7 +44,7 @@ class _FakeSession:
name="echo", name="echo",
title="Echo", title="Echo",
description="Echo text.", description="Echo text.",
inputSchema={"type": "object", "properties": {}}, input_schema={"type": "object", "properties": {}},
) )
] ]
) )
@@ -53,11 +53,11 @@ class _FakeSession:
return ListResourcesResult( return ListResourcesResult(
resources=[ resources=[
Resource( Resource(
uri=AnyUrl("fixture://docs/welcome"), uri=("fixture://docs/welcome"),
name="resource.welcome", name="resource.welcome",
title="Welcome", title="Welcome",
description="Welcome resource.", description="Welcome resource.",
mimeType="text/plain", mime_type="text/plain",
) )
] ]
) )
@@ -74,27 +74,19 @@ class _FakeSession:
] ]
) )
async def read_resource(self, uri: AnyUrl) -> Any: # TODO investigate why these are uses type/3 for object creation
return type( async def read_resource(self, uri: str) -> ReadResourceResult:
"ReadResourceResult", return ReadResourceResult.model_validate(
(), {"contents": [{"uri": str(uri), "text": "hello"}]}
{ )
"model_dump": lambda _self, **_kwargs: {
"contents": [{"uri": str(uri), "text": "hello"}]
}
},
)()
async def get_prompt( async def get_prompt(
self, self,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
) -> Any: ) -> GetPromptResult:
return type( return GetPromptResult.model_validate(
"GetPromptResult",
(),
{ {
"model_dump": lambda _self, **_kwargs: {
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@@ -105,15 +97,14 @@ class _FakeSession:
} }
] ]
} }
}, )
)()
async def send_request( async def send_request(
self, self,
request: ClientRequest, request: ClientRequest,
result_type: type[ClientResult], result_type: type[ServerResult] | TypeAdapter[ServerResult],
) -> Any: ) -> Any:
assert result_type is ClientResult assert isinstance(result_type, TypeAdapter)
self.requests.append(request) self.requests.append(request)
return type( return type(
"ClientResultModel", "ClientResultModel",
@@ -131,7 +122,7 @@ class _FakeSession:
) -> CallToolResult: ) -> CallToolResult:
return CallToolResult( return CallToolResult(
content=[TextContent(type="text", text="ok")], content=[TextContent(type="text", text="ok")],
structuredContent={"tool": tool_name, "payload": payload}, structured_content={"tool": tool_name, "payload": payload},
) )