Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3c2a822
S2 wave 0: conformance pin, classifier, discover handler, helpers, fi…
maxisbey Jun 20, 2026
ff6610a
S2 waves 1-3: driver-split kernel + two-channel ServerSession + class…
maxisbey Jun 20, 2026
703cde1
S2 wave 4: modern entry rewrite, Server.run wrapper, test migrations
maxisbey Jun 20, 2026
1c3e8fc
Client streamable-http: parse JSON-RPC errors from non-2xx response b…
maxisbey Jun 20, 2026
bbe0e8d
Testing-standards fixes: hollow-proof, provenance docstrings, constan…
maxisbey Jun 20, 2026
764650e
Absorb #2926 (SEP-2577 deprecations); fix client 4xx id-correlation
maxisbey Jun 20, 2026
9470abf
D8: move method-existence from classifier rung-4 to kernel dispatch
maxisbey Jun 20, 2026
7231664
Re-raise MCPError from @mcp.tool() handlers as a top-level JSON-RPC e…
maxisbey Jun 20, 2026
416c9d9
T3: interaction-suite additions for modern stateless entry
maxisbey Jun 20, 2026
e0f8ab9
T5: coverage backfill for S2-added branches
maxisbey Jun 20, 2026
4a79c32
Wave 5b polish + update tests for MCPError re-raise consequences
maxisbey Jun 20, 2026
078af46
Route unrecognised protocol-version headers to the modern entry; swap…
maxisbey Jun 20, 2026
bf0dd7f
Delete legacy transport's protocol-version validation (now owned by m…
maxisbey Jun 20, 2026
2313cde
C3: remove server-stateless from conformance baselines; C2: TODO-anch…
maxisbey Jun 20, 2026
d3b0977
Address review: serve_loop helper, bare 405, INVALID_REQUEST split, m…
maxisbey Jun 20, 2026
efa9c85
Address by-construction review: ServerSession reads standalone channe…
maxisbey Jun 20, 2026
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
19 changes: 7 additions & 12 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,6 @@ server:
- json-schema-2020-12

# --- Draft scenarios (same failures and reasons as the `--suite draft` leg) ---
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
# _meta-derived capabilities, error-code mappings, or server/discover yet.
- server-stateless
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
Expand All @@ -83,14 +80,12 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
- input-required-result-validate-input
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# against the request body is not implemented.
- http-header-validation

# --- WARNING-only entries ---
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
# the expected-failures evaluator counts WARNINGs as failures. Same entries
# as the draft suite in expected-failures.yml.
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# expected-failures evaluator counts WARNINGs as failures (the summary line
# only shows passed/failed, not warnings, so a local re-probe can mis-read
# these as stale).
- input-required-result-missing-input-response
- input-required-result-validate-input
24 changes: 7 additions & 17 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,7 @@ client:

server:
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
# _meta-derived capabilities, error-code mappings, or server/discover yet.
- server-stateless
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented;
# most scenarios currently fail early with "Missing session ID" because
# mcp-everything-server only runs in stateful mode.
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
- input-required-result-basic-list-roots
Expand All @@ -50,17 +45,12 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# against the request body is not implemented.
- http-header-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# expected-failures evaluator counts WARNINGs as failures (the summary line
# only shows passed/failed, not warnings, so a local re-probe can mis-read
# these as stale).
- input-required-result-missing-input-response
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
# now baselined (added when the stateless path landed — the stateless server
# reaches the handler, so the previous accidental pass via -32600 "Missing
# session ID" no longer applies). input-required-result-unsupported-methods
# is intentionally NOT baselined: it still passes for now; add it once it
# starts failing for real.
- input-required-result-validate-input
30 changes: 28 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
`.structured_content` off the returned `CallToolResult` instead of branching on
the result type.

### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error

Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
an `@mcp.tool()` handler now produces a top-level JSON-RPC error response with
the raised `code`, `message`, and `data` intact. Previously the tool wrapper
caught it like any other exception and returned `CallToolResult(isError=True)`,
which discarded the error code and structured `data`.

`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
when the request itself should be rejected (missing client capability,
elicitation required, invalid parameters). For tool *execution* failures the
calling LLM should see and react to, raise any other exception or return
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.

### `streamablehttp_client` removed

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

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.

### Streamable HTTP: lifespan now entered once at manager startup

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.

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).

### `Server.run()` no longer takes a `stateless` flag; `StatelessModeNotSupported` removed

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.

`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.

### `MCPServer.get_context()` removed

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

# After (v2)
session = ServerSession(dispatcher, connection, stateless=False)
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection
session = ServerSession(request_outbound, connection)
# where `request_outbound` is an Outbound and `connection` is a Connection
```

In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up.
Expand Down
22 changes: 22 additions & 0 deletions examples/servers/everything-server/mcp_everything_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError
from mcp.types import (
AudioContent,
Completion,
Expand All @@ -32,6 +33,7 @@
TextResourceContents,
UnsubscribeRequestParams,
)
from mcp.types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field

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


@mcp.tool()
async def test_missing_capability(ctx: Context) -> str:
"""Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error.

Requires the client to declare the ``sampling`` capability. When absent, raises
`MCPError` (which the tool dispatch re-raises rather than wrapping in
``CallToolResult.isError``) so the conformance harness observes a protocol-level
error response with ``data.requiredCapabilities``.
"""
client_params = ctx.session.client_params
sampling_declared = client_params is not None and client_params.capabilities.sampling is not None
if not sampling_declared:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="This tool requires the client 'sampling' capability",
data={"requiredCapabilities": ["sampling"]},
)
return "Client declared sampling capability; proceeding."


@mcp.tool()
async def test_reconnection(ctx: Context) -> str:
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""
Expand Down
29 changes: 21 additions & 8 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,16 +314,29 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
logger.debug("Received 202 Accepted")
return

if response.status_code == 404:
if isinstance(message, JSONRPCRequest):
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
await ctx.read_stream_writer.send(session_message)
return

if response.status_code >= 400:
if isinstance(message, JSONRPCRequest):
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
# A spec-correct server may return the JSON-RPC error in the
# body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404
# for METHOD_NOT_FOUND). Surface that error rather than the
# status-derived stand-in below.
if response.headers.get("content-type", "").lower().startswith("application/json"):
try:
body = await response.aread()
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
if isinstance(parsed, JSONRPCError):
# The server may have set `id: null` (request rejected before its
# id was parsed); use this request's id so correlation works.
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
await ctx.read_stream_writer.send(SessionMessage(reply))
return
except (httpx.StreamError, ValidationError):
pass
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
if response.status_code == 404:
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
else:
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
await ctx.read_stream_writer.send(session_message)
return
Expand Down
Loading
Loading