Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
--8<-- "docs_src/lowlevel/tutorial006.py"
```

* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
* The first argument is the method string. Notifications have a twin, `add_notification_handler`. Its handlers fire on stdio and on handshake-era HTTP connections; on the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` and not dispatched, because that revision defines no client-to-server notifications over HTTP.
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.

Expand Down
11 changes: 7 additions & 4 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ That is the point. Middleware wraps **every** inbound message:

* The connection setup: `server/discover`, or `initialize` and `notifications/initialized`
on a legacy session.
* Every request and every notification. For a notification, `ctx.request_id is None`,
`call_next(ctx)` returns `None`, and whatever you return is discarded.
* Every request and every notification that reaches the server. For a notification,
`ctx.request_id is None`, `call_next(ctx)` returns `None`, and whatever you return is discarded.
(On the `2026-07-28` streamable-HTTP path a client's notification POST is acknowledged `202` at
the transport and never dispatched, so it does not reach middleware either; that revision
defines no client-to-server notifications over HTTP.)
* Even a method the server has no handler for: `call_next` raises the
`MCPError(-32601, "Method not found")` *through* your middleware on its way to the client.

Expand Down Expand Up @@ -105,8 +108,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it

* A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or
appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`.
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
unknown methods) and runs outermost-first.
* It wraps **every** inbound message that reaches the server (`server/discover`, `initialize`,
requests, notifications, unknown methods) and runs outermost-first.
* `ctx.request_id is None` is how you tell a notification from a request.
* Raise instead of calling `call_next` to refuse one message; the connection survives.
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
Expand Down
82 changes: 67 additions & 15 deletions src/mcp/server/_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
path for earlier protocol revisions.

A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. A
notification POST is acknowledged `202` and dropped: the core protocol defines
no client-to-server notifications on this wire (cancellation is closing the
response stream), and a per-request entry has nothing for one to act on. JSON
mode handles the request directly in the ASGI task. SSE mode runs the handler
as a sibling task and defers committing to `text/event-stream` until the
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
Expand Down Expand Up @@ -56,10 +59,12 @@
from mcp.shared.inbound import (
ERROR_CODE_HTTP_STATUS,
MCP_PARAM_HEADER_PREFIX,
MCP_PROTOCOL_VERSION_HEADER,
InboundLadderRejection,
InboundModernRoute,
classify_inbound_request,
find_duplicated_routing_header,
unsupported_protocol_version_rejection,
validate_mcp_param_headers,
)
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
Expand Down Expand Up @@ -162,7 +167,7 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt

async def _write_rejection(
rejection: InboundLadderRejection,
request_id: RequestId,
request_id: RequestId | None,
scope: Scope,
receive: Receive,
send: Send,
Expand Down Expand Up @@ -196,6 +201,60 @@ async def _write(
)(scope, receive, send)


_INVALID_BODY: Final = JSONRPCError(
jsonrpc="2.0",
id=None,
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
)
"""Well-formed JSON that is not one request or notification: a batch, a posted response, a malformed envelope."""


def _is_notification_shaped(decoded: Any) -> bool:
"""Whether a decoded POST body is a single JSON object without an `id` member.

JSON-RPC 2.0 §4.1: a notification is a request object without an "id"
member, so key presence — not which model happens to validate — picks the
arm. (The notification model ignores unknown keys; letting it catch a
request whose id is malformed would 202 a message that is owed an error.)
"""
return isinstance(decoded, dict) and "id" not in decoded


async def _acknowledge_notification(
decoded: dict[str, Any],
request: Request,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""Answer an id-less POST body: `202` for a notification at a served version, a rejection otherwise.

Streamable-http §Sending Messages item 5 lets a server accept (202, no
body) or refuse (4xx) a notification POST; this entry accepts and drops.
The 2026-07-28 core protocol defines no client-to-server notifications over
HTTP (a client cancels by closing the response stream) and a per-request
entry holds no cross-request state for one to act on — honouring a posted
`notifications/cancelled` by client-chosen request id would let one
anonymous caller cancel another's work — but clients in the field still
POST them, and notifications are fire-and-forget, so they are acknowledged
as the handshake-era transport does rather than answered with an error
nobody reads. Header requirements for notification POSTs are undefined at
this revision; only the routing header that brought the POST here is
checked, so a version this entry does not serve is told so, as a request is.
"""
try:
notification = JSONRPCNotification.model_validate(decoded)
except ValidationError:
await _write(_INVALID_BODY, scope, receive, send)
return
requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a notification POST includes duplicate routing headers, this path skips duplicate-header rejection and reads one folded mcp-protocol-version value. Check find_duplicated_routing_header in _acknowledge_notification and return HEADER_MISMATCH before reading the version header.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/_streamable_http_modern.py, line 252:

<comment>When a notification POST includes duplicate routing headers, this path skips duplicate-header rejection and reads one folded `mcp-protocol-version` value. Check `find_duplicated_routing_header` in `_acknowledge_notification` and return `HEADER_MISMATCH` before reading the version header.</comment>

<file context>
@@ -196,6 +203,71 @@ async def _write(
+    except ValidationError:
+        await _write(_INVALID_BODY, scope, receive, send)
+        return
+    requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")
+    if requested not in MODERN_PROTOCOL_VERSIONS:
+        rej = JSONRPCError(
</file context>
Suggested change
requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")
duplicated = find_duplicated_routing_header(request.headers.items())
if duplicated is not None:
await _write(
JSONRPCError(
jsonrpc="2.0",
id=None,
error=ErrorData(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once"),
),
scope,
receive,
send,
)
return
requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate for now. The duplicate check guards the header↔body cross-check, which only requests get; the notification arm reads the routing header once (first value, same as the manager) and acts on nothing, so rejecting here would only turn a 202-and-drop into a 400 for a message we drop either way, on a POST whose header requirements the revision leaves undefined. If the notification arm ever grows real rungs it will get its own classifier and the check belongs there.

AI Disclaimer

if (unsupported := unsupported_protocol_version_rejection(requested)) is not None:
await _write_rejection(unsupported, None, scope, receive, send)
return
logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested)
await Response(status_code=202)(scope, receive, send)
Comment thread
maxisbey marked this conversation as resolved.


_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()

_MCP_PARAM_LIST_PAGE_CAP: Final = 100
Expand Down Expand Up @@ -346,22 +405,15 @@ async def handle_modern_request(
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
await _write(rej, scope, receive, send)
return
if _is_notification_shaped(decoded):
await _acknowledge_notification(decoded, request, scope, receive, send)
return
try:
req = JSONRPCRequest.model_validate(decoded)
except ValidationError:
# Well-formed JSON that isn't a single request object. The transport
# spec permits notification POSTs and gives the server two responses
# (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
# item 5). The core protocol defines no client→server notifications
# over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
# entry takes the cannot-accept branch. TODO(L57): S4 owns the
# strict-vs-lenient choice.
rej = JSONRPCError(
jsonrpc="2.0",
id=None,
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
)
await _write(rej, scope, receive, send)
# A batch, a posted response (clients MUST NOT send those: streamable-http
# §Sending Messages item 4), or a request whose envelope is malformed.
await _write(_INVALID_BODY, scope, receive, send)
return

if req.method == "subscriptions/listen" and not has_sse:
Expand Down
30 changes: 22 additions & 8 deletions src/mcp/shared/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"find_duplicated_routing_header",
"find_invalid_x_mcp_header",
"mcp_param_headers",
"unsupported_protocol_version_rejection",
"validate_mcp_param_headers",
"x_mcp_header_map",
]
Expand Down Expand Up @@ -367,6 +368,25 @@ def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str |
return None


def unsupported_protocol_version_rejection(
requested: str, supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS
) -> InboundLadderRejection | None:
"""The `UNSUPPORTED_PROTOCOL_VERSION` rejection for `requested`, or `None` if it is served.

The request ladder's last rung, shared with the transport's notification arm
so both message kinds name the same `supported` list in the same words.
"""
if requested in supported_modern_versions:
return None
return InboundLadderRejection(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="Unsupported protocol version",
data=UnsupportedProtocolVersionErrorData(
supported=list(supported_modern_versions), requested=requested
).model_dump(mode="json"),
)


def classify_inbound_request(
body: Mapping[str, Any],
*,
Expand Down Expand Up @@ -464,14 +484,8 @@ def classify_inbound_request(
message="the protocol-version envelope value must be a string",
)

if protocol_version not in supported_modern_versions:
return InboundLadderRejection(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="Unsupported protocol version",
data=UnsupportedProtocolVersionErrorData(
supported=list(supported_modern_versions), requested=protocol_version
).model_dump(mode="json"),
)
if (unsupported := unsupported_protocol_version_rejection(protocol_version, supported_modern_versions)) is not None:
return unsupported

return InboundModernRoute(
protocol_version=protocol_version,
Expand Down
23 changes: 22 additions & 1 deletion tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3182,7 +3182,12 @@ def __post_init__(self) -> None:
source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
behavior="A POST containing only notifications or responses returns 202 with no body.",
transports=("streamable-http",),
note="Only observable over HTTP: 202 is an HTTP status code.",
removed_in="2026-07-28",
superseded_by="hosting:http:modern:notification-post-202",
note=(
"Only observable over HTTP: 202 is an HTTP status code. At 2026-07-28 clients no longer post "
"responses (streamable-http §Sending Messages item 4), so only the notification half carries over."
),
),
"hosting:http:onerror": Requirement(
source="sdk",
Expand Down Expand Up @@ -3375,6 +3380,22 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
),
"hosting:http:modern:notification-post-202": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#sending-messages",
behavior=(
"A 2026-07-28 POST whose body is a single JSON-RPC notification is acknowledged 202 with no "
"body (the spec's accept branch) and is not dispatched; a posted JSON-RPC response is rejected "
"INVALID_REQUEST at HTTP 400."
),
added_in="2026-07-28",
supersedes=("hosting:http:notifications-202",),
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the HTTP status is the assertion. The revision defines no "
"client-to-server notifications on this transport (cancellation is closing the response stream), "
"so accept-and-drop is the SDK's choice between the two responses the spec permits."
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Client transport: streamable HTTP
# ═══════════════════════════════════════════════════════════════════════════
Expand Down
35 changes: 35 additions & 0 deletions tests/interaction/transports/test_hosting_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
SERVER_INFO_META_KEY,
CallToolRequestParams,
CallToolResult,
DiscoverResult,
EmptyResult,
ErrorData,
Implementation,
JSONRPCError,
JSONRPCResponse,
Expand Down Expand Up @@ -153,6 +155,39 @@
assert "mcp-session-id" not in response.headers


@requirement("hosting:http:modern:notification-post-202")
@pytest.mark.parametrize("json_response", [True, False], ids=["json", "sse"])
@pytest.mark.parametrize("stateless_http", [True, False], ids=["stateless-flag", "default"])
async def test_modern_notification_post_is_acknowledged_202_and_a_posted_response_is_rejected(
json_response: bool, stateless_http: bool
) -> None:
"""A 2026-07-28 notification POST is answered 202 with no body; a posted response is 400 INVALID_REQUEST.

Spec-permitted (streamable-http §Sending Messages item 5): the server may accept (202) or refuse
(4xx) a notification POST, and the SDK accepts -- the same answer the legacy leg gives, so a
client's courtesy `notifications/cancelled` is not met with an error on one era only.
Spec-mandated (item 4): clients MUST NOT post responses, so one is refused. Driven through the
mounted app so the manager's header routing is in the path, under both response modes and both
values of the legacy-only `stateless_http` flag (neither is read before the modern entry answers).

Check warning on line 171 in tests/interaction/transports/test_hosting_http_modern.py

View check run for this annotation

Claude / Claude Code Review

[quality] nit: the new test's docstring claims "(neither is read before the modern entry answers)" about the json_response/stateless_http parametrization, but json_response IS read before the notification 202 — handle_modern_request's Accept gate (`if n

