From 7750c5f2f11fcd848627e77d9ac812ae25acbfa6 Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Sun, 3 Aug 2025 15:19:16 -0300 Subject: [PATCH 1/9] feat: expose RequestParams._meta in ClientSession.call_tool --- src/mcp/client/session.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 1853ce7c1b..258eb195be 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -286,6 +286,7 @@ async def call_tool( name: str, arguments: dict[str, Any] | None = None, read_timeout_seconds: timedelta | None = None, + meta: dict[str, Any] | None = None, progress_callback: ProgressFnT | None = None, ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" @@ -297,6 +298,9 @@ async def call_tool( params=types.CallToolRequestParams( name=name, arguments=arguments, + _meta=types.RequestParams.Meta( + **(meta or {}) + ) ), ) ), From 16a171f8abcc1859b034efb0bb0f3cc6edff414c Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Sun, 3 Aug 2025 15:46:45 -0300 Subject: [PATCH 2/9] style: apply ruff format --- src/mcp/client/session.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 258eb195be..8fd5d036c6 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -296,11 +296,7 @@ async def call_tool( types.CallToolRequest( method="tools/call", params=types.CallToolRequestParams( - name=name, - arguments=arguments, - _meta=types.RequestParams.Meta( - **(meta or {}) - ) + name=name, arguments=arguments, _meta=types.RequestParams.Meta(**(meta or {})) ), ) ), From 9b0efae754f130389eaca4e24cd90d37801b6dae Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Mon, 4 Aug 2025 13:24:02 -0300 Subject: [PATCH 3/9] refactor: make _meta handling more readable --- src/mcp/client/session.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 8fd5d036c6..6dec885c77 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -291,13 +291,15 @@ async def call_tool( ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" + _meta: types.ReadResourceRequestParams.Meta | None = None + if meta is not None: + _meta = types.RequestParams.Meta(**meta) + result = await self.send_request( types.ClientRequest( types.CallToolRequest( method="tools/call", - params=types.CallToolRequestParams( - name=name, arguments=arguments, _meta=types.RequestParams.Meta(**(meta or {})) - ), + params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=_meta), ) ), types.CallToolResult, From d5c05497cc79b2528ff9cb22926a0099e22afc05 Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Mon, 4 Aug 2025 13:24:45 -0300 Subject: [PATCH 4/9] fix: reorder meta argument to avoid a BC --- src/mcp/client/session.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 6dec885c77..6115e915c9 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -286,8 +286,9 @@ async def call_tool( name: str, arguments: dict[str, Any] | None = None, read_timeout_seconds: timedelta | None = None, - meta: dict[str, Any] | None = None, progress_callback: ProgressFnT | None = None, + *, + meta: dict[str, Any] | None = None, ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" From 8f2ebb6de35153a443f67316f3026ab1fa0d1c8b Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Mon, 13 Oct 2025 21:01:38 -0300 Subject: [PATCH 5/9] Use RequestParams.Meta for _meta in call_tool --- src/mcp/client/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 124a60d185..b10d6a6eed 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -278,7 +278,7 @@ async def call_tool( ) -> types.CallToolResult: """Send a tools/call request with optional progress callback support.""" - _meta: types.ReadResourceRequestParams.Meta | None = None + _meta: types.RequestParams.Meta | None = None if meta is not None: _meta = types.RequestParams.Meta(**meta) From 05e6b275849ec6e4c1e09414432168bbcc4481e4 Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Mon, 13 Oct 2025 21:47:29 -0300 Subject: [PATCH 6/9] Implement test to assert _meta behavior in tool call --- tests/client/test_session.py | 65 ++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 53b60fce61..b416933c67 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -11,6 +11,7 @@ from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( LATEST_PROTOCOL_VERSION, + CallToolResult, ClientNotification, ClientRequest, Implementation, @@ -23,6 +24,7 @@ JSONRPCResponse, ServerCapabilities, ServerResult, + TextContent, ) @@ -492,8 +494,65 @@ async def mock_server(): # Assert that capabilities are properly set with custom callbacks assert received_capabilities is not None - assert received_capabilities.sampling is not None # Custom sampling callback provided + # Custom sampling callback provided + assert received_capabilities.sampling is not None assert isinstance(received_capabilities.sampling, types.SamplingCapability) - assert received_capabilities.roots is not None # Custom list_roots callback provided + # Custom list_roots callback provided + assert received_capabilities.roots is not None assert isinstance(received_capabilities.roots, types.RootsCapability) - assert received_capabilities.roots.listChanged is True # Should be True for custom callback + # Should be True for custom callback + assert received_capabilities.roots.listChanged is True + + +@pytest.mark.anyio +@pytest.mark.parametrize(argnames="meta", argvalues=[{"toolMeta": "value"}]) +async def test_client_tool_call_with_meta(meta: dict[str, Any] | None): + """Test that client tool call requests can include metadata.""" + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + + async def mock_server(): + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + + assert jsonrpc_request.root.method == "tools/call" + + if meta is not None: + assert jsonrpc_request.root.params + assert "_meta" in jsonrpc_request.root.params + assert jsonrpc_request.root.params["_meta"] == meta + + result = ServerResult( + CallToolResult(content=[TextContent(type="text", text="Called successfully")], isError=False) + ) + + async with server_to_client_send: + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + async with ( + ClientSession( + server_to_client_receive, + client_to_server_send, + ) as session, + anyio.create_task_group() as tg, + client_to_server_send, + client_to_server_receive, + server_to_client_send, + server_to_client_receive, + ): + tg.start_soon(mock_server) + + session._tool_output_schemas["sample_tool"] = None + + await session.call_tool(name="sample_tool", arguments={"foo": "bar"}, meta=meta) From 796938e8b542a9ac4ad42e9ecfd7fdac8608c21c Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Mon, 13 Oct 2025 22:20:05 -0300 Subject: [PATCH 7/9] Use default CallToolRequest.method value in call_tool --- src/mcp/client/session.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index b10d6a6eed..e57879e39d 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -285,7 +285,6 @@ async def call_tool( result = await self.send_request( types.ClientRequest( types.CallToolRequest( - method="tools/call", params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=_meta), ) ), From 701611ddf8e66ebd5d4c309738351e4f4d41e919 Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Mon, 13 Oct 2025 22:27:30 -0300 Subject: [PATCH 8/9] Include None case for test_client_tool_call_with_meta --- tests/client/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_session.py b/tests/client/test_session.py index b416933c67..2bfbb55ab1 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -505,7 +505,7 @@ async def mock_server(): @pytest.mark.anyio -@pytest.mark.parametrize(argnames="meta", argvalues=[{"toolMeta": "value"}]) +@pytest.mark.parametrize(argnames="meta", argvalues=[None, {"toolMeta": "value"}]) async def test_client_tool_call_with_meta(meta: dict[str, Any] | None): """Test that client tool call requests can include metadata.""" client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) From 2b61abba1368a760a42149d084291b56bcd318f2 Mon Sep 17 00:00:00 2001 From: Samuel Chenatti Date: Tue, 14 Oct 2025 09:49:52 -0300 Subject: [PATCH 9/9] Reimplement test_client_tool_call_with_meta to goes through all the protocol phases --- tests/client/test_session.py | 84 ++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 2bfbb55ab1..f2135e4552 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -507,11 +507,47 @@ async def mock_server(): @pytest.mark.anyio @pytest.mark.parametrize(argnames="meta", argvalues=[None, {"toolMeta": "value"}]) async def test_client_tool_call_with_meta(meta: dict[str, Any] | None): - """Test that client tool call requests can include metadata.""" + """Test that client tool call requests can include metadata""" client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1) server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1) + mocked_tool = types.Tool(name="sample_tool", inputSchema={}) + async def mock_server(): + # Receive initialization request from client + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + request = ClientRequest.model_validate( + jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True) + ) + assert isinstance(request.root, InitializeRequest) + + result = ServerResult( + InitializeResult( + protocolVersion=LATEST_PROTOCOL_VERSION, + capabilities=ServerCapabilities(), + serverInfo=Implementation(name="mock-server", version="0.1.0"), + ) + ) + + # Answer initialization request + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + # Receive initialized notification + await client_to_server_receive.receive() + + # Wait for the client to send a 'tools/call' request session_message = await client_to_server_receive.receive() jsonrpc_request = session_message.message assert isinstance(jsonrpc_request.root, JSONRPCRequest) @@ -527,18 +563,42 @@ async def mock_server(): CallToolResult(content=[TextContent(type="text", text="Called successfully")], isError=False) ) - async with server_to_client_send: - await server_to_client_send.send( - SessionMessage( - JSONRPCMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=jsonrpc_request.root.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) + # Send the tools/call result + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), ) ) ) + ) + + # Wait for the tools/list request from the client + # The client requires this step to validate the tool output schema + session_message = await client_to_server_receive.receive() + jsonrpc_request = session_message.message + assert isinstance(jsonrpc_request.root, JSONRPCRequest) + + assert jsonrpc_request.root.method == "tools/list" + + result = types.ListToolsResult(tools=[mocked_tool]) + + await server_to_client_send.send( + SessionMessage( + JSONRPCMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=jsonrpc_request.root.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + ) + + server_to_client_send.close() async with ( ClientSession( @@ -553,6 +613,6 @@ async def mock_server(): ): tg.start_soon(mock_server) - session._tool_output_schemas["sample_tool"] = None + await session.initialize() - await session.call_tool(name="sample_tool", arguments={"foo": "bar"}, meta=meta) + await session.call_tool(name=mocked_tool.name, arguments={"foo": "bar"}, meta=meta)