diff --git a/docs/_client/transports.md b/docs/_client/transports.md index f0c7ed84..7f34e202 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -10,6 +10,11 @@ nav_order: 2 This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports, their sessions and server-to-client request handling, and the interface a custom transport must implement. +Both bundled transports check the `jsonrpc` member of the messages a server sends: the response to a pending request fails +that request with `MCP::Client::RequestHandlerError` instead of being returned when the member is missing or is not exactly `"2.0"`, +and a server-to-client request with such a member is ignored rather than answered. +A custom transport does its own checking; `MCP::Client` uses the Hash it returns as is. + ## Stdio Transport Layer Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output. diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 3d3f23b8..0a45b8e6 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -202,9 +202,10 @@ def feed(chunk) if parsed.key?("result") || parsed.key?("error") @response ||= parsed - elsif parsed["method"] && parsed.key?("id") - # A server-to-client request (e.g. `elicitation/create`) delivered - # on the stream while the original request is still pending. + elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"]) + # A server-to-client request (e.g. `elicitation/create`) delivered on the stream while + # the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered, + # as the TypeScript client drops it at schema validation. @on_request&.call(parsed) end end @@ -426,6 +427,10 @@ def send_request(request:) body = resolve_response_body(stream, response, method, params) + # Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before + # anything is learned from the body, and only for a request: a notification awaits no response. + reject_invalid_response!(body, method, params) if request[:id] || request["id"] + capture_session_info(method, response, body) if response capture_mcp_param_declarations(method, params, body) @@ -1172,6 +1177,20 @@ def parse_json_buffer(buffer, method, params) ) end + # A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200) + # is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`. + # The received value is left out of the message because it can be any JSON value. + def reject_invalid_response!(body, method, params) + return if body.nil? + return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"]) + + raise RequestHandlerError.new( + 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"', + { method: method, params: params }, + error_type: :parse_error, + ) + end + # SEP-1699 resumability: the server closed the SSE stream after a priming event # without delivering the response. Treat the graceful close like a network failure: # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index 0fbb96ec..ba4558f8 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -442,17 +442,22 @@ def read_response(request) # other server-to-client requests over stdio stay unsupported as documented, # and notifications carry no id. if parsed.is_a?(Hash) && parsed.key?("method") - # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at - # schema validation, so a ping carrying one is skipped rather than echoed back. - answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) + # A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject + # other shapes at schema validation, so a ping carrying one is skipped rather than echoed back. + answer_ping(parsed) if answerable_ping?(parsed) next end # A JSON-RPC message is an object; skip a non-object frame (array or scalar) # the same way as a frame without an id. next unless parsed.is_a?(Hash) && parsed.key?("id") + next unless parsed["id"] == request_id - return parsed if parsed["id"] == request_id + # Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it + # instead of being skipped: without a `read_timeout`, skipping would wait forever. + raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"]) + + return parsed end rescue JSON::ParserError => e raise RequestHandlerError.new( @@ -475,6 +480,10 @@ def answer_ping(parsed) # surface as an error of the unrelated request whose response the loop is reading. end + def answerable_ping?(parsed) + parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"]) + end + def json_rpc_id?(id) id.is_a?(String) || id.is_a?(Numeric) end @@ -532,6 +541,16 @@ def raise_connection_error!(method, params) error_type: :internal_error, ) end + + # The line framing is intact, so the transport stays usable; the received value is left out of + # the message because it can be any JSON value. + def raise_invalid_response!(method, params) + raise RequestHandlerError.new( + 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"', + { method: method, params: params }, + error_type: :internal_error, + ) + end end end end diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 0b9960a8..57828e65 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -70,7 +70,7 @@ def test_headers_are_added_to_the_request .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) # The test passes if the request is made with the correct headers @@ -94,7 +94,7 @@ def test_accept_header_is_included_in_requests .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) client.send_request(request: request) @@ -119,7 +119,7 @@ def test_custom_accept_header_overrides_default .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) custom_client.send_request(request: request) @@ -138,7 +138,7 @@ def test_mcp_method_and_name_headers_for_tools_call ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -157,7 +157,7 @@ def test_mcp_name_header_falls_back_to_uri_for_resources_read ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -176,7 +176,7 @@ def test_mcp_method_and_name_headers_for_prompts_get ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -196,7 +196,7 @@ def test_mcp_method_header_without_name_when_params_lack_name_and_uri end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) client.send_request(request: request) @@ -215,7 +215,7 @@ def test_mcp_name_header_is_base64_encoded_when_unsafe ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -254,7 +254,7 @@ def test_mcp_method_header_for_initialize_without_params end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -273,7 +273,7 @@ def test_mcp_name_header_with_string_keyed_params ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -292,7 +292,7 @@ def test_mcp_name_header_is_base64_encoded_when_value_has_surrounding_whitespace ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -313,7 +313,7 @@ def test_mcp_name_header_is_base64_encoded_when_value_has_crlf end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -332,7 +332,7 @@ def test_mcp_name_header_re_encodes_value_matching_base64_sentinel ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -351,7 +351,7 @@ def test_mcp_method_header_without_name_for_non_hash_params end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: {} }.to_json, + body: { jsonrpc: "2.0", result: {} }.to_json, ) client.send_request(request: request) @@ -369,12 +369,12 @@ def test_send_request_returns_faraday_response .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) response = client.send_request(request: request) assert_instance_of(Hash, response) - assert_equal({ "result" => { "tools" => [] } }, response) + assert_equal({ "jsonrpc" => "2.0", "result" => { "tools" => [] } }, response) end def test_send_request_raises_bad_request_error @@ -466,7 +466,7 @@ def test_send_request_raises_session_expired_error_on_404_with_session "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -488,7 +488,7 @@ def test_session_expired_error_is_a_request_handler_error "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -561,7 +561,7 @@ def test_block_customizes_faraday_connection ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) custom_client.send_request(request: request) @@ -930,14 +930,14 @@ def test_send_request_parses_json_response_when_adapter_does_not_stream # streaming support; the body must be read from `response.body`. stubs = Faraday::Adapter::Test::Stubs.new do |stub| stub.post("/") do - [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: { tools: [] } }.to_json] end end client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } response = client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) - assert_equal({ "result" => { "tools" => [] } }, response) + assert_equal({ "jsonrpc" => "2.0", "result" => { "tools" => [] } }, response) end def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_only @@ -946,7 +946,7 @@ def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_on # the json 3.0 signature, so the body must reach `JSON.parse` through the client's own call. stubs = Faraday::Adapter::Test::Stubs.new do |stub| stub.post("/") do - [200, { "Content-Type" => "application/json" }, { result: { tools: [] } }.to_json] + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: { tools: [] } }.to_json] end end client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } @@ -957,7 +957,7 @@ def test_send_request_parses_a_json_body_with_a_parser_taking_keyword_options_on client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) end - assert_equal({ "result" => { "tools" => [] } }, response) + assert_equal({ "jsonrpc" => "2.0", "result" => { "tools" => [] } }, response) end def test_send_request_mirrors_x_mcp_header_params_into_mcp_param_headers @@ -1132,7 +1132,7 @@ def test_send_request_rejects_json_body_exceeding_max_message_bytes ).to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: ["a" * 128] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: ["a" * 128] } }.to_json, ) error = assert_raises(RequestHandlerError) do @@ -1737,7 +1737,7 @@ def test_captures_session_id_and_protocol_version_on_initialize "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1754,7 +1754,7 @@ def test_includes_session_and_protocol_version_headers_after_initialize "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1769,7 +1769,7 @@ def test_includes_session_and_protocol_version_headers_after_initialize .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) @@ -1795,7 +1795,7 @@ def test_adopts_a_counter_offered_protocol_version_from_the_handshake end.to_return( status: 200, headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, - body: { result: { protocolVersion: counter_offered } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: counter_offered } }.to_json, ) stub_notification @@ -1813,7 +1813,7 @@ def test_adopts_a_counter_offered_protocol_version_from_the_handshake end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) @@ -1827,7 +1827,7 @@ def test_does_not_send_protocol_version_header_before_initialize .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1841,7 +1841,7 @@ def test_ignores_empty_session_id_header "Content-Type" => "application/json", "Mcp-Session-Id" => "", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1857,7 +1857,7 @@ def test_session_id_not_overwritten_by_subsequent_responses "Content-Type" => "application/json", "Mcp-Session-Id" => "original-session", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1871,7 +1871,7 @@ def test_session_id_not_overwritten_by_subsequent_responses "Content-Type" => "application/json", "Mcp-Session-Id" => "different-session", }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) @@ -1884,7 +1884,7 @@ def test_stateless_server_without_session_id_header .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1901,7 +1901,7 @@ def test_clears_session_state_on_404 "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -1956,7 +1956,7 @@ def test_close_clears_stateless_connection_state .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) stub_notification @@ -2038,6 +2038,7 @@ def test_connect_performs_initialize_handshake status: 200, headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s1" }, body: { + jsonrpc: "2.0", result: { protocolVersion: "2025-11-25", capabilities: { tools: {} }, @@ -2082,7 +2083,7 @@ def test_connect_uses_default_client_info_and_protocol_version .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: MCP::Configuration::LATEST_HANDSHAKE_PROTOCOL_VERSION } }.to_json, ) client.connect @@ -2105,7 +2106,7 @@ def test_connect_accepts_custom_parameters .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-03-26" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-03-26" } }.to_json, ) client.connect( @@ -2129,7 +2130,7 @@ def test_connect_offers_the_latest_handshake_version_by_default end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) stub_notification @@ -2148,7 +2149,7 @@ def test_connect_rejects_a_modern_protocol_version_in_the_initialize_result .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2026-07-28" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2026-07-28" } }.to_json, ) error = assert_raises(RequestHandlerError) do @@ -2175,7 +2176,7 @@ def test_connect_does_not_warn_for_deprecated_capabilities_when_negotiated_proto .to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) assert_no_deprecation_warning do @@ -2202,7 +2203,7 @@ def test_connect_raises_on_jsonrpc_error_response stub_request(:post, url).to_return( status: 200, headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, - body: { error: { code: -32602, message: "Unsupported protocol version" } }.to_json, + body: { jsonrpc: "2.0", error: { code: -32602, message: "Unsupported protocol version" } }.to_json, ) error = assert_raises(RequestHandlerError) do @@ -2240,7 +2241,7 @@ def test_connect_raises_on_unsupported_negotiated_protocol_version .to_return( status: 200, headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, - body: { result: { protocolVersion: "2099-01-01" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2099-01-01" } }.to_json, ) error = assert_raises(RequestHandlerError) do @@ -2319,7 +2320,7 @@ def test_modern_requests_carry_the_envelope_and_matching_header end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + body: { jsonrpc: "2.0", result: { tools: [] } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" }) @@ -2333,7 +2334,7 @@ def test_connect_modern_fails_without_a_mutual_modern_version end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + body: { jsonrpc: "2.0", result: { supportedVersions: ["2025-11-25"] } }.to_json, ) error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) } @@ -2349,7 +2350,7 @@ def test_connect_auto_falls_back_to_the_legacy_handshake_on_discovery_errors end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { error: { code: -32601, message: "Method not found" } }.to_json, + body: { jsonrpc: "2.0", error: { code: -32601, message: "Method not found" } }.to_json, ) init_stub = stub_initialize notification_stub = stub_notification @@ -2371,7 +2372,7 @@ def test_connect_auto_falls_back_when_discovery_lacks_a_mutual_modern_version end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + body: { jsonrpc: "2.0", result: { supportedVersions: ["2025-11-25"] } }.to_json, ) init_stub = stub_initialize stub_notification @@ -2396,7 +2397,7 @@ def test_connect_auto_propagates_the_discovery_failure_for_an_explicitly_modern_ end.to_return( status: 200, headers: { "Content-Type" => "application/json" }, - body: { result: { supportedVersions: ["2025-11-25"] } }.to_json, + body: { jsonrpc: "2.0", result: { supportedVersions: ["2025-11-25"] } }.to_json, ) error = assert_raises(RequestHandlerError) do @@ -2431,7 +2432,7 @@ def test_reconnect_after_close .to_return( status: 200, headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "s2" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.connect @@ -2452,7 +2453,7 @@ def test_close_allows_reinitializing_a_fresh_session "Content-Type" => "application/json", "Mcp-Session-Id" => "session-xyz", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" }) @@ -2492,6 +2493,304 @@ def test_send_notification_surfaces_faraday_errors assert_match(%r{notifications/cancelled}, error.message) end + def test_send_request_raises_error_for_a_json_response_that_is_not_json_rpc_2_0 + bodies = [ + { result: { tools: [] } }, + { jsonrpc: "1.0", result: { tools: [] } }, + { jsonrpc: 2.0, result: { tools: [] } }, + { error: { code: -32600, message: "Invalid request" } }, + [{ jsonrpc: "2.0", result: { tools: [] } }], + "tools", + ] + + bodies.each do |body| + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: body.to_json, + ) + + error = assert_raises(RequestHandlerError, body.inspect) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + assert_equal('Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"', error.message) + assert_equal(:parse_error, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + end + + def test_send_request_raises_error_for_a_json_response_that_is_not_json_rpc_2_0_when_adapter_does_not_stream + # The Faraday test adapter ignores `on_data`. A String body is parsed by the client; + # a Hash body stands for one that a JSON middleware of the customizer parsed already. + [{ result: { tools: [] } }.to_json, { "result" => { "tools" => [] } }].each do |body| + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") { [200, { "Content-Type" => "application/json" }, body] } + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + + error = assert_raises(RequestHandlerError, body.inspect) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + assert_equal(:parse_error, error.error_type) + end + end + + def test_send_request_does_not_check_the_body_answering_a_notification + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: "{}", + ) + + response = client.send_request(request: { jsonrpc: "2.0", method: "notifications/initialized" }) + + assert_empty(response) + end + + def test_send_request_raises_error_for_an_sse_response_that_is_not_json_rpc_2_0 + # The first response-shaped event settles the exchange, so the valid one after it changes nothing. + sse_body = <<~SSE + data: {"id":"test_id","result":{"tools":[]}} + + data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[]}} + + SSE + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + get_stub = stub_request(:get, url) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + assert_equal('Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"', error.message) + assert_equal(:parse_error, error.error_type) + assert_not_requested(get_stub) + end + + def test_send_request_raises_error_for_an_sse_response_that_is_not_json_rpc_2_0_when_adapter_does_not_stream + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do + [200, { "Content-Type" => "text/event-stream" }, %(data: {"id":"test_id","result":{"tools":[]}}\n\n)] + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + + error = assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + # The message tells this rejection apart from a stream that held no response, which is a `:parse_error` too. + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + assert_equal(:parse_error, error.error_type) + end + + def test_send_request_raises_error_for_a_response_that_is_not_json_rpc_2_0_on_the_reconnected_stream + stub_request(:post, url).with( + body: reconnection_request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 100\ndata:\n\n", + ) + get_stub = stub_request(:get, url).with( + headers: { "Last-Event-ID" => "event-1" }, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: %(id: event-2\ndata: {"id":"test_id","result":{"content":[]}}\n\n), + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: reconnection_request) + end + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + + # The rejection ends the exchange; the client does not reconnect again for another answer. + assert_requested(get_stub, times: 1) + end + + def test_send_request_does_not_dispatch_a_server_request_that_is_not_json_rpc_2_0 + request = { jsonrpc: "2.0", id: "test_id", method: "tools/call", params: { name: "ask", arguments: {} } } + server_request = { id: 0, method: "elicitation/create", params: { message: "?" } } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + post_stub = stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "event: message\ndata: #{server_request.to_json}\n\nevent: message\ndata: #{tool_result.to_json}\n\n", + ) + handled = false + client.on_server_request("elicitation/create") { handled = true } + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + refute(handled) + + # Neither a handler result nor an error was POSTed back. + assert_requested(post_stub, times: 1) + end + + def test_listener_skips_a_server_request_that_is_not_json_rpc_2_0 + stub_initialize + stub_notification + stub_request(:delete, url).to_return(status: 200) + + skipped = { id: 6, method: "elicitation/create", params: { message: "skipped" } } + answered = { jsonrpc: "2.0", id: 7, method: "elicitation/create", params: { message: "answered" } } + stub_request(:get, url).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "event: message\ndata: #{skipped.to_json}\n\nevent: message\ndata: #{answered.to_json}\n\n", + ) + skipped_stub = stub_request(:post, url) + .with { |req| JSON.parse(req.body)["id"] == 6 } + .to_return(status: 202, body: "") + answered_stub = stub_request(:post, url).with( + body: { jsonrpc: "2.0", id: 7, result: { action: "accept", content: { message: "answered" } } }.to_json, + ).to_return( + status: 202, body: "", + ) + + client.connect + client.on_server_request("elicitation/create") do |params| + { action: "accept", content: { message: params["message"] } } + end + + # The events arrive in order, so the first one has been passed over by the time the second is answered. + wait_until { requested?(answered_stub) } + + refute(requested?(skipped_stub)) + ensure + client.close + end + + def test_connect_raises_on_an_initialize_response_that_is_not_json_rpc_2_0 + answers = { + "application/json" => { result: { protocolVersion: "2025-11-25" } }.to_json, + # The `initialize` stream is read to its end instead of being aborted at the response. + "text/event-stream" => %(data: {"id":"1","result":{"protocolVersion":"2025-11-25"}}\n\n), + } + answers.each do |content_type, body| + stub_request(:post, url).with { |req| + JSON.parse(req.body)["method"] == "initialize" + }.to_return( + status: 200, + headers: { "Content-Type" => content_type, "Mcp-Session-Id" => "session-abc" }, + body: body, + ) + client = HTTP.new(url: url) + + error = assert_raises(RequestHandlerError, content_type) { client.connect } + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + refute_predicate(client, :connected?) + + # Nothing is learned from a rejected response. + assert_nil(client.session_id) + assert_nil(client.protocol_version) + assert_nil(client.server_info) + end + end + + def test_connect_modern_raises_on_a_discover_response_that_is_not_json_rpc_2_0 + stub_discover_that_is_not_json_rpc_2_0 + + error = assert_raises(RequestHandlerError) { client.connect(mode: :modern) } + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + refute_predicate(client, :modern?) + refute_predicate(client, :connected?) + end + + def test_connect_auto_falls_back_when_the_discover_response_is_not_json_rpc_2_0 + stub_discover_that_is_not_json_rpc_2_0 + stub_initialize + stub_notification + + result = client.connect(mode: :auto) + + assert_equal("2025-11-25", result["protocolVersion"]) + refute_predicate(client, :modern?) + assert_predicate(client, :connected?) + end + + def test_send_request_raises_error_for_a_response_that_is_not_json_rpc_2_0_on_a_modern_connection + stub_discover + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "tools/list" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + client.connect(mode: :modern) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + end + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + assert_predicate(client, :modern?) + end + + def test_send_request_learns_no_x_mcp_header_declarations_from_a_rejected_tools_list + call_headers = nil + stubs = Faraday::Adapter::Test::Stubs.new do |stub| + stub.post("/") do |env| + case JSON.parse(env.request_body)["method"] + when "server/discover" + discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" } + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: discover }.to_json] + when "tools/list" + [200, { "Content-Type" => "application/json" }, { result: { tools: [mcp_param_annotated_tool] } }.to_json] + else + call_headers = env.request_headers + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: { content: [] } }.to_json] + end + end + end + client = HTTP.new(url: url) { |faraday| faraday.adapter(:test, stubs) } + client.connect(mode: :modern) + + assert_raises(RequestHandlerError) do + client.send_request(request: { jsonrpc: "2.0", id: 1, method: "tools/list" }) + end + client.send_request(request: { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "test_custom_headers", arguments: { region: "us-west1" } }, + }) + + refute(call_headers.key?("Mcp-Param-Region"), "a rejected listing must teach no header declarations") + end + + def test_call_tool_raises_when_the_response_is_not_json_rpc_2_0 + stub_initialize + stub_notification + stub_request(:post, url).with { |req| + JSON.parse(req.body)["method"] == "tools/call" + }.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { content: [{ type: "text", text: "ok" }] } }.to_json, + ) + mcp_client = Client.new(transport: client) + mcp_client.connect(mode: :legacy) + + error = assert_raises(RequestHandlerError) { mcp_client.call_tool(name: "echo", arguments: {}) } + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + end + private def initialize_session @@ -2502,7 +2801,7 @@ def initialize_session "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc", }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) @@ -2514,7 +2813,7 @@ def stub_initialize .to_return( status: 200, headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, - body: { result: { protocolVersion: "2025-11-25" } }.to_json, + body: { jsonrpc: "2.0", result: { protocolVersion: "2025-11-25" } }.to_json, ) end @@ -2531,6 +2830,7 @@ def stub_discover status: 200, headers: { "Content-Type" => "application/json" }, body: { + jsonrpc: "2.0", result: { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, @@ -2542,6 +2842,16 @@ def stub_discover ) end + def stub_discover_that_is_not_json_rpc_2_0 + stub_request(:post, url).with do |req| + JSON.parse(req.body)["method"] == "server/discover" + end.to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { supportedVersions: ["2026-07-28"], capabilities: {}, ttlMs: 0, cacheScope: "private" } }.to_json, + ) + end + def stub_request(method, url) WebMock.stub_request(method, url) end @@ -2570,13 +2880,13 @@ def mcp_param_test_client(tools:, on_call:) case JSON.parse(env.request_body)["method"] when "server/discover" discover = { supportedVersions: ["2026-07-28"], capabilities: { tools: {} }, ttlMs: 0, cacheScope: "private" } - [200, { "Content-Type" => "application/json" }, { result: discover }.to_json] + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: discover }.to_json] when "tools/list" listing = tools.respond_to?(:call) ? tools.call : tools - [200, { "Content-Type" => "application/json" }, { result: { tools: listing } }.to_json] + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: { tools: listing } }.to_json] else on_call.call(env.request_headers) - [200, { "Content-Type" => "application/json" }, { result: { content: [] } }.to_json] + [200, { "Content-Type" => "application/json" }, { jsonrpc: "2.0", result: { content: [] } }.to_json] end end end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 08e8d171..fd5d3823 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -709,6 +709,170 @@ def test_send_request_raises_error_on_invalid_json stdout_write.close end + def test_send_request_raises_error_on_a_response_that_is_not_json_rpc_2_0 + answers = [ + { result: { tools: [] } }, + { jsonrpc: "1.0", result: { tools: [] } }, + { jsonrpc: 2.0, result: { tools: [] } }, + { jsonrpc: nil, result: { tools: [] } }, + { error: { code: -32600, message: "Invalid request" } }, + ] + answers.each do |answer| + server = lambda do |requests, frames| + answer_handshake(requests, frames) + first = JSON.parse(requests.gets) + write_frame(frames, answer.merge(id: first["id"])) + second = JSON.parse(requests.gets) + write_frame(frames, { jsonrpc: "2.0", id: second["id"], result: { tools: [] } }) + end + + with_scripted_server(server) do |transport| + transport.connect + error = assert_raises(RequestHandlerError, answer.inspect) do + transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) + end + + assert_equal('Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"', error.message) + assert_equal(:internal_error, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + + # The line framing is intact, so the same connection answers the next request. + response = transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" }) + + assert_equal("second", response["id"]) + end + end + end + + def test_send_request_skips_frames_that_are_not_json_rpc_2_0_unless_they_answer_the_awaited_request + server = lambda do |requests, frames| + answer_handshake(requests, frames) + request = JSON.parse(requests.gets) + + # The stale answer to another request and a notification, neither of them JSON-RPC 2.0. + write_frame(frames, { id: "stale", result: {} }) + write_frame(frames, { jsonrpc: "1.0", method: "notifications/progress", params: {} }) + write_frame(frames, { jsonrpc: "2.0", id: request["id"], result: { tools: [] } }) + end + + with_scripted_server(server) do |transport| + transport.connect + response = transport.send_request(request: { jsonrpc: "2.0", id: "test-id", method: "tools/list" }) + + assert_equal("test-id", response["id"]) + end + end + + def test_a_ping_that_is_not_json_rpc_2_0_is_not_answered + written = [] + server = lambda do |requests, frames| + answer_handshake(requests, frames) + first = JSON.parse(requests.gets) + write_frame(frames, { id: "srv-ping-1", method: "ping" }) + write_frame(frames, { jsonrpc: "2.0", id: "srv-ping-2", method: "ping" }) + write_frame(frames, { jsonrpc: "2.0", id: first["id"], result: { tools: [] } }) + + # Everything the client writes up to its next request, so that no pong can go unseen. + loop do + written << JSON.parse(requests.gets) + break if written.last["method"] + end + + write_frame(frames, { jsonrpc: "2.0", id: written.last["id"], result: { tools: [] } }) + end + + with_scripted_server(server) do |transport| + transport.connect + transport.send_request(request: { jsonrpc: "2.0", id: "first", method: "tools/list" }) + transport.send_request(request: { jsonrpc: "2.0", id: "second", method: "tools/list" }) + end + + pongs = written.reject { |frame| frame["method"] } + + assert_equal(["srv-ping-2"], pongs.map { |frame| frame["id"] }) + end + + def test_connect_raises_on_an_initialize_response_that_is_not_json_rpc_2_0 + server = lambda do |requests, frames| + init_request = JSON.parse(requests.gets) + write_frame(frames, { id: init_request["id"], result: handshake_result }) + end + + with_scripted_server(server) do |transport| + error = assert_raises(RequestHandlerError) { transport.connect } + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + refute_predicate(transport, :connected?) + assert_nil(transport.server_info) + end + end + + def test_connect_modern_raises_on_a_discover_response_that_is_not_json_rpc_2_0 + server = lambda do |requests, frames| + discover_request = JSON.parse(requests.gets) + write_frame(frames, { id: discover_request["id"], result: discover_result }) + end + + with_scripted_server(server) do |transport| + error = assert_raises(RequestHandlerError) { transport.connect(mode: :modern) } + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + refute_predicate(transport, :modern?) + refute_predicate(transport, :connected?) + end + end + + def test_connect_auto_falls_back_when_the_discover_response_is_not_json_rpc_2_0 + server = lambda do |requests, frames| + discover_request = JSON.parse(requests.gets) + write_frame(frames, { id: discover_request["id"], result: discover_result }) + answer_handshake(requests, frames) + end + + with_scripted_server(server) do |transport| + result = transport.connect(mode: :auto) + + assert_equal("2025-11-25", result["protocolVersion"]) + refute_predicate(transport, :modern?) + assert_predicate(transport, :connected?) + end + end + + def test_send_request_raises_error_on_a_response_that_is_not_json_rpc_2_0_on_a_modern_connection + server = lambda do |requests, frames| + discover_request = JSON.parse(requests.gets) + write_frame(frames, { jsonrpc: "2.0", id: discover_request["id"], result: discover_result }) + request = JSON.parse(requests.gets) + write_frame(frames, { id: request["id"], result: { tools: [] } }) + end + + with_scripted_server(server) do |transport| + transport.connect(mode: :modern) + error = assert_raises(RequestHandlerError) do + transport.send_request(request: { jsonrpc: "2.0", id: "t1", method: "tools/list" }) + end + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + assert_predicate(transport, :modern?) + end + end + + def test_call_tool_raises_when_the_response_is_not_json_rpc_2_0 + server = lambda do |requests, frames| + answer_handshake(requests, frames) + request = JSON.parse(requests.gets) + write_frame(frames, { id: request["id"], result: { content: [{ type: "text", text: "ok" }] } }) + end + + with_scripted_server(server) do |transport| + client = Client.new(transport: transport) + client.connect(mode: :legacy) + error = assert_raises(RequestHandlerError) { client.call_tool(name: "echo", arguments: {}) } + + assert_includes(error.message, "not a valid JSON-RPC 2.0 message") + end + end + def test_close_kills_process_on_timeout stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe @@ -1952,6 +2116,52 @@ def mock_wait_thread thread.stubs(:value).returns(nil) thread end + + # Yields a transport whose server side is `server`, a callable given the pipe the server reads + # client frames from and the pipe it writes its own frames to. The `read_timeout` turns a regression + # that waits for a frame into a failure instead of a hang. + def with_scripted_server(server, read_timeout: 2) + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, stderr_write = IO.pipe + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + server_thread = Thread.new { server.call(stdin_read, stdout_write) } + yield Stdio.new(command: "ruby", args: ["server.rb"], read_timeout: read_timeout) + ensure + server_thread.kill + server_thread.join + [stdin_read, stdin_write, stdout_read, stdout_write, stderr_read, stderr_write].compact.each do |io| + io.close unless io.closed? + end + end + + def answer_handshake(requests, frames) + init_request = JSON.parse(requests.gets) + write_frame(frames, { jsonrpc: "2.0", id: init_request["id"], result: handshake_result }) + + # `notifications/initialized` + requests.gets + end + + def write_frame(frames, frame) + frames.puts(JSON.generate(frame)) + frames.flush + end + + def handshake_result + { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "test-server", version: "1.0.0" } } + end + + def discover_result + { + supportedVersions: ["2026-07-28"], + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + ttlMs: 0, + cacheScope: "private", + } + end end end end