diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index a7a3f1a796..35ccb1d58a 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -261,6 +261,7 @@ class MCPTool: 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 @@ -1026,6 +1027,7 @@ class MCPTool: # 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: @@ -1035,6 +1037,9 @@ class MCPTool: 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) @@ -1185,14 +1190,15 @@ class MCPTool: } } - # 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 cd3173a7d3..0fc5867d79 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -4194,6 +4194,57 @@ async def test_mcp_tool_call_tool_otel_meta(use_span, expect_traceparent, span_e 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")]) + ) + self.session.list_prompts = AsyncMock( + return_value=types.ListPromptsResult(prompts=[]) + ) + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + return 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") + + 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(