Skip to content

Commit f5b9f39

Browse files
committed
Address PR review findings
1 parent 2c4b51a commit f5b9f39

File tree

4 files changed

+88
-4
lines changed

4 files changed

+88
-4
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async def main():
6363
from mcp.server.lowlevel.experimental import ExperimentalHandlers
6464
from mcp.server.models import InitializationOptions
6565
from mcp.server.session import ServerSession
66-
from mcp.server.streamable_http import EventStore
66+
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore
6767
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
6868
from mcp.server.transport_security import TransportSecuritySettings
6969
from mcp.shared._otel import build_server_span_attributes, extract_trace_context, otel_span
@@ -73,7 +73,6 @@ async def main():
7373
from mcp.shared.session import RequestResponder
7474

7575
logger = logging.getLogger(__name__)
76-
MCP_SESSION_ID_HEADER = "mcp-session-id"
7776

7877
LifespanResultT = TypeVar("LifespanResultT", default=Any)
7978

src/mcp/shared/_otel.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from collections.abc import Iterator
66
from contextlib import contextmanager
7-
from typing import Any
7+
from typing import Any, cast
88

99
from opentelemetry.context import Context
1010
from opentelemetry.propagate import extract, inject
@@ -51,7 +51,16 @@ def build_client_span_attributes(
5151
"jsonrpc.request.id": request_id,
5252
}
5353

54-
if params is not None and (resource_uri := params.get("uri")) is not None:
54+
resource_uri = None
55+
if params is not None:
56+
resource_uri = params.get("uri")
57+
if resource_uri is None:
58+
ref = params.get("ref")
59+
if isinstance(ref, dict):
60+
typed_ref = cast(dict[str, Any], ref)
61+
resource_uri = typed_ref.get("uri")
62+
63+
if resource_uri is not None:
5564
attributes["mcp.resource.uri"] = resource_uri
5665

5766
return attributes
@@ -75,6 +84,9 @@ def build_server_span_attributes(
7584
}
7685

7786
resource_uri = getattr(params, "uri", None)
87+
if resource_uri is None:
88+
ref = getattr(params, "ref", None)
89+
resource_uri = getattr(ref, "uri", None)
7890
if resource_uri is not None:
7991
attributes["mcp.resource.uri"] = str(resource_uri)
8092

tests/shared/test_otel.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,49 @@ def test_resource() -> str:
7878
assert server_span["attributes"]["rpc.method"] == "resources/read"
7979
assert server_span["attributes"]["mcp.method.name"] == "resources/read"
8080
assert server_span["attributes"]["mcp.resource.uri"] == "test://resource"
81+
82+
# Server span should be in the same trace as the client span (context propagation).
83+
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
84+
85+
86+
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
87+
async def test_completion_spans_include_resource_template_uri(capfire: CaptureLogfire):
88+
"""Verify completion spans include the referenced resource template URI."""
89+
server = MCPServer("test")
90+
91+
@server.completion()
92+
async def handle_completion(
93+
ref: types.ResourceTemplateReference | types.PromptReference,
94+
argument: types.CompletionArgument,
95+
context: types.CompletionContext | None,
96+
) -> types.Completion:
97+
assert isinstance(ref, types.ResourceTemplateReference)
98+
assert argument.name == "path"
99+
assert argument.value == "rea"
100+
assert context is None
101+
return types.Completion(values=["README.md"])
102+
103+
async with Client(server) as client:
104+
result = await client.complete(
105+
ref=types.ResourceTemplateReference(type="ref/resource", uri="repo://files/{path}"),
106+
argument={"name": "path", "value": "rea"},
107+
)
108+
109+
assert result.completion.values == ["README.md"]
110+
111+
spans = capfire.exporter.exported_spans_as_dict()
112+
113+
client_span = next(s for s in spans if s["name"] == "MCP send completion/complete")
114+
server_span = next(s for s in spans if s["name"] == "MCP handle completion/complete")
115+
116+
assert client_span["attributes"]["rpc.system"] == "mcp"
117+
assert client_span["attributes"]["rpc.method"] == "completion/complete"
118+
assert client_span["attributes"]["mcp.method.name"] == "completion/complete"
119+
assert client_span["attributes"]["mcp.resource.uri"] == "repo://files/{path}"
120+
121+
assert server_span["attributes"]["rpc.system"] == "mcp"
122+
assert server_span["attributes"]["rpc.service"] == "test"
123+
assert server_span["attributes"]["rpc.method"] == "completion/complete"
124+
assert server_span["attributes"]["mcp.method.name"] == "completion/complete"
125+
assert server_span["attributes"]["mcp.resource.uri"] == "repo://files/{path}"
126+
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]

tests/shared/test_streamable_http.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import requests
2424
import uvicorn
2525
from httpx_sse import ServerSentEvent
26+
from logfire.testing import CaptureLogfire
2627
from starlette.applications import Starlette
2728
from starlette.requests import Request
2829
from starlette.routing import Mount
@@ -1081,6 +1082,32 @@ async def test_streamable_http_client_resource_read(initialized_client_session:
10811082
assert response.contents[0].text == "Read test-resource"
10821083

10831084

1085+
@pytest.mark.anyio
1086+
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
1087+
async def test_streamable_http_server_span_includes_session_id(capfire: CaptureLogfire):
1088+
"""Verify streamable HTTP server spans include the negotiated MCP session ID."""
1089+
app = _create_server()
1090+
mcp_app = app.streamable_http_app(host="testserver")
1091+
1092+
async with (
1093+
mcp_app.router.lifespan_context(mcp_app),
1094+
httpx.ASGITransport(mcp_app) as transport,
1095+
httpx.AsyncClient(transport=transport, base_url="http://testserver") as http_client,
1096+
streamable_http_client("http://testserver/mcp", http_client=http_client) as (read_stream, write_stream),
1097+
ClientSession(read_stream, write_stream) as session,
1098+
):
1099+
await session.initialize()
1100+
response = await session.read_resource(uri="foobar://test-resource")
1101+
1102+
assert response.contents[0].uri == "foobar://test-resource"
1103+
1104+
spans = capfire.exporter.exported_spans_as_dict()
1105+
server_span = next(s for s in spans if s["name"] == "MCP handle resources/read")
1106+
1107+
assert server_span["attributes"]["mcp.session.id"]
1108+
assert server_span["attributes"]["mcp.resource.uri"] == "foobar://test-resource"
1109+
1110+
10841111
@pytest.mark.anyio
10851112
async def test_streamable_http_client_tool_invocation(initialized_client_session: ClientSession):
10861113
"""Test client tool invocation."""

0 commit comments

Comments
 (0)