Answer server-to-client pings in the client

## Motivation and Context

The ping utility says the receiver "MUST respond promptly with an empty response",
and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor
that on the client side (the TypeScript protocol layer installs an automatic pong,
and the Python session answers `PingRequest` with an empty result), but this client answered
a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio,
leaving long-lived legacy sessions vulnerable to a liveness-checking server.

Over HTTP, an unhandled `ping` is now answered with the empty result in `dispatch_server_request`.
The default deliberately does not live in `@server_request_handlers`: that registry's `any?` opens
the GET listening stream on connect, and a built-in pong must not change listener behavior -
a server can only ping on a stream that is already open. A handler registered via
`on_server_request("ping")` still wins.

Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response.
Frames are told apart by shape rather than id: one carrying `method` is a request or notification,
never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered -
and a request frame can no longer be returned to the caller as if it were the response.
Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading,
which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay
unsupported as documented.

The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written
over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading,
and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright -
is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found,
exactly as before this change.

The modern lifecycle removes `ping` altogether (SEP-2575), so this applies to handshake-lifecycle connections only.

## How Has This Been Tested?

New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with
the ping's id, and a user-registered `"ping"` handler overrides the default; over stdio, a ping injected while
a response is awaited is answered on stdin and the original response still returns. Both fail without the change
(Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over
a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio
and answered with Method not found over HTTP. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None. A server ping that previously received Method not found (or nothing) now receives the empty result
the specification requires.
This commit is contained in:
Koichi ITO 2026-09-03 16:55:06 +09:00
commit 088f4b99e4

View file

@ -1303,6 +1303,131 @@ module MCP
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",