Skip to content

Commit 9416795

Browse files
committed
Reject server messages that are not JSON-RPC 2.0 in the client transports
## Motivation and Context Issue #589 reports that `MCP::Client#call_tool` returns normally when the server's response omits `jsonrpc` or sets it to `"1.0"`. The bundled transports never read that member of an incoming message, while the server side of this SDK answers `-32600` to such a request and the TypeScript, Python, Go, and C# SDKs validate every incoming message against a schema whose `jsonrpc` is the literal `"2.0"`. `MCP::Client::Stdio` and `MCP::Client::HTTP` now check it. A response that is not a JSON object carrying `"jsonrpc": "2.0"` fails the request with `RequestHandlerError` instead of being returned: over stdio the frame answering the awaited id, over HTTP the JSON body or the first response-shaped SSE event, on a resumed stream as well. Over HTTP that is what the Python client does with the answer to a request, and the TypeScript client with a JSON body. Over stdio both of them report the message as an error and keep reading; this transport reads synchronously and its `read_timeout` defaults to none, so reading on would wait forever, and the request fails at once instead. A server-to-client request that is not JSON-RPC 2.0 is ignored: a `ping` is not answered and a handler is not called. Frames for other ids keep being skipped, the body answering a notification is not checked, and under `mode: :auto` a rejected `server/discover` answer falls back to the legacy handshake like any other probe failure. An empty or `null` JSON body of a 200 is still handed over as `nil`, as before. The check lives in the transports, not in `MCP::Client#request`, so a custom transport keeps handing over whatever Hash it builds. The HTTP client tests stubbed most JSON bodies as `{ result: ... }` without the member, which the transport now rejects, so those 54 stubs and three whole-response assertions carry `jsonrpc: "2.0"`. Closes #589. ## How Has This Been Tested? test/mcp/client/stdio_test.rb and test/mcp/client/http_test.rb. Against the library before this change, 20 of the 22 new tests fail; the other two pin behavior that stays the same. ## Breaking Changes A server whose responses lack `"jsonrpc": "2.0"` no longer works with the bundled transports, and neither does a test that stubs such a response for them: the member has to be sent, and there is no opt-out. Servers built with an MCP SDK send it already, and `MCP::Client` over a custom transport or a test double is unaffected.
1 parent d4202c2 commit 9416795

5 files changed

Lines changed: 625 additions & 62 deletions

File tree

‎docs/_client/transports.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ nav_order: 2
1010
This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports,
1111
their sessions and server-to-client request handling, and the interface a custom transport must implement.
1212

13+
Both bundled transports check the `jsonrpc` member of the messages a server sends: the response to a pending request fails
14+
that request with `MCP::Client::RequestHandlerError` instead of being returned when the member is missing or is not exactly `"2.0"`,
15+
and a server-to-client request with such a member is ignored rather than answered.
16+
A custom transport does its own checking; `MCP::Client` uses the Hash it returns as is.
17+
1318
## Stdio Transport Layer
1419

1520
Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output.

‎lib/mcp/client/http.rb‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,10 @@ def feed(chunk)
202202

203203
if parsed.key?("result") || parsed.key?("error")
204204
@response ||= parsed
205-
elsif parsed["method"] && parsed.key?("id")
206-
# A server-to-client request (e.g. `elicitation/create`) delivered
207-
# on the stream while the original request is still pending.
205+
elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
206+
# A server-to-client request (e.g. `elicitation/create`) delivered on the stream while
207+
# the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered,
208+
# as the TypeScript client drops it at schema validation.
208209
@on_request&.call(parsed)
209210
end
210211
end
@@ -426,6 +427,10 @@ def send_request(request:)
426427

427428
body = resolve_response_body(stream, response, method, params)
428429

430+
# Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before
431+
# anything is learned from the body, and only for a request: a notification awaits no response.
432+
reject_invalid_response!(body, method, params) if request[:id] || request["id"]
433+
429434
capture_session_info(method, response, body) if response
430435
capture_mcp_param_declarations(method, params, body)
431436

@@ -1172,6 +1177,20 @@ def parse_json_buffer(buffer, method, params)
11721177
)
11731178
end
11741179

1180+
# A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200)
1181+
# is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`.
1182+
# The received value is left out of the message because it can be any JSON value.
1183+
def reject_invalid_response!(body, method, params)
1184+
return if body.nil?
1185+
return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"])
1186+
1187+
raise RequestHandlerError.new(
1188+
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
1189+
{ method: method, params: params },
1190+
error_type: :parse_error,
1191+
)
1192+
end
1193+
11751194
# SEP-1699 resumability: the server closed the SSE stream after a priming event
11761195
# without delivering the response. Treat the graceful close like a network failure:
11771196
# wait the `retry:` interval the server asked for (default 1000ms), then reconnect with

‎lib/mcp/client/stdio.rb‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -442,17 +442,22 @@ def read_response(request)
442442
# other server-to-client requests over stdio stay unsupported as documented,
443443
# and notifications carry no id.
444444
if parsed.is_a?(Hash) && parsed.key?("method")
445-
# A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
446-
# schema validation, so a ping carrying one is skipped rather than echoed back.
447-
answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"])
445+
# A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject
446+
# other shapes at schema validation, so a ping carrying one is skipped rather than echoed back.
447+
answer_ping(parsed) if answerable_ping?(parsed)
448448
next
449449
end
450450

451451
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
452452
# the same way as a frame without an id.
453453
next unless parsed.is_a?(Hash) && parsed.key?("id")
454+
next unless parsed["id"] == request_id
454455

455-
return parsed if parsed["id"] == request_id
456+
# Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it
457+
# instead of being skipped: without a `read_timeout`, skipping would wait forever.
458+
raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"])
459+
460+
return parsed
456461
end
457462
rescue JSON::ParserError => e
458463
raise RequestHandlerError.new(
@@ -475,6 +480,10 @@ def answer_ping(parsed)
475480
# surface as an error of the unrelated request whose response the loop is reading.
476481
end
477482

483+
def answerable_ping?(parsed)
484+
parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
485+
end
486+
478487
def json_rpc_id?(id)
479488
id.is_a?(String) || id.is_a?(Numeric)
480489
end
@@ -532,6 +541,16 @@ def raise_connection_error!(method, params)
532541
error_type: :internal_error,
533542
)
534543
end
544+
545+
# The line framing is intact, so the transport stays usable; the received value is left out of
546+
# the message because it can be any JSON value.
547+
def raise_invalid_response!(method, params)
548+
raise RequestHandlerError.new(
549+
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
550+
{ method: method, params: params },
551+
error_type: :internal_error,
552+
)
553+
end
535554
end
536555
end
537556
end

0 commit comments

Comments
 (0)