diff --git a/docs/_client/ping.md b/docs/_client/ping.md index e01e21bb..f6889e9d 100644 --- a/docs/_client/ping.md +++ b/docs/_client/ping.md @@ -29,6 +29,16 @@ is not a Hash (matching the spec requirement that `result` be an object). Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing) propagate as exceptions raised by the transport layer. +## Answering Server Pings + +On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with +the empty result - no handler is needed. Registering `transport.on_server_request("ping")` on `MCP::Client::HTTP` replaces +the automatic answer. +Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when +the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is +dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:` +on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits. + ## Server Side How servers answer `ping` requests and ping the client themselves is documented on diff --git a/docs/_client/transports.md b/docs/_client/transports.md index 1e1aaac0..a8a47643 100644 --- a/docs/_client/transports.md +++ b/docs/_client/transports.md @@ -233,6 +233,12 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f end ``` +{: .note } +> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside +> the SSE streaming callback of another response, re-entering the connection on the same thread. +> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter +> with persistent connections must tolerate that re-entry. + ## Custom Transports If the transport layer you need is not included in the gem, you can build and pass your own instances so long as they conform to the following interface: diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index e189c74f..1c3fd867 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -1118,6 +1118,19 @@ def dispatch_server_request(message) }, } end + elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric)) + # The ping receiver "MUST respond promptly with an empty response" (MCP ping utility), + # so an unhandled ping is answered with the empty result rather than Method not found. + # A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at + # schema validation, so a ping carrying one falls through to Method not found instead. + # The default lives here instead of in `@server_request_handlers`, whose `any?` opens + # the GET listening stream on connect: a built-in pong must not change listener behavior. + # A handler registered via `on_server_request("ping")` still wins through the branch above. + { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: message["id"], + result: {}, + } else { jsonrpc: JsonRpcHandler::Version::V2_0, diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index fee1a3d9..0fbb96ec 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -436,6 +436,18 @@ def read_response(request) parsed = JSON.parse(line.strip) + # A frame carrying `method` is a request or notification, never the awaited response, + # whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id + # may legitimately collide with the awaited one. Pings are answered; + # 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"]) + 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") @@ -451,6 +463,22 @@ def read_response(request) ) end + # The ping receiver "MUST respond promptly with an empty response". Answered inline from + # the read loop, so a keepalive cannot stall unanswered while a response is awaited; + # between requests nothing reads stdout, and a queued ping is answered on the next read. + def answer_ping(parsed) + @write_mutex.synchronize do + write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} }) + end + rescue RequestHandlerError + # Best effort: a pong cannot be delivered over a broken stdin, and the failure must not + # surface as an error of the unrelated request whose response the loop is reading. + end + + def json_rpc_id?(id) + id.is_a?(String) || id.is_a?(Numeric) + end + def ensure_running! return if @wait_thread.alive? diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 87cdb551..077db316 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1303,6 +1303,131 @@ def test_send_request_dispatches_sampling_request_to_registered_handler assert_equal(100, received_params["maxTokens"]) end + def test_send_request_answers_a_server_ping_with_an_empty_result + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_ping = { jsonrpc: "2.0", id: 7, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + pong = { jsonrpc: "2.0", id: 7, result: {} } + pong_stub = stub_request(:post, url).with( + body: pong.to_json, + ).to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(pong_stub) + end + + def test_a_registered_ping_handler_overrides_the_default_pong + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + server_ping = { jsonrpc: "2.0", id: 8, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_response = { jsonrpc: "2.0", id: 8, result: { custom: "pong" } } + response_stub = stub_request(:post, url).with( + body: expected_response.to_json, + ).to_return(status: 202, body: "") + + client.on_server_request("ping") { { custom: "pong" } } + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(response_stub) + end + + def test_a_ping_with_a_malformed_id_is_answered_with_method_not_found + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/call", + params: { name: "some_tool", arguments: {} }, + } + + # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames at schema + # validation, so the default pong does not fire and the frame falls through. + server_ping = { jsonrpc: "2.0", id: { x: 1 }, method: "ping" } + tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } } + sse_body = <<~BODY + event: message + data: #{server_ping.to_json} + + event: message + data: #{tool_result.to_json} + + BODY + + stub_request(:post, url).with( + body: request.to_json, + ).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + expected_error = { + jsonrpc: "2.0", + id: { x: 1 }, + error: { + code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, + message: "Method not found: ping", + }, + } + error_stub = stub_request(:post, url).with( + body: expected_error.to_json, + ).to_return(status: 202, body: "") + + response = client.send_request(request: request) + + assert_equal({ "content" => [] }, response["result"]) + assert_requested(error_stub) + end + def test_send_request_answers_unregistered_server_request_with_method_not_found request = { jsonrpc: "2.0", diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index 3b160402..08e8d171 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -85,6 +85,245 @@ def test_send_request_starts_process_and_returns_response stderr_read.close end + def test_send_request_answers_a_server_ping_inline + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # Ping the client while it awaits the response; the receiver "MUST respond promptly". + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) + stdout_write.flush + + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + pong = JSON.parse(pong_line) + assert_equal("srv-ping-1", pong["id"]) + assert_equal({}, pong["result"]) + refute(pong.key?("error")) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_server_ping_reusing_the_awaited_id_is_answered_and_not_returned_as_the_response + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # JSON-RPC id spaces are per sender, so a server ping may reuse the id the client + # is awaiting; it must still be answered rather than treated as the response. + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", method: "ping" })) + stdout_write.flush + + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + pong = JSON.parse(pong_line) + assert_equal("test-id", pong["id"]) + assert_equal({}, pong["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_pong_that_cannot_be_written_does_not_fail_the_awaited_request + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # Break the write path: the pong cannot be delivered, and that must not fail the request. + stdin_read.close + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-1", method: "ping" })) + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + + assert_equal("test-id", response["id"]) + assert_empty(response.dig("result", "tools")) + ensure + server_thread.join + stdin_read.close unless stdin_read.closed? + stdin_write.close + stdout_read.close + stdout_write.close + end + + def test_a_ping_with_a_malformed_id_is_not_answered + stdin_read, stdin_write = IO.pipe + stdout_read, stdout_write = IO.pipe + stderr_read, _ = IO.pipe + + Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread]) + + transport = Stdio.new(command: "ruby", args: ["server.rb"]) + + request = { + jsonrpc: "2.0", + id: "test-id", + method: "tools/list", + } + + pong_line = nil + server_thread = Thread.new do + init_line = stdin_read.gets + init_request = JSON.parse(init_line) + stdout_write.puts(JSON.generate({ + jsonrpc: "2.0", + id: init_request["id"], + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0.0" }, + }, + })) + stdout_write.flush + + # Read initialized notification, then the tools/list request. + stdin_read.gets + stdin_read.gets + + # A Hash id is not a JSON-RPC id; the reference SDKs reject such frames outright. + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: { "x" => 1 }, method: "ping" })) + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "srv-ping-2", method: "ping" })) + stdout_write.flush + + # The single pong that arrives must answer the well-formed ping, not the malformed one. + pong_line = stdin_read.gets + + stdout_write.puts(JSON.generate({ jsonrpc: "2.0", id: "test-id", result: { tools: [] } })) + stdout_write.flush + end + + transport.connect + response = transport.send_request(request: request) + server_thread.join + + assert_equal("test-id", response["id"]) + pong = JSON.parse(pong_line) + assert_equal("srv-ping-2", pong["id"]) + assert_equal({}, pong["result"]) + ensure + server_thread.join + stdin_read.close + stdin_write.close + stdout_read.close + stdout_write.close + end + def test_send_request_skips_notifications stdin_read, stdin_write = IO.pipe stdout_read, stdout_write = IO.pipe