Skip to content

Commit b2025ab

Browse files
authored
Acknowledge notification POSTs with 202 on the 2026-07-28 HTTP entry (#3326)
1 parent 9057285 commit b2025ab

8 files changed

Lines changed: 254 additions & 37 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ The constructor covers the methods MCP defines. `add_request_handler` covers eve
173173
--8<-- "docs_src/lowlevel/tutorial006.py"
174174
```
175175

176-
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
176+
* 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.
177177
* `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.
178178
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
179179

docs/advanced/middleware.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ That is the point. Middleware wraps **every** inbound message:
4848

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

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

106109
* A middleware is `async (ctx, call_next) -> result`, passed as `MCPServer(middleware=[...])` (or
107110
appended to `mcp.middleware`), and appended to `server.middleware` on the low-level `Server`.
108-
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
109-
unknown methods) and runs outermost-first.
111+
* It wraps **every** inbound message that reaches the server (`server/discover`, `initialize`,
112+
requests, notifications, unknown methods) and runs outermost-first.
110113
* `ctx.request_id is None` is how you tell a notification from a request.
111114
* Raise instead of calling `call_next` to refuse one message; the connection survives.
112115
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See

src/mcp/server/_streamable_http_modern.py

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
path for earlier protocol revisions.
66
77
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
8-
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
8+
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. A
9+
notification POST is acknowledged `202` and dropped: the core protocol defines
10+
no client-to-server notifications on this wire (cancellation is closing the
11+
response stream), and a per-request entry has nothing for one to act on. JSON
912
mode handles the request directly in the ASGI task. SSE mode runs the handler
1013
as a sibling task and defers committing to `text/event-stream` until the
1114
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
@@ -56,10 +59,12 @@
5659
from mcp.shared.inbound import (
5760
ERROR_CODE_HTTP_STATUS,
5861
MCP_PARAM_HEADER_PREFIX,
62+
MCP_PROTOCOL_VERSION_HEADER,
5963
InboundLadderRejection,
6064
InboundModernRoute,
6165
classify_inbound_request,
6266
find_duplicated_routing_header,
67+
unsupported_protocol_version_rejection,
6368
validate_mcp_param_headers,
6469
)
6570
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
@@ -162,7 +167,7 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt
162167

163168
async def _write_rejection(
164169
rejection: InboundLadderRejection,
165-
request_id: RequestId,
170+
request_id: RequestId | None,
166171
scope: Scope,
167172
receive: Receive,
168173
send: Send,
@@ -196,6 +201,60 @@ async def _write(
196201
)(scope, receive, send)
197202

198203

204+
_INVALID_BODY: Final = JSONRPCError(
205+
jsonrpc="2.0",
206+
id=None,
207+
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
208+
)
209+
"""Well-formed JSON that is not one request or notification: a batch, a posted response, a malformed envelope."""
210+
211+
212+
def _is_notification_shaped(decoded: Any) -> bool:
213+
"""Whether a decoded POST body is a single JSON object without an `id` member.
214+
215+
JSON-RPC 2.0 §4.1: a notification is a request object without an "id"
216+
member, so key presence — not which model happens to validate — picks the
217+
arm. (The notification model ignores unknown keys; letting it catch a
218+
request whose id is malformed would 202 a message that is owed an error.)
219+
"""
220+
return isinstance(decoded, dict) and "id" not in decoded
221+
222+
223+
async def _acknowledge_notification(
224+
decoded: dict[str, Any],
225+
request: Request,
226+
scope: Scope,
227+
receive: Receive,
228+
send: Send,
229+
) -> None:
230+
"""Answer an id-less POST body: `202` for a notification at a served version, a rejection otherwise.
231+
232+
Streamable-http §Sending Messages item 5 lets a server accept (202, no
233+
body) or refuse (4xx) a notification POST; this entry accepts and drops.
234+
The 2026-07-28 core protocol defines no client-to-server notifications over
235+
HTTP (a client cancels by closing the response stream) and a per-request
236+
entry holds no cross-request state for one to act on — honouring a posted
237+
`notifications/cancelled` by client-chosen request id would let one
238+
anonymous caller cancel another's work — but clients in the field still
239+
POST them, and notifications are fire-and-forget, so they are acknowledged
240+
as the handshake-era transport does rather than answered with an error
241+
nobody reads. Header requirements for notification POSTs are undefined at
242+
this revision; only the routing header that brought the POST here is
243+
checked, so a version this entry does not serve is told so, as a request is.
244+
"""
245+
try:
246+
notification = JSONRPCNotification.model_validate(decoded)
247+
except ValidationError:
248+
await _write(_INVALID_BODY, scope, receive, send)
249+
return
250+
requested = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, "")
251+
if (unsupported := unsupported_protocol_version_rejection(requested)) is not None:
252+
await _write_rejection(unsupported, None, scope, receive, send)
253+
return
254+
logger.debug("acknowledged and dropped client notification %s at %s", notification.method, requested)
255+
await Response(status_code=202)(scope, receive, send)
256+
257+
199258
_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower()
200259

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

