Skip to content

Commit b1f49e2

Browse files
committed
fix: JSONRPCDispatcher coerces string response/progress IDs to int for correlation
Matches BaseSession._normalize_request_id and the TypeScript SDK: a peer that echoes the request ID as a JSON string still resolves the waiter. Applied at both lookup sites (_resolve_pending and the progress-token match). Parity prep for the PR6 e2e suite.
1 parent a1c16c4 commit b1f49e2

2 files changed

Lines changed: 100 additions & 2 deletions

File tree

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@
7676
`TransportContext(kind="jsonrpc", can_send_request=True)` when not supplied."""
7777

7878

79+
def _coerce_id(request_id: RequestId) -> RequestId:
80+
"""Coerce a string request ID to int when it's a valid int literal.
81+
82+
`_allocate_id` only ever produces ``int`` keys for ``_pending``, but a peer
83+
may echo the ID back as a JSON string. The TypeScript SDK and `BaseSession`
84+
both perform this coercion at lookup time so the response still correlates.
85+
"""
86+
if isinstance(request_id, str):
87+
try:
88+
return int(request_id)
89+
except ValueError:
90+
pass
91+
return request_id
92+
93+
7994
@dataclass(slots=True)
8095
class _Pending:
8196
"""An outbound request awaiting its response."""
@@ -409,7 +424,7 @@ def _dispatch_notification(
409424
if msg.method == "notifications/progress":
410425
match msg.params:
411426
case {"progressToken": str() | int() as token, "progress": int() | float() as progress} if (
412-
pending := self._pending.get(token)
427+
pending := self._pending.get(_coerce_id(token))
413428
) is not None and pending.on_progress is not None:
414429
total = msg.params.get("total")
415430
message = msg.params.get("message")
@@ -428,7 +443,7 @@ def _dispatch_notification(
428443
self._spawn(on_notify, dctx, msg.method, msg.params, sender_ctx=sender_ctx)
429444

430445
def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None:
431-
pending = self._pending.get(request_id) if request_id is not None else None
446+
pending = self._pending.get(_coerce_id(request_id)) if request_id is not None else None
432447
if pending is None:
433448
logger.debug("dropping response for unknown/late request id %r", request_id)
434449
return

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from mcp.shared.exceptions import MCPError
1919
from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage]
2020
JSONRPCDispatcher,
21+
_coerce_id,
2122
_outbound_metadata,
2223
_Pending,
2324
)
@@ -29,6 +30,7 @@
2930
INVALID_PARAMS,
3031
ErrorData,
3132
JSONRPCError,
33+
JSONRPCNotification,
3234
JSONRPCRequest,
3335
JSONRPCResponse,
3436
Tool,
@@ -511,6 +513,87 @@ def test_outbound_metadata_with_resumption_token_returns_client_metadata():
511513
assert _outbound_metadata(None, {}) is None
512514

513515

516+
@pytest.mark.anyio
517+
async def test_response_with_string_id_correlates_to_int_keyed_pending_request():
518+
"""A peer that echoes the request ID as a JSON string still resolves the waiter."""
519+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
520+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
521+
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
522+
on_request, on_notify = echo_handlers(Recorder())
523+
try:
524+
async with anyio.create_task_group() as tg:
525+
await tg.start(client.run, on_request, on_notify)
526+
with anyio.fail_after(5):
527+
528+
async def respond_stringly() -> None:
529+
out = await c2s_recv.receive()
530+
assert isinstance(out, SessionMessage)
531+
assert isinstance(out.message, JSONRPCRequest)
532+
rid = out.message.id
533+
assert isinstance(rid, int)
534+
await s2c_send.send(
535+
SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=str(rid), result={"ok": True}))
536+
)
537+
538+
tg.start_soon(respond_stringly)
539+
result = await client.send_raw_request("ping", None)
540+
assert result == {"ok": True}
541+
tg.cancel_scope.cancel()
542+
finally:
543+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
544+
s.close()
545+
546+
547+
@pytest.mark.anyio
548+
async def test_progress_with_string_token_reaches_callback_for_int_keyed_request():
549+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
550+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
551+
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
552+
on_request, on_notify = echo_handlers(Recorder())
553+
seen: list[float] = []
554+
try:
555+
async with anyio.create_task_group() as tg:
556+
await tg.start(client.run, on_request, on_notify)
557+
with anyio.fail_after(5):
558+
559+
async def respond_with_string_token_progress() -> None:
560+
out = await c2s_recv.receive()
561+
assert isinstance(out, SessionMessage)
562+
assert isinstance(out.message, JSONRPCRequest)
563+
rid = out.message.id
564+
assert isinstance(rid, int)
565+
await s2c_send.send(
566+
SessionMessage(
567+
message=JSONRPCNotification(
568+
jsonrpc="2.0",
569+
method="notifications/progress",
570+
params={"progressToken": str(rid), "progress": 0.5},
571+
)
572+
)
573+
)
574+
await s2c_send.send(
575+
SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=rid, result={"ok": True}))
576+
)
577+
578+
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
579+
seen.append(progress)
580+
581+
tg.start_soon(respond_with_string_token_progress)
582+
result = await client.send_raw_request("ping", None, {"on_progress": on_progress})
583+
assert result == {"ok": True}
584+
tg.cancel_scope.cancel()
585+
finally:
586+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
587+
s.close()
588+
assert seen == [0.5]
589+
590+
591+
def test_coerce_id_passes_through_non_numeric_string_and_int():
592+
assert _coerce_id("7") == 7
593+
assert _coerce_id("not-an-int") == "not-an-int"
594+
assert _coerce_id(42) == 42
595+
596+
514597
@pytest.mark.anyio
515598
async def test_jsonrpc_error_response_with_null_id_is_dropped():
516599
"""Parse-error responses (id=null) have no waiter; they're logged and dropped."""

0 commit comments

Comments
 (0)