[quality] nit: the new test's docstring claims "(neither is read before the modern entry answers)" about the json_response/stateless_http parametrization, but json_response IS read before the notification 202 — handle_modern_request's Accept gate (`if not has_json or (not json_response and not has_sse)`, src/mcp/server/_streamable_http_modern.py:396) evaluates it ahead of _acknowledge_notification, and it genuinely changes the answer: with json_response=False a notification POST whose Accept l
Comment on lines +169 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] nit: the new test's docstring claims "(neither is read before the modern entry answers)" about the json_response/stateless_http parametrization, but json_response IS read before the notification 202 — handle_modern_request's Accept gate (if not has_json or (not json_response and not has_sse), src/mcp/server/_streamable_http_modern.py:396) evaluates it ahead of _acknowledge_notification, and it genuinely changes the answer: with json_response=False a notification POST whose Accept lacks text/event-stream is 406, with json_response=True it is 202.

Extended reasoning...

Concrete cost: a documented-but-false invariant in the test that .claude/skills/test-quality/SKILL.md-style provenance docstrings are supposed to state accurately. A maintainer extending the notification arm (e.g. deciding whether a bare Accept: application/json notification POST should 202 in SSE mode, exactly the interop shape this PR is about) reads this docstring, concludes the response-mode flag cannot influence a notification POST's answer, and skips testing the 406-vs-202 divergence — the docstring is only true because base_headers() happens to send both accept types. The stateless_http half of the claim is correct; the json_response half is verifiably wrong at src/mcp/server/_streamable_http_modern.py:396.

Verification: nit — the test docstring at tests/interaction/transports/test_hosting_http_modern.py:170-171 says "under both response modes and both values of the legacy-only stateless_http flag (neither is read before the modern entry answers)", but json_response (the response-mode flag) is read before the notification arm answers: src/mcp/server/_streamable_http_modern.py:395-397 `has_json, has_sse = che

"""
notification = {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}
posted_response: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "result": {}}
async with mounted_app(_server(), json_response=json_response, stateless_http=stateless_http) as (http, _):
acknowledged = await http.post(
"/mcp", json=notification, headers=_modern_headers(method="notifications/cancelled")
)
refused = await http.post("/mcp", json=posted_response, headers=_modern_headers(method="tools/list"))

assert (acknowledged.status_code, acknowledged.content) == (202, b"")
assert "mcp-session-id" not in acknowledged.headers
assert refused.status_code == 400
assert JSONRPCError.model_validate(refused.json()) == JSONRPCError(
jsonrpc="2.0",
id=None,
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
)


@requirement("hosting:http:modern:initialize-removed")
async def test_modern_initialize_is_method_not_found() -> None:
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
Expand Down
Loading
Loading