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
2 changed files with 421 additions and 0 deletions

View file

@ -1118,6 +1118,19 @@ module MCP
},
}
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,

View file

@ -436,6 +436,18 @@ module MCP
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 @@ module MCP
)
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?