Skip to content

Commit 1a16c8c

Browse files
committed
feat(client): add _meta support to call_tool for tool calls\n\n- Adds optional _meta parameter to ClientSession.call_tool and ClientSessionGroup.call_tool\n- Passes _meta as request metadata for tool calls, enabling user-supplied context or progress tokens\n- Backward compatible: does not affect existing usage\n- Adds/updates tests to verify _meta passthrough and correct request construction
1 parent d0443a1 commit 1a16c8c

File tree

4 files changed

+123
-3
lines changed

4 files changed

+123
-3
lines changed

src/mcp/client/session.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,12 @@ async def call_tool(
282282
arguments: dict[str, Any] | None = None,
283283
read_timeout_seconds: timedelta | None = None,
284284
progress_callback: ProgressFnT | None = None,
285+
_meta: dict[str, Any] | None = None,
285286
) -> types.CallToolResult:
286-
"""Send a tools/call request with optional progress callback support."""
287+
"""Send a tools/call request with optional progress callback."""
288+
289+
# Create the Meta object if _meta is provided
290+
meta_obj = types.RequestParams.Meta(**_meta) if _meta else None
287291

288292
return await self.send_request(
289293
types.ClientRequest(
@@ -292,6 +296,7 @@ async def call_tool(
292296
params=types.CallToolRequestParams(
293297
name=name,
294298
arguments=arguments,
299+
_meta=meta_obj,
295300
),
296301
)
297302
),

src/mcp/client/session_group.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,13 @@ def tools(self) -> dict[str, types.Tool]:
172172
"""Returns the tools as a dictionary of names to tools."""
173173
return self._tools
174174

175-
async def call_tool(self, name: str, args: dict[str, Any]) -> types.CallToolResult:
175+
async def call_tool(
176+
self, name: str, args: dict[str, Any], _meta: dict[str, Any] | None = None
177+
) -> types.CallToolResult:
176178
"""Executes a tool given its name and arguments."""
177179
session = self._tool_to_session[name]
178180
session_tool_name = self.tools[name].name
179-
return await session.call_tool(session_tool_name, args)
181+
return await session.call_tool(session_tool_name, args, _meta=_meta)
180182

181183
async def disconnect_from_server(self, session: mcp.ClientSession) -> None:
182184
"""Disconnects from a single MCP server."""

tests/client/test_session.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,3 +495,82 @@ async def mock_server():
495495
assert received_capabilities.roots is not None # Custom list_roots callback provided
496496
assert isinstance(received_capabilities.roots, types.RootsCapability)
497497
assert received_capabilities.roots.listChanged is True # Should be True for custom callback
498+
499+
500+
@pytest.mark.anyio
501+
async def test_client_session_call_tool_with_meta():
502+
"""Test that call_tool properly handles the _meta parameter."""
503+
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage](1)
504+
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
505+
506+
received_request = None
507+
508+
async def mock_server():
509+
nonlocal received_request
510+
511+
session_message = await client_to_server_receive.receive()
512+
jsonrpc_request = session_message.message
513+
assert isinstance(jsonrpc_request.root, JSONRPCRequest)
514+
request = ClientRequest.model_validate(
515+
jsonrpc_request.model_dump(by_alias=True, mode="json", exclude_none=True)
516+
)
517+
received_request = request
518+
519+
# Send a successful response
520+
result = ServerResult(
521+
types.CallToolResult(
522+
content=[types.TextContent(type="text", text="Tool executed successfully")],
523+
isError=False,
524+
)
525+
)
526+
527+
async with server_to_client_send:
528+
await server_to_client_send.send(
529+
SessionMessage(
530+
JSONRPCMessage(
531+
JSONRPCResponse(
532+
jsonrpc="2.0",
533+
id=jsonrpc_request.root.id,
534+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
535+
)
536+
)
537+
)
538+
)
539+
540+
async with (
541+
ClientSession(
542+
server_to_client_receive,
543+
client_to_server_send,
544+
) as session,
545+
anyio.create_task_group() as tg,
546+
client_to_server_send,
547+
client_to_server_receive,
548+
server_to_client_send,
549+
server_to_client_receive,
550+
):
551+
tg.start_soon(mock_server)
552+
553+
# Test call_tool with _meta parameter
554+
meta_data = {"user_id": "12345", "session_id": "abc123"}
555+
result = await session.call_tool(
556+
name="test_tool",
557+
arguments={"param1": "value1"},
558+
_meta=meta_data,
559+
)
560+
561+
# Assert that the request was sent with the correct meta data
562+
assert received_request is not None
563+
assert isinstance(received_request.root, types.CallToolRequest)
564+
assert received_request.root.params.name == "test_tool"
565+
assert received_request.root.params.arguments == {"param1": "value1"}
566+
assert received_request.root.params.meta is not None
567+
assert received_request.root.params.meta.progressToken is None # No progressToken in our test meta
568+
# The meta object should contain our custom data
569+
assert hasattr(received_request.root.params.meta, "user_id")
570+
assert hasattr(received_request.root.params.meta, "session_id")
571+
572+
# Assert the result
573+
assert isinstance(result, types.CallToolResult)
574+
assert len(result.content) == 1
575+
assert isinstance(result.content[0], types.TextContent)
576+
assert result.content[0].text == "Tool executed successfully"

tests/client/test_session_group.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,40 @@ def hook(name, server_info):
7777
mock_session.call_tool.assert_called_once_with(
7878
"my_tool",
7979
{"name": "value1", "args": {}},
80+
_meta=None,
81+
)
82+
83+
async def test_call_tool_with_meta(self):
84+
# --- Mock Dependencies ---
85+
mock_session = mock.AsyncMock()
86+
87+
# --- Prepare Session Group ---
88+
def hook(name, server_info):
89+
return f"{(server_info.name)}-{name}"
90+
91+
mcp_session_group = ClientSessionGroup(component_name_hook=hook)
92+
mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", inputSchema={})}
93+
mcp_session_group._tool_to_session = {"server1-my_tool": mock_session}
94+
text_content = types.TextContent(type="text", text="OK")
95+
mock_session.call_tool.return_value = types.CallToolResult(content=[text_content])
96+
97+
# --- Test Execution with _meta ---
98+
meta_data = {"user_id": "12345", "session_id": "abc123"}
99+
result = await mcp_session_group.call_tool(
100+
name="server1-my_tool",
101+
args={
102+
"name": "value1",
103+
"args": {},
104+
},
105+
_meta=meta_data,
106+
)
107+
108+
# --- Assertions ---
109+
assert result.content == [text_content]
110+
mock_session.call_tool.assert_called_once_with(
111+
"my_tool",
112+
{"name": "value1", "args": {}},
113+
_meta=meta_data,
80114
)
81115

82116
async def test_connect_to_server(self, mock_exit_stack):

0 commit comments

Comments
 (0)