Skip to content

Commit a9cf0da

Browse files
committed
[v1.x] fix(streamable-http): reject duplicate JSON-RPC ids with 409
The MCP base protocol requires that a request ID "MUST NOT have been previously used by the requestor within the same session". Before this change a duplicate POST silently overwrote the prior _request_streams entry, leaving the original in-flight request hanging forever. Mirror the existing GET_STREAM_KEY collision branch and return 409 Conflict, keeping the prior stream untouched. Closes #2655
1 parent 9773a3f commit a9cf0da

2 files changed

Lines changed: 98 additions & 3 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,9 @@ async def _validate_accept_header(self, request: Request, scope: Scope, send: Se
443443
return False
444444
return True
445445

446-
async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None:
446+
async def _handle_post_request( # noqa: C901, PLR0915
447+
self, scope: Scope, request: Request, receive: Receive, send: Send
448+
) -> None:
447449
"""Handle POST requests containing JSON-RPC messages."""
448450
writer = self._read_stream_writer
449451
if writer is None: # pragma: no cover
@@ -531,7 +533,18 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
531533
)
532534

533535
# Extract the request ID outside the try block for proper scope
534-
request_id = str(message.root.id) # pragma: no cover
536+
request_id = str(message.root.id)
537+
# The MCP base protocol requires that "the request ID MUST NOT have been previously
538+
# used by the requestor within the same session". If a client violates this, the
539+
# prior stream would be silently overwritten and the in-flight request would hang,
540+
# so reject the duplicate and leave the existing request untouched.
541+
if request_id in self._request_streams:
542+
response = self._create_error_response(
543+
f"Conflict: request id {request_id!r} is already in flight on this session",
544+
HTTPStatus.CONFLICT,
545+
)
546+
await response(scope, receive, send)
547+
return
535548
# Register this stream for the request ID
536549
self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](0) # pragma: no cover
537550
request_stream_reader = self._request_streams[request_id][1] # pragma: no cover

tests/server/test_streamable_http_manager.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
"""Tests for StreamableHTTPSessionManager."""
22

33
import json
4+
from http import HTTPStatus
45
from typing import Any
56
from unittest.mock import AsyncMock, patch
67

78
import anyio
89
import pytest
10+
from starlette.requests import Request
911
from starlette.types import Message
1012

1113
from mcp.server import streamable_http_manager
1214
from mcp.server.lowlevel import Server
13-
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
15+
from mcp.server.streamable_http import (
16+
MCP_SESSION_ID_HEADER,
17+
EventMessage,
18+
StreamableHTTPServerTransport,
19+
)
1420
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
21+
from mcp.shared.message import SessionMessage
1522
from mcp.types import INVALID_REQUEST
1623

1724

@@ -390,3 +397,78 @@ def test_session_idle_timeout_rejects_non_positive():
390397
def test_session_idle_timeout_rejects_stateless():
391398
with pytest.raises(RuntimeError, match="not supported in stateless"):
392399
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=30, stateless=True)
400+
401+
402+
@pytest.mark.anyio
403+
async def test_handle_post_rejects_duplicate_request_id():
404+
"""Reject a POST whose JSON-RPC id matches an in-flight request on the same session.
405+
406+
The MCP base protocol forbids reusing a request ID within a session. Prior to the
407+
fix, the second POST silently overwrote the prior ``_request_streams`` entry,
408+
leaving the first request hanging forever. Now the duplicate is rejected with
409+
409 Conflict and the prior in-flight stream is preserved untouched.
410+
"""
411+
transport = StreamableHTTPServerTransport(mcp_session_id=None)
412+
413+
# The early ``writer is None`` guard reads this; the duplicate-id branch never
414+
# actually sends on it, so a real stream is sufficient.
415+
read_writer, read_reader = anyio.create_memory_object_stream[SessionMessage | Exception](0)
416+
transport._read_stream_writer = read_writer
417+
418+
# Seed an in-flight request with id "1". The duplicate-id check must leave this
419+
# pair in place.
420+
in_flight_pair = anyio.create_memory_object_stream[EventMessage](0)
421+
transport._request_streams["1"] = in_flight_pair
422+
423+
body = json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 1, "params": {}}).encode()
424+
425+
body_sent = False
426+
427+
async def mock_receive() -> Message:
428+
nonlocal body_sent
429+
if body_sent: # pragma: no cover
430+
await anyio.sleep_forever()
431+
body_sent = True
432+
return {"type": "http.request", "body": body, "more_body": False}
433+
434+
sent_messages: list[Message] = []
435+
response_body = b""
436+
437+
async def mock_send(message: Message) -> None:
438+
nonlocal response_body
439+
sent_messages.append(message)
440+
if message["type"] == "http.response.body":
441+
response_body += message.get("body", b"")
442+
443+
scope = {
444+
"type": "http",
445+
"method": "POST",
446+
"path": "/mcp",
447+
"headers": [
448+
(b"content-type", b"application/json"),
449+
(b"accept", b"application/json, text/event-stream"),
450+
],
451+
}
452+
453+
request = Request(scope, mock_receive)
454+
await transport._handle_post_request(scope, request, mock_receive, mock_send)
455+
456+
response_start = next(
457+
(msg for msg in sent_messages if msg["type"] == "http.response.start"),
458+
None,
459+
)
460+
assert response_start is not None, "Should have sent a response"
461+
assert response_start["status"] == HTTPStatus.CONFLICT
462+
463+
error = json.loads(response_body)
464+
assert error["jsonrpc"] == "2.0"
465+
assert error["error"]["code"] == INVALID_REQUEST
466+
assert "already in flight" in error["error"]["message"]
467+
468+
# The pre-existing in-flight stream must remain untouched.
469+
assert transport._request_streams["1"] is in_flight_pair
470+
471+
in_flight_pair[0].close()
472+
in_flight_pair[1].close()
473+
read_writer.close()
474+
read_reader.close()

0 commit comments

Comments
 (0)