Skip to content

Commit a8b669c

Browse files
committed
test: ServerRunner coverage to 100% — otel span assertions + connected_runner harness
- Add opentelemetry-sdk as a dev dep and a tests/server/conftest.py 'spans' fixture (TracerProvider + InMemorySpanExporter) so otel_middleware's span contract is observable. - Replace the otel pass-through test with four span-asserting tests (name + target, _meta traceparent → parent, MCPError → ERROR status without traceback, unexpected exception → ERROR status + exception event). These surfaced that start_as_current_span's default set_status_on_exception / record_exception was overwriting the middleware's explicit set_status and attaching tracebacks to protocol-level MCPErrors — now disabled and handled explicitly. - Add handler-return contract tests (None → {}, unsupported → INTERNAL_ERROR). - Introduce connected_runner async-contextmanager test harness and retrofit all tests through runner.run(); drop two tests made redundant by that. Harness closes dispatchers gracefully and re-raises body exceptions outside the task group so failures aren't ExceptionGroup-wrapped (and to avoid a coverage.py trace-loss false-negative on cancel-during-aexit). - Remove the unused Server.connection_lifespan placeholder; it lands with its consumer.
1 parent 4c5cd39 commit a8b669c

7 files changed

Lines changed: 246 additions & 218 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ dev = [
7373
"pillow>=12.0",
7474
"strict-no-cover",
7575
"logfire>=3.0.0",
76+
"opentelemetry-sdk>=1.39.1",
7677
]
7778
docs = [
7879
"mkdocs>=1.6.1",

src/mcp/server/lowlevel/server.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,6 @@ def get_notification_handler(self, method: str) -> Callable[..., Awaitable[Any]]
259259
"""Return the handler for a notification method, or ``None``."""
260260
return self._notification_handlers.get(method)
261261

262-
@property
263-
def connection_lifespan(self) -> None:
264-
"""Per-connection lifespan. ``None`` until the registry refactor adds it."""
265-
return None
266-
267262
# TODO: Rethink capabilities API. Currently capabilities are derived from registered
268263
# handlers but require NotificationOptions to be passed externally for list_changed
269264
# flags, and experimental_capabilities as a separate dict. Consider deriving capabilities

src/mcp/server/runner.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,21 @@ async def wrapped(
145145
case _:
146146
parent = None
147147
span_name = f"MCP handle {method}{f' {target}' if target else ''}"
148-
with otel_span(span_name, kind=SpanKind.SERVER, attributes={"mcp.method.name": method}, context=parent) as span:
148+
with otel_span(
149+
span_name,
150+
kind=SpanKind.SERVER,
151+
attributes={"mcp.method.name": method},
152+
context=parent,
153+
record_exception=False,
154+
set_status_on_exception=False,
155+
) as span:
149156
try:
150157
return await next_on_request(dctx, method, params)
151158
except MCPError as e:
152159
span.set_status(StatusCode.ERROR, e.error.message)
153160
raise
154161
except Exception as e:
162+
span.record_exception(e)
155163
span.set_status(StatusCode.ERROR, str(e))
156164
raise
157165

src/mcp/shared/_otel.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,18 @@ def otel_span(
2020
kind: SpanKind,
2121
attributes: dict[str, Any] | None = None,
2222
context: Context | None = None,
23+
record_exception: bool = True,
24+
set_status_on_exception: bool = True,
2325
) -> Iterator[Any]:
2426
"""Create an OTel span."""
25-
with _tracer.start_as_current_span(name, kind=kind, attributes=attributes, context=context) as span:
27+
with _tracer.start_as_current_span(
28+
name,
29+
kind=kind,
30+
attributes=attributes,
31+
context=context,
32+
record_exception=record_exception,
33+
set_status_on_exception=set_status_on_exception,
34+
) as span:
2635
yield span
2736

2837

tests/server/conftest.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Shared fixtures for server-side tests."""
2+
3+
from collections.abc import Iterator
4+
5+
import pytest
6+
from opentelemetry import trace
7+
from opentelemetry.sdk.trace import TracerProvider
8+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
9+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
10+
11+
_span_exporter = InMemorySpanExporter()
12+
13+
14+
@pytest.fixture(scope="session")
15+
def _tracer_provider() -> TracerProvider:
16+
"""Install a real OTel SDK tracer provider once per test session.
17+
18+
The runtime dependency is ``opentelemetry-api`` only, which yields no-op
19+
``NonRecordingSpan`` objects. Tests that need to assert on emitted spans
20+
request the `spans` fixture, which depends on this one to make the global
21+
tracer record into an in-memory exporter.
22+
"""
23+
provider = TracerProvider()
24+
provider.add_span_processor(SimpleSpanProcessor(_span_exporter))
25+
trace.set_tracer_provider(provider)
26+
return provider
27+
28+
29+
@pytest.fixture
30+
def spans(_tracer_provider: TracerProvider) -> Iterator[InMemorySpanExporter]:
31+
"""In-memory OTel span exporter, cleared before and after each test."""
32+
_span_exporter.clear()
33+
yield _span_exporter
34+
_span_exporter.clear()

0 commit comments

Comments
 (0)