Merge pull request #541 from koic/respond_to_server_pings_in_the_client

Answer server-to-client pings in the client
This commit is contained in:
Koichi ITO 2026-09-05 02:23:10 +09:00 • committed by GitHub
commit 6d272df0e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 421 additions and 0 deletions

View file

@ -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) Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
propagate as exceptions raised by the transport layer. 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 ## Server Side
How servers answer `ping` requests and ping the client themselves is documented on How servers answer `ping` requests and ping the client themselves is documented on

View file

@ -233,6 +233,12 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
end 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 ## 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: 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:

View file

@ -1118,6 +1118,19 @@ module MCP
}, },
} }
end 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 else
{ {
jsonrpc: JsonRpcHandler::Version::V2_0, jsonrpc: JsonRpcHandler::Version::V2_0,

View file

@ -436,6 +436,18 @@ module MCP
parsed = JSON.parse(line.strip) 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) # A JSON-RPC message is an object; skip a non-object frame (array or scalar)
# the same way as a frame without an id. # the same way as a frame without an id.
next unless parsed.is_a?(Hash) && parsed.key?("id") next unless parsed.is_a?(Hash) && parsed.key?("id")
@ -451,6 +463,22 @@ module MCP
) )
end 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! def ensure_running!
return if @wait_thread.alive? return if @wait_thread.alive?

View file

@ -1303,6 +1303,131 @@ module MCP
assert_equal(100, received_params["maxTokens"]) assert_equal(100, received_params["maxTokens"])
end 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 def test_send_request_answers_unregistered_server_request_with_method_not_found
request = { request = {
jsonrpc: "2.0", jsonrpc: "2.0",

View file

@ -85,6 +85,245 @@ module MCP
stderr_read.close stderr_read.close
end 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 def test_send_request_skips_notifications
stdin_read, stdin_write = IO.pipe stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe stdout_read, stdout_write = IO.pipe