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:
parent
6b1704f330
commit
088f4b99e4
6 changed files with 421 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,245 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue