From 906f53c9279b745d3f028fefc3484038384c9849 Mon Sep 17 00:00:00 2001 From: charles-adedotun Date: Mon, 11 May 2026 03:09:55 -0500 Subject: [PATCH] fix(shared): strip envelope fields from request_data before re-wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseSession.send_request constructs a JSONRPCRequest as: JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) When request_data came from model_dump() on an existing JSONRPCRequest (forwarding/proxy scenarios — e.g. a ServerSession receiving a request and re-emitting it through a ClientSession), the dump preserves the envelope fields jsonrpc and id, which then collide with the explicit kwargs above and raise: TypeError: got multiple values for keyword argument 'jsonrpc' Fix: pop jsonrpc and id from request_data before the unpack. Added a regression test that exercises the forwarding pattern via create_client_server_memory_streams. Fixes #2548 --- src/mcp/shared/session.py | 6 ++++ tests/shared/test_session.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 243eef5ae..0ce6135de 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -282,6 +282,12 @@ async def send_request( meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {}) inject_trace_context(meta) + # Strip envelope fields that would collide with the explicit kwargs below. + # Happens when request_data comes from model_dump() on a JSONRPCRequest + # (e.g. forwarding/proxy scenarios). See issue #2548. + request_data.pop("jsonrpc", None) + request_data.pop("id", None) + jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data) await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata)) diff --git a/tests/shared/test_session.py b/tests/shared/test_session.py index d7c6cc3b5..9c5af94c1 100644 --- a/tests/shared/test_session.py +++ b/tests/shared/test_session.py @@ -416,3 +416,56 @@ async def make_request(client_session: ClientSession): # Pending request completed successfully assert len(result_holder) == 1 assert isinstance(result_holder[0], EmptyResult) + + +@pytest.mark.anyio +async def test_send_request_strips_envelope_fields_when_forwarding(): + """Test that send_request does not raise TypeError when request_data contains + jsonrpc and id fields (e.g. from model_dump() on a JSONRPCRequest). + + This is the forwarding/proxy scenario: a session receives an incoming + JSONRPCRequest and re-emits it via another session's send_request. + model_dump() on a JSONRPCRequest preserves the envelope fields jsonrpc and + id, which used to collide with the explicit kwargs at the construction site. + Regression test for issue #2548. + """ + ev_response_received = anyio.Event() + result_holder: list[EmptyResult] = [] + + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def mock_server() -> None: + """Receive the forwarded request and send back an empty response.""" + message = await server_read.receive() + assert isinstance(message, SessionMessage) + assert isinstance(message.message, JSONRPCRequest) + request_id = message.message.id + await server_write.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=request_id, result={}))) + + async def forward_request(client_session: ClientSession) -> None: + """Simulate a proxy forwarding an incoming JSONRPCRequest.""" + # Build a JSONRPCRequest as a proxy would receive it from an upstream + # server. model_dump() on this object includes jsonrpc and id, which + # previously caused TypeError: got multiple values for keyword argument. + incoming = JSONRPCRequest(jsonrpc="2.0", id=99, method="ping", params=None) + result = await client_session.send_request( + incoming, # type: ignore[arg-type] + EmptyResult, + ) + result_holder.append(result) + ev_response_received.set() + + async with ( + anyio.create_task_group() as tg, + ClientSession(read_stream=client_read, write_stream=client_write) as client_session, + ): + tg.start_soon(mock_server) + tg.start_soon(forward_request, client_session) + + with anyio.fail_after(2): # pragma: no branch + await ev_response_received.wait() + + assert len(result_holder) == 1 + assert isinstance(result_holder[0], EmptyResult)