diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index e3ca013f908..49dee254db1 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -50,6 +50,8 @@ ) from .exceptions import ToolException, ToolExecutionException from .observability import ( + MCP_SERVER_NAME_PROPERTY, + MCP_SERVER_URL_PROPERTY, OtelAttr, create_mcp_client_span, set_mcp_span_error, @@ -808,6 +810,46 @@ def _get_input_model_from_mcp_prompt(prompt: types.Prompt) -> dict[str, Any]: return schema +def _sanitize_mcp_server_url(url: str | None) -> str | None: + """Return an http(s) URL without user info, query string or fragment, else ``None``. + + Query strings and user info commonly carry secrets (API keys, SAS tokens, function keys), so only the + scheme, host, port and path are ever copied onto a span. + """ + if not isinstance(url, str) or not url: + return None + from urllib.parse import urlsplit + + try: + parts = urlsplit(url.strip()) + host = parts.hostname + port = parts.port + except ValueError: + return None + if parts.scheme not in {"http", "https"} or not host: + return None + netloc = f"[{host}]" if ":" in host else host + if port is not None: + netloc = f"{netloc}:{port}" + return f"{parts.scheme}://{netloc}{parts.path}" + + +def _mcp_server_identity_properties(tool: Any) -> dict[str, str]: + """Build the ``additional_properties`` entries that identify the MCP server behind an MCP function. + + These feed ``microsoft.tool.server.name`` and ``url.full`` on the ``execute_tool`` span. Values that are + unavailable (for example, a stdio server has no URL) are omitted. + """ + properties: dict[str, str] = {} + name = getattr(tool, "name", None) + if isinstance(name, str) and name: + properties[MCP_SERVER_NAME_PROPERTY] = name + url = _sanitize_mcp_server_url(getattr(tool, "url", None)) + if url: + properties[MCP_SERVER_URL_PROPERTY] = url + return properties + + def _normalize_mcp_name(name: str) -> str: """Normalize MCP tool/prompt names to allowed identifier pattern (A-Za-z0-9_.-).""" return re.sub(r"[^A-Za-z0-9_.-]", "-", name) @@ -3253,6 +3295,7 @@ async def _load_tools_locked(self) -> None: _MCP_REMOTE_NAME_KEY: tool.name, _MCP_NORMALIZED_NAME_KEY: normalized_name, _MCP_IS_TOOL_KEY: True, + **_mcp_server_identity_properties(self), }, ) new_functions.append(func) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 9fec610e248..ac8e8c96032 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -348,6 +348,9 @@ class OtelAttr(str, Enum): MCP_METHOD_NAME = "mcp.method.name" MCP_PROTOCOL_VERSION = "mcp.protocol.version" MCP_SESSION_ID = "mcp.session.id" + # Identity of the MCP server an MCP-served tool call was routed to (set on execute_tool spans). + TOOL_SERVER_NAME = "microsoft.tool.server.name" + URL_FULL = "url.full" PROMPT_NAME = "gen_ai.prompt.name" NETWORK_TRANSPORT = "network.transport" NETWORK_PROTOCOL_NAME = "network.protocol.name" @@ -2689,6 +2692,13 @@ def run( # region Otel Helpers +# ``FunctionTool.additional_properties`` keys under which an MCP-served function carries the identity of the +# MCP server it calls. ``get_function_span_attributes`` reads them to stamp ``microsoft.tool.server.name`` and +# ``url.full`` on the ``execute_tool`` span. The URL is stored already stripped of credentials and query string. +MCP_SERVER_NAME_PROPERTY = "_mcp_server_name" +MCP_SERVER_URL_PROPERTY = "_mcp_server_url" + + def get_function_span_attributes(function: FunctionTool, tool_call_id: str | None = None) -> dict[str, str]: """Get the span attributes for the given function. @@ -2707,6 +2717,13 @@ def get_function_span_attributes(function: FunctionTool, tool_call_id: str | Non } if function.description: attributes[OtelAttr.TOOL_DESCRIPTION] = function.description + properties = function.additional_properties or {} + server_name = properties.get(MCP_SERVER_NAME_PROPERTY) + if isinstance(server_name, str) and server_name: + attributes[OtelAttr.TOOL_SERVER_NAME] = server_name + server_url = properties.get(MCP_SERVER_URL_PROPERTY) + if isinstance(server_url, str) and server_url: + attributes[OtelAttr.URL_FULL] = server_url return attributes diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index 47a28b9d205..302830e55aa 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -510,3 +510,85 @@ async def test_mcp_spans_not_created_when_observability_disabled(span_exporter: # endregion + + +# region execute_tool server identity + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("https://user:pw@mcp.example.com:8443/api/mcp?code=secret#frag", "https://mcp.example.com:8443/api/mcp"), + ("http://localhost/mcp", "http://localhost/mcp"), + ("https://[::1]:9000/mcp?x=1", "https://[::1]:9000/mcp"), + ("ftp://example.com/mcp", None), + ("not a url", None), + ("", None), + (None, None), + ], +) +def test_sanitize_mcp_server_url(raw: str | None, expected: str | None): + from agent_framework._mcp import _sanitize_mcp_server_url + + assert _sanitize_mcp_server_url(raw) == expected + + +def test_mcp_server_identity_properties_for_http_tool(): + from agent_framework._mcp import _mcp_server_identity_properties + from agent_framework.observability import MCP_SERVER_NAME_PROPERTY, MCP_SERVER_URL_PROPERTY + + tool = MCPStreamableHTTPTool(name="weather", url="https://mcp.example.com/mcp?key=secret") + + assert _mcp_server_identity_properties(tool) == { + MCP_SERVER_NAME_PROPERTY: "weather", + MCP_SERVER_URL_PROPERTY: "https://mcp.example.com/mcp", + } + + +def test_mcp_server_identity_properties_for_stdio_tool_omits_url(): + from agent_framework._mcp import _mcp_server_identity_properties + from agent_framework.observability import MCP_SERVER_NAME_PROPERTY + + tool = MCPStdioTool(name="local", command="python") + + assert _mcp_server_identity_properties(tool) == {MCP_SERVER_NAME_PROPERTY: "local"} + + +def test_function_span_attributes_include_mcp_server_identity(): + from agent_framework import FunctionTool + from agent_framework.observability import ( + MCP_SERVER_NAME_PROPERTY, + MCP_SERVER_URL_PROPERTY, + get_function_span_attributes, + ) + + function = FunctionTool( + name="get_weather", + description="", + func=lambda: "ok", + additional_properties={ + MCP_SERVER_NAME_PROPERTY: "weather", + MCP_SERVER_URL_PROPERTY: "https://mcp.example.com/mcp", + }, + ) + + attributes = get_function_span_attributes(function, "call-1") + + assert attributes[OtelAttr.TOOL_SERVER_NAME] == "weather" + assert attributes[OtelAttr.URL_FULL] == "https://mcp.example.com/mcp" + assert attributes[OtelAttr.TOOL_NAME] == "get_weather" + + +def test_function_span_attributes_omit_server_identity_for_plain_function(): + from agent_framework import FunctionTool + from agent_framework.observability import get_function_span_attributes + + function = FunctionTool(name="plain", description="", func=lambda: "ok") + + attributes = get_function_span_attributes(function, "call-1") + + assert OtelAttr.TOOL_SERVER_NAME not in attributes + assert OtelAttr.URL_FULL not in attributes + + +# endregion