From 9c7c2e0063f0519cc3abbc715fd9a9acfb518181 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Wed, 13 May 2026 23:58:44 +0800 Subject: [PATCH 1/2] Python: forward MCP tool call metadata --- python/packages/core/agent_framework/_mcp.py | 12 +++-- python/packages/core/tests/core/test_mcp.py | 47 ++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 0d85b1699ae..98008727cb7 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -261,6 +261,7 @@ def __init__( self.request_timeout = request_timeout self.client = client self._functions: list[FunctionTool] = [] + self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {} self.is_connected: bool = False self._tools_loaded: bool = False self._prompts_loaded: bool = False @@ -940,6 +941,7 @@ async def load_prompts(self) -> None: # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} + self._tool_call_meta_by_name.clear() params: types.PaginatedRequestParams | None = None while True: @@ -999,6 +1001,9 @@ async def load_tools(self) -> None: tool_list = await self.session.list_tools(params=params) # type: ignore[union-attr] for tool in tool_list.tools: + if tool.meta is not None: + self._tool_call_meta_by_name[tool.name] = dict(tool.meta) + normalized_name = _normalize_mcp_name(tool.name) local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix) @@ -1141,14 +1146,15 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: } } - # Inject OpenTelemetry trace context into MCP _meta for distributed tracing. - otel_meta = _inject_otel_into_mcp_meta() + # Some MCP proxies require their tools/list metadata to be echoed on tools/call. + tool_meta = self._tool_call_meta_by_name.get(tool_name) + meta = _inject_otel_into_mcp_meta(dict(tool_meta) if tool_meta is not None else None) parser = self.parse_tool_results or self._parse_tool_result_from_mcp # Try the operation, reconnecting once if the connection is closed for attempt in range(2): try: - result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=otel_meta) # type: ignore + result = await self.session.call_tool(tool_name, arguments=filtered_kwargs, meta=meta) # type: ignore if result.isError: parsed = parser(result) text = ( diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 487331e3f0d..095effe24b4 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -4084,6 +4084,53 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: assert meta is None +async def test_mcp_tool_call_tool_forwards_tool_list_meta(): + """call_tool echoes per-tool metadata returned by tools/list.""" + from opentelemetry import trace + + tool_meta = { + "tool_configuration": { + "name": "WorkIQSharePoint.readSmallBinaryFile", + "type": "foundry_toolbox", + } + } + + class TestServer(MCPTool): + async def connect(self): + self.session = Mock(spec=ClientSession) + self.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool( + name="WorkIQSharePoint.readSmallBinaryFile", + description="Read a binary file", + inputSchema={ + "type": "object", + "properties": {"fileId": {"type": "string"}}, + "required": ["fileId"], + }, + _meta=tool_meta, + ) + ] + ) + ) + self.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")]) + ) + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return None + + server = TestServer(name="test_server") + async with server: + await server.load_tools() + + with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)): + await server.call_tool("WorkIQSharePoint.readSmallBinaryFile", fileId="file-1") + + assert server.session.call_tool.call_args.kwargs["meta"] == tool_meta + + async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client(): """Test that calling get_mcp_client multiple times does not accumulate duplicate hooks.""" tool = MCPStreamableHTTPTool( From 6a285ef567663517d4f62baa03b4ec43a9c6eeeb Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 14 May 2026 15:22:51 +0800 Subject: [PATCH 2/2] fix: preserve MCP tool meta after prompt reload --- python/packages/core/agent_framework/_mcp.py | 2 +- python/packages/core/tests/core/test_mcp.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 75a632ba031..35ccb1d58a9 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -977,7 +977,6 @@ async def load_prompts(self) -> None: # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} - self._tool_call_meta_by_name.clear() params: types.PaginatedRequestParams | None = None while True: @@ -1028,6 +1027,7 @@ async def load_tools(self) -> None: # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} + self._tool_call_meta_by_name.clear() params: types.PaginatedRequestParams | None = None while True: diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 75cd74c46e8..0fc5867d798 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -4227,6 +4227,9 @@ async def connect(self): self.session.call_tool = AsyncMock( return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")]) ) + self.session.list_prompts = AsyncMock( + return_value=types.ListPromptsResult(prompts=[]) + ) def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: return None @@ -4234,6 +4237,7 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: server = TestServer(name="test_server") async with server: await server.load_tools() + await server.load_prompts() with trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)): await server.call_tool("WorkIQSharePoint.readSmallBinaryFile", fileId="file-1")