Skip to content

Commit 47b655c

Browse files
committed
Advance mint counter past caller-supplied integer request IDs (#3126)
When a caller supplies an integer request ID via CallOptions["request_id"], advance the monotonic mint counter past it so future auto-minted IDs never collide with a previously-used supplied ID. This satisfies the JSON-RPC spec requirement that request IDs MUST NOT be reused within the same session. Applied to both JSONRPCDispatcher and DirectDispatcher.
1 parent a4f4ccd commit 47b655c

3 files changed

Lines changed: 71 additions & 2 deletions

File tree

src/mcp/shared/direct_dispatcher.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ async def _dispatch_request(
250250
in_flight_key = coerce_request_id(request_id)
251251
if in_flight_key in self._in_flight_ids:
252252
raise ValueError(f"request id {request_id!r} is already in flight")
253+
# Advance the mint counter past any supplied integer id so
254+
# the monotonic sequence never revisits it after completion.
255+
if isinstance(in_flight_key, int):
256+
self._next_id = max(self._next_id, in_flight_key)
253257
else:
254258
# Synthesize an id (the DispatchContext contract reserves None
255259
# for notifications), minting past any key a supplied id

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ async def send_raw_request(
346346
pending_key = coerce_request_id(request_id)
347347
if pending_key in self._pending:
348348
raise ValueError(f"request id {request_id!r} is already in flight")
349+
# Advance the mint counter past any supplied integer id so the
350+
# monotonic sequence can never revisit it after the request completes.
351+
# This satisfies the spec: "The request ID MUST NOT have been
352+
# previously used by the requestor within the same session."
353+
if isinstance(pending_key, int):
354+
self._next_id = max(self._next_id, pending_key)
349355
else:
350356
# Mint past any key a supplied id occupies: the collision error is
351357
# reserved for the caller who actually chose the id.

tests/shared/test_dispatcher.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,12 @@ async def parked() -> None:
474474

475475
tg.start_soon(parked)
476476
await entered.wait()
477-
# The counter mints 1 and 2, then skips the occupied 3 to 4.
477+
# The counter is advanced to 3 when "3" is supplied, so
478+
# subsequent mints produce 4, 5, 6 — never revisiting 3.
478479
for _ in range(3):
479480
await client.send_raw_request("plain", None)
480481
release.set()
481-
assert [request_id for request_id in seen_ids if request_id != "3"] == [1, 2, 4]
482+
assert [request_id for request_id in seen_ids if request_id != "3"] == [4, 5, 6]
482483

483484

484485
@pytest.mark.anyio
@@ -512,6 +513,64 @@ async def first() -> None:
512513
assert await client.send_raw_request("again", None, {"request_id": "7"}) == {}
513514

514515

516+
@pytest.mark.anyio
517+
async def test_minted_ids_never_reuse_a_completed_caller_supplied_id(pair_factory: PairFactory):
518+
"""Regression: after a caller-supplied integer id completes, the mint counter
519+
must have advanced past it so no future minted id collides. This is the bug
520+
from GH-3126: the counter could land on a previously-used supplied id because
521+
the guard only checked `_pending`/`_in_flight_ids` (cleared on completion)."""
522+
seen_ids: list[RequestId | None] = []
523+
524+
async def track(
525+
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
526+
) -> dict[str, Any]:
527+
seen_ids.append(ctx.request_id)
528+
return {}
529+
530+
async with running_pair(pair_factory, server_on_request=track) as (client, *_):
531+
with anyio.fail_after(5):
532+
# Send a request with a caller-supplied integer id.
533+
await client.send_raw_request("supplied", None, {"request_id": 5})
534+
# Now send several auto-minted requests. None should reuse id 5.
535+
for _ in range(6):
536+
await client.send_raw_request("minted", None)
537+
538+
# The first id is the supplied 5; the rest are minted sequentially starting
539+
# above 5 (i.e. 6, 7, 8, 9, 10, 11).
540+
assert seen_ids[0] == 5
541+
minted_ids = seen_ids[1:]
542+
assert 5 not in minted_ids
543+
# Verify they are unique and monotonically increasing integers > 5.
544+
assert all(isinstance(i, int) and i > 5 for i in minted_ids)
545+
assert len(minted_ids) == len(set(minted_ids))
546+
547+
548+
@pytest.mark.anyio
549+
async def test_minted_ids_never_reuse_a_completed_numeric_string_id(pair_factory: PairFactory):
550+
"""Same as above but with a numeric-string supplied id ("3"), which coerces to
551+
int 3 in the collision domain. Minted ids must skip past 3."""
552+
seen_ids: list[RequestId | None] = []
553+
554+
async def track(
555+
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
556+
) -> dict[str, Any]:
557+
seen_ids.append(ctx.request_id)
558+
return {}
559+
560+
async with running_pair(pair_factory, server_on_request=track) as (client, *_):
561+
with anyio.fail_after(5):
562+
await client.send_raw_request("supplied", None, {"request_id": "3"})
563+
for _ in range(4):
564+
await client.send_raw_request("minted", None)
565+
566+
assert seen_ids[0] == "3"
567+
minted_ids = seen_ids[1:]
568+
# 3 should never appear (even though "3" completed and left _pending/_in_flight).
569+
assert 3 not in minted_ids
570+
assert all(isinstance(i, int) and i > 3 for i in minted_ids)
571+
assert len(minted_ids) == len(set(minted_ids))
572+
573+
515574
@pytest.mark.anyio
516575
async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory):
517576
"""The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do."""

0 commit comments

Comments
 (0)