367419
if req.method == "subscriptions/listen" and not has_sse:

src/mcp/shared/inbound.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"find_duplicated_routing_header",
5353
"find_invalid_x_mcp_header",
5454
"mcp_param_headers",
55+
"unsupported_protocol_version_rejection",
5556
"validate_mcp_param_headers",
5657
"x_mcp_header_map",
5758
]
@@ -367,6 +368,25 @@ def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str |
367368
return None
368369

369370

371+
def unsupported_protocol_version_rejection(
372+
requested: str, supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS
373+
) -> InboundLadderRejection | None:
374+
"""The `UNSUPPORTED_PROTOCOL_VERSION` rejection for `requested`, or `None` if it is served.
375+
376+
The request ladder's last rung, shared with the transport's notification arm
377+
so both message kinds name the same `supported` list in the same words.
378+
"""
379+
if requested in supported_modern_versions:
380+
return None
381+
return InboundLadderRejection(
382+
code=UNSUPPORTED_PROTOCOL_VERSION,
383+
message="Unsupported protocol version",
384+
data=UnsupportedProtocolVersionErrorData(
385+
supported=list(supported_modern_versions), requested=requested
386+
).model_dump(mode="json"),
387+
)
388+
389+
370390
def classify_inbound_request(
371391
body: Mapping[str, Any],
372392
*,
@@ -464,14 +484,8 @@ def classify_inbound_request(
464484
message="the protocol-version envelope value must be a string",
465485
)
466486

467-
if protocol_version not in supported_modern_versions:
468-
return InboundLadderRejection(
469-
code=UNSUPPORTED_PROTOCOL_VERSION,
470-
message="Unsupported protocol version",
471-
data=UnsupportedProtocolVersionErrorData(
472-
supported=list(supported_modern_versions), requested=protocol_version
473-
).model_dump(mode="json"),
474-
)
487+
if (unsupported := unsupported_protocol_version_rejection(protocol_version, supported_modern_versions)) is not None:
488+
return unsupported
475489

476490
return InboundModernRoute(
477491
protocol_version=protocol_version,

tests/interaction/_requirements.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3182,7 +3182,12 @@ def __post_init__(self) -> None:
31823182
source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server",
31833183
behavior="A POST containing only notifications or responses returns 202 with no body.",
31843184
transports=("streamable-http",),
3185-
note="Only observable over HTTP: 202 is an HTTP status code.",
3185+
removed_in="2026-07-28",
3186+
superseded_by="hosting:http:modern:notification-post-202",
3187+
note=(
3188+
"Only observable over HTTP: 202 is an HTTP status code. At 2026-07-28 clients no longer post "
3189+
"responses (streamable-http §Sending Messages item 4), so only the notification half carries over."
3190+
),
31863191
),
31873192
"hosting:http:onerror": Requirement(
31883193
source="sdk",
@@ -3375,6 +3380,22 @@ def __post_init__(self) -> None:
33753380
transports=("streamable-http",),
33763381
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
33773382
),
3383+
"hosting:http:modern:notification-post-202": Requirement(
3384+
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#sending-messages",
3385+
behavior=(
3386+
"A 2026-07-28 POST whose body is a single JSON-RPC notification is acknowledged 202 with no "
3387+
"body (the spec's accept branch) and is not dispatched; a posted JSON-RPC response is rejected "
3388+
"INVALID_REQUEST at HTTP 400."
3389+
),
3390+
added_in="2026-07-28",
3391+
supersedes=("hosting:http:notifications-202",),
3392+
transports=("streamable-http",),
3393+
note=(
3394+
"Only observable over streamable HTTP: the HTTP status is the assertion. The revision defines no "
3395+
"client-to-server notifications on this transport (cancellation is closing the response stream), "
3396+
"so accept-and-drop is the SDK's choice between the two responses the spec permits."
3397+
),
3398+
),
33783399
# ═══════════════════════════════════════════════════════════════════════════
33793400
# Client transport: streamable HTTP
33803401
# ═══════════════════════════════════════════════════════════════════════════

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
HEADER_MISMATCH,
2121
INTERNAL_ERROR,
2222
INVALID_PARAMS,
23+
INVALID_REQUEST,
2324
METHOD_NOT_FOUND,
2425
MISSING_REQUIRED_CLIENT_CAPABILITY,
2526
SERVER_INFO_META_KEY,
2627
CallToolRequestParams,
2728
CallToolResult,
2829
DiscoverResult,
2930
EmptyResult,
31+
ErrorData,
3032
Implementation,
3133
JSONRPCError,
3234
JSONRPCResponse,
@@ -153,6 +155,39 @@ async def test_modern_response_carries_no_session_id_header() -> None:
153155
assert "mcp-session-id" not in response.headers
154156

155157

158+
@requirement("hosting:http:modern:notification-post-202")
159+
@pytest.mark.parametrize("json_response", [True, False], ids=["json", "sse"])
160+
@pytest.mark.parametrize("stateless_http", [True, False], ids=["stateless-flag", "default"])
161+
async def test_modern_notification_post_is_acknowledged_202_and_a_posted_response_is_rejected(
162+
json_response: bool, stateless_http: bool
163+
) -> None:
164+
"""A 2026-07-28 notification POST is answered 202 with no body; a posted response is 400 INVALID_REQUEST.
165+
166+
Spec-permitted (streamable-http §Sending Messages item 5): the server may accept (202) or refuse
167+
(4xx) a notification POST, and the SDK accepts -- the same answer the legacy leg gives, so a
168+
client's courtesy `notifications/cancelled` is not met with an error on one era only.
169+
Spec-mandated (item 4): clients MUST NOT post responses, so one is refused. Driven through the
170+
mounted app so the manager's header routing is in the path, under both response modes and both
171+
values of the legacy-only `stateless_http` flag (neither is read before the modern entry answers).
172+
"""
173+
notification = {"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}}
174+
posted_response: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "result": {}}
175+
async with mounted_app(_server(), json_response=json_response, stateless_http=stateless_http) as (http, _):
176+
acknowledged = await http.post(
177+
"/mcp", json=notification, headers=_modern_headers(method="notifications/cancelled")
178+
)
179+
refused = await http.post("/mcp", json=posted_response, headers=_modern_headers(method="tools/list"))
180+
181+
assert (acknowledged.status_code, acknowledged.content) == (202, b"")
182+
assert "mcp-session-id" not in acknowledged.headers
183+
assert refused.status_code == 400
184+
assert JSONRPCError.model_validate(refused.json()) == JSONRPCError(
185+
jsonrpc="2.0",
186+
id=None,
187+
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request or notification object"),
188+
)
189+
190+
156191
@requirement("hosting:http:modern:initialize-removed")
157192
async def test_modern_initialize_is_method_not_found() -> None:
158193
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.

0 commit comments

Comments
 (0)