Skip to content

Commit be9c118

Browse files
committed
fix(streamable-http): mirror the transport's 405 shape (Allow header + body)
cubic-dev-ai flagged that the manager's manager-layer 405 for PUT/PATCH/OPTIONS/HEAD without a session-id was missing the RFC 7231 ``Allow`` header and diverged from the transport's response body. That breaks clients / middleware that rely on the standard 405 metadata to learn which methods are allowed on the resource. The manager now mirrors ``StreamableHTTPServerTransport._handle_unsupported_request`` exactly: * Body: JSON-RPC error with message ``"Method Not Allowed"`` (previously ``"Method Not Allowed (PUT)"`` — a divergence). * Headers: ``Content-Type: application/json`` + ``Allow: GET, POST, DELETE``. The 400 branch for GET/DELETE is unchanged. The parametrized regression test now also asserts the ``Allow`` header value for every 405 row (PUT/PATCH/OPTIONS/HEAD).
1 parent cf87495 commit be9c118

2 files changed

Lines changed: 24 additions & 2 deletions

File tree

src/mcp/server/streamable_http_manager.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,12 @@ async def _handle_stateful_request(
298298
# so the correct response for a missing session is 400 — matching
299299
# the transport's existing "Missing session ID" wording. Anything
300300
# else is a genuinely unsupported method, so 405 is more accurate.
301+
# Both branches mirror the shape produced by
302+
# ``StreamableHTTPServerTransport._create_error_response`` /
303+
# ``_handle_unsupported_request`` so clients see the same
304+
# JSON-RPC body and headers (including the RFC 7231 ``Allow``
305+
# advertisement for 405) whether the rejection happens here or
306+
# in the transport layer.
301307
if request.method in ("GET", "DELETE"):
302308
error_body = JSONRPCError(
303309
jsonrpc="2.0",
@@ -313,12 +319,15 @@ async def _handle_stateful_request(
313319
error_body = JSONRPCError(
314320
jsonrpc="2.0",
315321
id="server-error",
316-
error=ErrorData(code=INVALID_REQUEST, message=f"Method Not Allowed ({request.method})"),
322+
error=ErrorData(code=INVALID_REQUEST, message="Method Not Allowed"),
317323
)
318324
response = Response(
319325
content=error_body.model_dump_json(by_alias=True, exclude_none=True),
320326
status_code=405,
321-
media_type="application/json",
327+
headers={
328+
"Content-Type": "application/json",
329+
"Allow": "GET, POST, DELETE",
330+
},
322331
)
323332
await response(scope, receive, send)
324333
return

tests/server/test_streamable_http_manager.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,19 @@ async def mock_receive(): # pragma: no cover
469469
assert error_data["error"]["code"] == INVALID_REQUEST
470470
assert expected_message_substring in error_data["error"]["message"]
471471

472+
# RFC 7231: a 405 response must advertise the allowed methods via the
473+
# ``Allow`` header. The manager's 405 mirrors the transport's shape
474+
# (``Allow: GET, POST, DELETE``) exactly so downstream clients get
475+
# identical metadata whether the rejection happens here or one layer
476+
# deeper.
477+
if expected_status == 405:
478+
response_headers = {
479+
name.decode().lower(): value.decode() for name, value in response_start.get("headers", [])
480+
}
481+
assert response_headers.get("allow") == "GET, POST, DELETE", (
482+
f"405 response must include RFC 7231 Allow header — got headers={response_headers}"
483+
)
484+
472485

473486
@pytest.mark.anyio
474487
async def test_bad_host_header_rejected_before_session_allocation():

0 commit comments

Comments
 (0)