Skip to content

Commit 2397319

Browse files
authored
Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)
1 parent 4472428 commit 2397319

35 files changed

Lines changed: 2256 additions & 1088 deletions

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

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,6 @@ server:
6969
- json-schema-2020-12
7070

7171
# --- Draft scenarios (same failures and reasons as the `--suite draft` leg) ---
72-
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
73-
# _meta-derived capabilities, error-code mappings, or server/discover yet.
74-
- server-stateless
7572
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
7673
- input-required-result-basic-elicitation
7774
- input-required-result-basic-sampling
@@ -83,14 +80,12 @@ server:
8380
- input-required-result-result-type
8481
- input-required-result-tampered-state
8582
- input-required-result-capability-check
86-
- input-required-result-validate-input
87-
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
88-
# case-insensitive/whitespace-trimmed header validation not implemented.
83+
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
84+
# against the request body is not implemented.
8985
- http-header-validation
90-
91-
# --- WARNING-only entries ---
92-
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
93-
# the expected-failures evaluator counts WARNINGs as failures. Same entries
94-
# as the draft suite in expected-failures.yml.
95-
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
86+
# WARNING-only entries: these scenarios emit no FAILURE checks but the
87+
# expected-failures evaluator counts WARNINGs as failures (the summary line
88+
# only shows passed/failed, not warnings, so a local re-probe can mis-read
89+
# these as stale).
9690
- input-required-result-missing-input-response
91+
- input-required-result-validate-input

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

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,7 @@ client:
3434

3535
server:
3636
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
37-
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
38-
# _meta-derived capabilities, error-code mappings, or server/discover yet.
39-
- server-stateless
40-
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented;
41-
# most scenarios currently fail early with "Missing session ID" because
42-
# mcp-everything-server only runs in stateful mode.
37+
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
4338
- input-required-result-basic-elicitation
4439
- input-required-result-basic-sampling
4540
- input-required-result-basic-list-roots
@@ -50,17 +45,12 @@ server:
5045
- input-required-result-result-type
5146
- input-required-result-tampered-state
5247
- input-required-result-capability-check
53-
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
54-
# case-insensitive/whitespace-trimmed header validation not implemented.
48+
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
49+
# against the request body is not implemented.
5550
- http-header-validation
56-
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
57-
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
58-
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
51+
# WARNING-only entries: these scenarios emit no FAILURE checks but the
52+
# expected-failures evaluator counts WARNINGs as failures (the summary line
53+
# only shows passed/failed, not warnings, so a local re-probe can mis-read
54+
# these as stale).
5955
- input-required-result-missing-input-response
60-
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
61-
# now baselined (added when the stateless path landed — the stateless server
62-
# reaches the handler, so the previous accidental pass via -32600 "Missing
63-
# session ID" no longer applies). input-required-result-unsupported-methods
64-
# is intentionally NOT baselined: it still passes for now; add it once it
65-
# starts failing for real.
6656
- input-required-result-validate-input

docs/migration.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
1919
`.structured_content` off the returned `CallToolResult` instead of branching on
2020
the result type.
2121

22+
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
23+
24+
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
25+
an `@mcp.tool()` handler now produces a top-level JSON-RPC error response with
26+
the raised `code`, `message`, and `data` intact. Previously the tool wrapper
27+
caught it like any other exception and returned `CallToolResult(isError=True)`,
28+
which discarded the error code and structured `data`.
29+
30+
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
31+
when the request itself should be rejected (missing client capability,
32+
elicitation required, invalid parameters). For tool *execution* failures the
33+
calling LLM should see and react to, raise any other exception or return
34+
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
35+
2236
### `streamablehttp_client` removed
2337

2438
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.
@@ -487,6 +501,18 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru
487501

488502
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.
489503

504+
### Streamable HTTP: lifespan now entered once at manager startup
505+
506+
When serving streamable HTTP (stateful or `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 sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently.
507+
508+
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (the public surface for reaching it from high-level `@mcp.tool()` handlers is being finalised as part of the public-surface review).
509+
510+
### `Server.run()` no longer takes a `stateless` flag; `StatelessModeNotSupported` removed
511+
512+
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
513+
514+
`StatelessModeNotSupported` has been removed. Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. If you were catching `StatelessModeNotSupported`, catch `NoBackChannelError` instead.
515+
490516
### `MCPServer.get_context()` removed
491517

492518
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
@@ -1202,8 +1228,8 @@ from mcp.server import ServerRequestContext
12021228
session = ServerSession(read_stream, write_stream, init_options, stateless=False)
12031229

12041230
# After (v2)
1205-
session = ServerSession(dispatcher, connection, stateless=False)
1206-
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection
1231+
session = ServerSession(request_outbound, connection)
1232+
# where `request_outbound` is an Outbound and `connection` is a Connection
12071233
```
12081234

12091235
In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up.

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/client/streamable_http.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,29 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
314314
logger.debug("Received 202 Accepted")
315315
return
316316

317-
if response.status_code == 404:
318-
if isinstance(message, JSONRPCRequest):
319-
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
320-
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
321-
await ctx.read_stream_writer.send(session_message)
322-
return
323-
324317
if response.status_code >= 400:
325318
if isinstance(message, JSONRPCRequest):
326-
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
319+
# A spec-correct server may return the JSON-RPC error in the
320+
# body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404
321+
# for METHOD_NOT_FOUND). Surface that error rather than the
322+
# status-derived stand-in below.
323+
if response.headers.get("content-type", "").lower().startswith("application/json"):
324+
try:
325+
body = await response.aread()
326+
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
327+
if isinstance(parsed, JSONRPCError):
328+
# The server may have set `id: null` (request rejected before its
329+
# id was parsed); use this request's id so correlation works.
330+
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
331+
await ctx.read_stream_writer.send(SessionMessage(reply))
332+
return
333+
except (httpx.StreamError, ValidationError):
334+
pass
335+
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
336+
if response.status_code == 404:
337+
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
338+
else:
339+
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
327340
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
328341
await ctx.read_stream_writer.send(session_message)
329342
return

0 commit comments

Comments
 (0)