Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions python/packages/core/agent_framework/_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions python/packages/core/agent_framework/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.

Expand All @@ -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
Comment on lines +2725 to +2726
return attributes


Expand Down
82 changes: 82 additions & 0 deletions python/packages/core/tests/core/test_mcp_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading