Skip to content

Commit 4c51c51

Browse files
committed
S2 wave 0: conformance pin, classifier, discover handler, helpers, fixture tool, migration note
- F1: conformance harness pin -> github:modelcontextprotocol/conformance#fe3a3103; reconcile both expected-failures baselines (auth/scope-step-up moves to 2026-only per #337) - D2: extract to_jsonrpc_response + aclose_shielded helpers in runner.py; add handler_exception_to_error_data in shared/jsonrpc_dispatcher.py (single exception->ErrorData source) - H1: new src/mcp/shared/inbound.py — classify_inbound_request() + ERROR_CODE_HTTP_STATUS table (pure classifier; no I/O, no mcp.server imports; method-at-version via CLIENT_REQUESTS map) - H2: server/discover auto-derived handler at lowlevel.Server.__init__; get_capabilities() params now optional; new server_info property - H4: test_missing_capability fixture tool in everything-server (raises MISSING_REQUIRED_CLIENT_CAPABILITY) - C1: docs/migration.md entry for stateless_http lifespan cardinality change Part of the 2026-07-28 server-stateless conformance work (#2891).
1 parent b7a5bff commit 4c51c51

11 files changed

Lines changed: 475 additions & 66 deletions

File tree

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
# 2026 leg reads the `server:` section. Both burn down independently of the
1111
# 2025 legs.
1212
#
13-
# Baseline established against @modelcontextprotocol/conformance pinned in
14-
# .github/workflows/conformance.yml (CONFORMANCE_VERSION = 0.2.0-alpha.4).
15-
# New conformance releases are adopted by deliberately bumping that pin and
16-
# reconciling both this file and expected-failures.yml in the same change.
13+
# Baseline established against the harness pinned via CONFORMANCE_VERSION in
14+
# .github/workflows/conformance.yml. New conformance releases are adopted by
15+
# deliberately bumping that pin and reconciling both this file and
16+
# expected-failures.yml in the same change.
1717
#
1818
# Entries are grouped by what unblocks them. As each gap closes the
1919
# corresponding scenarios start passing and MUST be removed from this list

.github/actions/conformance/expected-failures.yml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# Conformance scenarios not yet passing against the Python SDK on main.
22
# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries.
33
#
4-
# Baseline established against @modelcontextprotocol/conformance pinned in
5-
# .github/workflows/conformance.yml (CONFORMANCE_VERSION = 0.2.0-alpha.4).
6-
# New conformance releases are adopted by deliberately bumping that pin and
7-
# reconciling both this file and expected-failures.2026-07-28.yml in the same
8-
# change.
4+
# Baseline established against the harness pinned via CONFORMANCE_VERSION in
5+
# .github/workflows/conformance.yml. New conformance releases are adopted by
6+
# deliberately bumping that pin and reconciling both this file and
7+
# expected-failures.2026-07-28.yml in the same change.
98
#
109
# Entries are grouped by SEP. As each SEP lands in the SDK the corresponding
1110
# scenarios start passing and MUST be removed from this list (the runner fails
@@ -40,9 +39,6 @@ client:
4039
- auth/offline-access-not-supported
4140

4241
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
43-
# SEP-2350 (scope step-up): WARNING-only; the expected-failures evaluator
44-
# counts WARNINGs as failures.
45-
- auth/scope-step-up
4642
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
4743
# client support for the token-exchange + JWT bearer flow.
4844
- auth/enterprise-managed-authorization

.github/actions/conformance/run-server.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,5 @@ done
4747

4848
echo "Server ready at $SERVER_URL"
4949

50-
npx --yes @modelcontextprotocol/conformance@"${CONFORMANCE_VERSION:?set CONFORMANCE_VERSION (pinned in .github/workflows/conformance.yml)}" \
50+
npx --yes "${CONFORMANCE_VERSION:?set CONFORMANCE_VERSION (pinned in .github/workflows/conformance.yml)}" \
5151
server --url "$SERVER_URL" "$@"

.github/workflows/conformance.yml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ permissions:
1414
contents: read
1515

1616
env:
17-
# Pinned conformance harness version. Bump deliberately and reconcile
18-
# both .github/actions/conformance/expected-failures*.yml files in the
19-
# same change.
20-
CONFORMANCE_VERSION: "0.2.0-alpha.4"
17+
# Pinned conformance harness package spec (passed verbatim to `npx --yes`).
18+
# Accepts a published version (`@modelcontextprotocol/conformance@0.2.0-alpha.5`)
19+
# or a git ref (`github:modelcontextprotocol/conformance#<sha>`). Bump
20+
# deliberately and reconcile both
21+
# .github/actions/conformance/expected-failures*.yml files in the same change.
22+
CONFORMANCE_VERSION: "github:modelcontextprotocol/conformance#fe3a3103bc6f9469039b7ed5c8f2739438de2fa7"
2123

2224
jobs:
2325
server-conformance:
@@ -67,13 +69,13 @@ jobs:
6769
- run: uv sync --frozen --all-extras --package mcp
6870
- name: Run client conformance (all suite)
6971
run: >-
70-
npx --yes @modelcontextprotocol/conformance@"$CONFORMANCE_VERSION" client
72+
npx --yes "$CONFORMANCE_VERSION" client
7173
--command 'uv run --frozen python .github/actions/conformance/client.py'
7274
--suite all
7375
--expected-failures ./.github/actions/conformance/expected-failures.yml
7476
- name: Run client conformance (2026-07-28 wire, all suite)
7577
run: >-
76-
npx --yes @modelcontextprotocol/conformance@"$CONFORMANCE_VERSION" client
78+
npx --yes "$CONFORMANCE_VERSION" client
7779
--command 'uv run --frozen python .github/actions/conformance/client.py'
7880
--suite all
7981
--spec-version 2026-07-28

docs/migration.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,18 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru
458458

459459
If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor.
460460

461+
### `stateless_http=True`: lifespan now entered once at startup
462+
463+
When serving streamable HTTP with `stateless_http=True`, the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all requests. Previously each incoming request entered (and exited) `lifespan` independently.
464+
465+
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of on every request. If your lifespan was acquiring per-connection resources, move that into the handler and register cleanup on the per-connection `exit_stack`:
466+
467+
```python
468+
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
469+
db = await ctx.connection.exit_stack.enter_async_context(open_db())
470+
...
471+
```
472+
461473
### `MCPServer.get_context()` removed
462474

463475
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.

examples/servers/everything-server/mcp_everything_server/server.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mcp.server.mcpserver import Context, MCPServer
1515
from mcp.server.mcpserver.prompts.base import UserMessage
1616
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
17+
from mcp.shared.exceptions import MCPError
1718
from mcp.types import (
1819
AudioContent,
1920
Completion,
@@ -32,6 +33,7 @@
3233
TextResourceContents,
3334
UnsubscribeRequestParams,
3435
)
36+
from mcp.types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
3537
from pydantic import BaseModel, Field
3638

3739
logger = logging.getLogger(__name__)
@@ -311,6 +313,26 @@ def test_error_handling() -> str:
311313
raise RuntimeError("This tool intentionally returns an error for testing")
312314

313315

316+
@mcp.tool()
317+
async def test_missing_capability(ctx: Context) -> str:
318+
"""Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error.
319+
320+
Requires the client to declare the ``sampling`` capability. When absent, raises
321+
`MCPError` (which the tool dispatch re-raises rather than wrapping in
322+
``CallToolResult.isError``) so the conformance harness observes a protocol-level
323+
error response with ``data.requiredCapabilities``.
324+
"""
325+
client_params = ctx.session.client_params
326+
sampling_declared = client_params is not None and client_params.capabilities.sampling is not None
327+
if not sampling_declared:
328+
raise MCPError(
329+
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
330+
message="This tool requires the client 'sampling' capability",
331+
data={"requiredCapabilities": ["sampling"]},
332+
)
333+
return "Client declared sampling capability; proceeding."
334+
335+
314336
@mcp.tool()
315337
async def test_reconnection(ctx: Context) -> str:
316338
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""

src/mcp/server/lowlevel/server.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ async def main():
6666
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
6767
from mcp.shared.message import SessionMessage
6868
from mcp.shared.transport_context import TransportContext
69+
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
6970

7071
logger = logging.getLogger(__name__)
7172

@@ -117,6 +118,15 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
117118
return types.EmptyResult()
118119

119120

121+
def _package_version(package: str) -> str:
122+
try:
123+
return importlib_version(package)
124+
except Exception: # pragma: no cover
125+
pass
126+
127+
return "unknown" # pragma: no cover
128+
129+
120130
class Server(Generic[LifespanResultT]):
121131
def __init__(
122132
self,
@@ -226,6 +236,7 @@ def __init__(
226236

227237
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
228238
("ping", types.RequestParams, on_ping),
239+
("server/discover", types.RequestParams, self._handle_discover),
229240
("prompts/list", types.PaginatedRequestParams, on_list_prompts),
230241
("prompts/get", types.GetPromptRequestParams, on_get_prompt),
231242
("resources/list", types.PaginatedRequestParams, on_list_resources),
@@ -309,18 +320,9 @@ def create_initialization_options(
309320
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
310321
) -> InitializationOptions:
311322
"""Create initialization options from this server instance."""
312-
313-
def pkg_version(package: str) -> str:
314-
try:
315-
return importlib_version(package)
316-
except Exception: # pragma: no cover
317-
pass
318-
319-
return "unknown" # pragma: no cover
320-
321323
return InitializationOptions(
322324
server_name=self.name,
323-
server_version=self.version if self.version else pkg_version("mcp"),
325+
server_version=self.version if self.version else _package_version("mcp"),
324326
title=self.title,
325327
description=self.description,
326328
capabilities=self.get_capabilities(
@@ -334,10 +336,11 @@ def pkg_version(package: str) -> str:
334336

335337
def get_capabilities(
336338
self,
337-
notification_options: NotificationOptions,
338-
experimental_capabilities: dict[str, dict[str, Any]],
339+
notification_options: NotificationOptions | None = None,
340+
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
339341
) -> types.ServerCapabilities:
340342
"""Convert existing handlers to a ServerCapabilities object."""
343+
notification_options = notification_options or NotificationOptions()
341344
prompts_capability = None
342345
resources_capability = None
343346
tools_capability = None
@@ -377,6 +380,40 @@ def get_capabilities(
377380
)
378381
return capabilities
379382

383+
@property
384+
def server_info(self) -> types.Implementation:
385+
"""The `serverInfo` block describing this implementation.
386+
387+
Derived from the constructor's identity fields. `version` falls back to
388+
the installed `mcp` package version when not supplied explicitly.
389+
"""
390+
return types.Implementation(
391+
name=self.name,
392+
version=self.version if self.version else _package_version("mcp"),
393+
title=self.title,
394+
description=self.description,
395+
website_url=self.website_url,
396+
icons=self.icons,
397+
)
398+
399+
async def _handle_discover(
400+
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None
401+
) -> types.DiscoverResult:
402+
"""Default `server/discover` handler.
403+
404+
Auto-derived from server state at call time, so capabilities reflect
405+
whatever has been registered (constructor `on_*` kwargs and later
406+
`add_request_handler` calls). Operators can replace it wholesale via
407+
`add_request_handler("server/discover", ...)`. Reachability for legacy
408+
peers is decided at the boundary (`types.methods`), not here.
409+
"""
410+
return types.DiscoverResult(
411+
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
412+
capabilities=self.get_capabilities(),
413+
server_info=self.server_info,
414+
instructions=self.instructions,
415+
)
416+
380417
@property
381418
def session_manager(self) -> StreamableHTTPSessionManager:
382419
"""Get the StreamableHTTP session manager.

src/mcp/server/runner.py

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from __future__ import annotations
1717

1818
import logging
19-
from collections.abc import Mapping
19+
from collections.abc import Awaitable, Mapping
2020
from dataclasses import dataclass, field
2121
from functools import partial, reduce
2222
from typing import TYPE_CHECKING, Any, Generic, cast
@@ -33,6 +33,7 @@
3333
from mcp.shared._otel import extract_trace_context, otel_span
3434
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnRequest
3535
from mcp.shared.exceptions import MCPError
36+
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data
3637
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
3738
from mcp.shared.transport_context import TransportContext
3839
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@@ -46,6 +47,9 @@
4647
Implementation,
4748
InitializeRequestParams,
4849
InitializeResult,
50+
JSONRPCError,
51+
JSONRPCResponse,
52+
RequestId,
4953
RequestParams,
5054
RequestParamsMeta,
5155
)
@@ -54,7 +58,7 @@
5458
if TYPE_CHECKING:
5559
from mcp.server.lowlevel.server import Server
5660

57-
__all__ = ["CallNext", "ServerMiddleware", "ServerRunner", "otel_middleware"]
61+
__all__ = ["CallNext", "ServerMiddleware", "ServerRunner", "aclose_shielded", "otel_middleware", "to_jsonrpc_response"]
5862

5963
logger = logging.getLogger(__name__)
6064

@@ -64,8 +68,8 @@
6468
_INIT_EXEMPT: frozenset[str] = frozenset({"ping"})
6569

6670
_EXIT_STACK_CLOSE_TIMEOUT: float = 5
67-
"""Bound for the shielded exit-stack unwind in `run()`; a hung cleanup
68-
callback must not wedge shutdown."""
71+
"""Bound for `aclose_shielded`'s exit-stack unwind; a hung cleanup callback
72+
must not wedge shutdown."""
6973

7074

7175
def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
@@ -169,6 +173,47 @@ def _dump_result(result: Any) -> dict[str, Any]:
169173
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
170174

171175

176+
async def aclose_shielded(connection: Connection) -> None:
177+
"""Unwind ``connection.exit_stack`` under a shielded, bounded scope.
178+
179+
Called from a driver's ``finally``: the shield lets per-connection cleanup
180+
callbacks run even when the driver itself is being cancelled, the
181+
`_EXIT_STACK_CLOSE_TIMEOUT` bound stops a hung callback wedging shutdown,
182+
and a raising callback is logged-and-swallowed so it never masks the
183+
driver's own exception.
184+
"""
185+
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
186+
try:
187+
await connection.exit_stack.aclose()
188+
except Exception:
189+
logger.exception("connection exit_stack cleanup raised")
190+
if scope.cancelled_caught:
191+
logger.warning(
192+
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
193+
_EXIT_STACK_CLOSE_TIMEOUT,
194+
)
195+
196+
197+
async def to_jsonrpc_response(request_id: RequestId, coro: Awaitable[dict[str, Any]]) -> JSONRPCResponse | JSONRPCError:
198+
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
199+
200+
The exception-to-wire boundary for the request-per-call drivers
201+
(`serve_one`, the modern HTTP entry). `MCPError` and `ValidationError`
202+
map via the shared `handler_exception_to_error_data` ladder; any other
203+
exception is logged and surfaced as `INTERNAL_ERROR` so handler internals
204+
never reach the wire.
205+
"""
206+
try:
207+
result = await coro
208+
except Exception as exc:
209+
error = handler_exception_to_error_data(exc)
210+
if error is None:
211+
logger.exception("request handler raised")
212+
error = ErrorData(code=INTERNAL_ERROR, message="Internal server error")
213+
return JSONRPCError(jsonrpc="2.0", id=request_id, error=error)
214+
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
215+
216+
172217
@dataclass
173218
class ServerRunner(Generic[LifespanT]):
174219
"""Per-connection orchestrator. One instance per client connection."""
@@ -205,26 +250,14 @@ async def run(self, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STAT
205250
to `dispatcher.run()`. `task_status.started()` is forwarded so callers
206251
can `await tg.start(runner.run)` and resume once the dispatcher is
207252
ready to accept requests. Once the dispatcher exits,
208-
`connection.exit_stack` is unwound (shielded from outer cancellation,
209-
bounded by `_EXIT_STACK_CLOSE_TIMEOUT`) so any per-connection cleanup
210-
registered by handlers or middleware gets a chance to run without a
211-
misbehaving callback hanging shutdown indefinitely.
253+
`connection.exit_stack` is unwound via `aclose_shielded` so any
254+
per-connection cleanup registered by handlers or middleware gets a
255+
chance to run.
212256
"""
213257
try:
214258
await self.dispatcher.run(self._compose_on_request(), self._on_notify, task_status=task_status)
215259
finally:
216-
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
217-
try:
218-
await self.connection.exit_stack.aclose()
219-
except Exception:
220-
# Raising here would mask dispatcher.run()'s exception and
221-
# crash stdio servers on normal disconnect.
222-
logger.exception("connection exit_stack cleanup raised")
223-
if scope.cancelled_caught:
224-
logger.warning(
225-
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
226-
_EXIT_STACK_CLOSE_TIMEOUT,
227-
)
260+
await aclose_shielded(self.connection)
228261

229262
def _compose_on_request(self) -> OnRequest:
230263
"""Wrap `_on_request` in `dispatch_middleware`, outermost-first.

0 commit comments

Comments
 (0)