Skip to content

Commit 088f4b9

Browse files
committed
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.
1 parent 6b1704f commit 088f4b9

6 files changed

Lines changed: 421 additions & 0 deletions

File tree

‎docs/_client/ping.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ is not a Hash (matching the spec requirement that `result` be an object).
2929
Transport-level errors (for example, `MCP::Client::Stdio`'s `read_timeout:` firing)
3030
propagate as exceptions raised by the transport layer.
3131

32+
## Answering Server Pings
33+
34+
On handshake-lifecycle connections a server may ping the client the same way, and the client answers automatically with
35+
the empty result - no handler is needed. Registering `transport.on_server_request("ping")` on `MCP::Client::HTTP` replaces
36+
the automatic answer.
37+
Over stdio, a ping that arrives while a response is awaited is answered inline; between requests it is answered when
38+
the next request starts reading. The answer is best effort: a pong that cannot be written (for example, over a broken pipe) is
39+
dropped rather than failing the request whose response is being read. Independently of pings, consider setting `read_timeout:`
40+
on `MCP::Client::Stdio`, since a server that never answers otherwise holds the read until the process exits.
41+
3242
## Server Side
3343

3444
How servers answer `ping` requests and ping the client themselves is documented on

‎docs/_client/transports.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,12 @@ http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") do |f
233233
end
234234
```
235235

236+
{: .note }
237+
> Answers to server-to-client requests (a pong, an elicitation result) are POSTed from inside
238+
> the SSE streaming callback of another response, re-entering the connection on the same thread.
239+
> The default Net::HTTP adapter opens a connection per request, which makes this safe; an adapter
240+
> with persistent connections must tolerate that re-entry.
241+
236242
## Custom Transports
237243

238244
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:

‎lib/mcp/client/http.rb‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,19 @@ def dispatch_server_request(message)
11181118
},
11191119
}
11201120
end
1121+
elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric))
1122+
# The ping receiver "MUST respond promptly with an empty response" (MCP ping utility),
1123+
# so an unhandled ping is answered with the empty result rather than Method not found.
1124+
# A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
1125+
# schema validation, so a ping carrying one falls through to Method not found instead.
1126+
# The default lives here instead of in `@server_request_handlers`, whose `any?` opens
1127+
# the GET listening stream on connect: a built-in pong must not change listener behavior.
1128+
# A handler registered via `on_server_request("ping")` still wins through the branch above.
1129+
{
1130+
jsonrpc: JsonRpcHandler::Version::V2_0,
1131+
id: message["id"],
1132+
result: {},
1133+
}
11211134
else
11221135
{
11231136
jsonrpc: JsonRpcHandler::Version::V2_0,

‎lib/mcp/client/stdio.rb‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,18 @@ def read_response(request)
436436

437437
parsed = JSON.parse(line.strip)
438438

439+
# A frame carrying `method` is a request or notification, never the awaited response,
440+
# whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id
441+
# may legitimately collide with the awaited one. Pings are answered;
442+
# other server-to-client requests over stdio stay unsupported as documented,
443+
# and notifications carry no id.
444+
if parsed.is_a?(Hash) && parsed.key?("method")
445+
# A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
446+
# schema validation, so a ping carrying one is skipped rather than echoed back.
447+
answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"])
448+
next
449+
end
450+
439451
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
440452
# the same way as a frame without an id.
441453
next unless parsed.is_a?(Hash) && parsed.key?("id")
@@ -451,6 +463,22 @@ def read_response(request)
451463
)
452464
end
453465

466+
# The ping receiver "MUST respond promptly with an empty response". Answered inline from
467+
# the read loop, so a keepalive cannot stall unanswered while a response is awaited;
468+
# between requests nothing reads stdout, and a queued ping is answered on the next read.
469+
def answer_ping(parsed)
470+
@write_mutex.synchronize do
471+
write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} })
472+
end
473+
rescue RequestHandlerError
474+
# Best effort: a pong cannot be delivered over a broken stdin, and the failure must not
475+
# surface as an error of the unrelated request whose response the loop is reading.
476+
end
477+
478+
def json_rpc_id?(id)
479+
id.is_a?(String) || id.is_a?(Numeric)
480+
end
481+
454482
def ensure_running!
455483
return if @wait_thread.alive?
456484

‎test/mcp/client/http_test.rb‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,6 +1303,131 @@ def test_send_request_dispatches_sampling_request_to_registered_handler
13031303
assert_equal(100, received_params["maxTokens"])
13041304
end
13051305

1306+
def test_send_request_answers_a_server_ping_with_an_empty_result
1307+
request = {
1308+
jsonrpc: "2.0",
1309+
id: "test_id",
1310+
method: "tools/call",
1311+
params: { name: "some_tool", arguments: {} },
1312+
}
1313+
1314+
server_ping = { jsonrpc: "2.0", id: 7, method: "ping" }
1315+
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
1316+
sse_body = <<~BODY
1317+
event: message
1318+
data: #{server_ping.to_json}
1319+
1320+
event: message
1321+
data: #{tool_result.to_json}
1322+
1323+
BODY
1324+
1325+
stub_request(:post, url).with(
1326+
body: request.to_json,
1327+
).to_return(
1328+
status: 200,
1329+
headers: { "Content-Type" => "text/event-stream" },
1330+
body: sse_body,
1331+
)
1332+
1333+
pong = { jsonrpc: "2.0", id: 7, result: {} }
1334+
pong_stub = stub_request(:post, url).with(
1335+
body: pong.to_json,
1336+
).to_return(status: 202, body: "")
1337+
1338+
response = client.send_request(request: request)
1339+
1340+
assert_equal({ "content" => [] }, response["result"])
1341+
assert_requested(pong_stub)
1342+
end
1343+
1344+
def test_a_registered_ping_handler_overrides_the_default_pong
1345+
request = {
1346+
jsonrpc: "2.0",
1347+
id: "test_id",
1348+
method: "tools/call",
1349+
params: { name: "some_tool", arguments: {} },
1350+
}
1351+
1352+
server_ping = { jsonrpc: "2.0", id: 8, method: "ping" }
1353+
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
1354+
sse_body = <<~BODY
1355+
event: message
1356+
data: #{server_ping.to_json}
1357+
1358+
event: message
1359+
data: #{tool_result.to_json}
1360+
1361+
BODY
1362+
1363+
stub_request(:post, url).with(
1364+
body: request.to_json,
1365+
).to_return(
1366+
status: 200,
1367+
headers: { "Content-Type" => "text/event-stream" },
1368+
body: sse_body,
1369+
)
1370+
1371+
expected_response = { jsonrpc: "2.0", id: 8, result: { custom: "pong" } }
1372+
response_stub = stub_request(:post, url).with(
1373+
body: expected_response.to_json,
1374+
).to_return(status: 202, body: "")
1375+
1376+
client.on_server_request("ping") { { custom: "pong" } }
1377+
1378+
response = client.send_request(request: request)
1379+
1380+
assert_equal({ "content" => [] }, response["result"])
1381+
assert_requested(response_stub)
1382+
end
1383+
1384+
def test_a_ping_with_a_malformed_id_is_answered_with_method_not_found
1385+
request = {
1386+
jsonrpc: "2.0",
1387+
id: "test_id",
1388+
method: "tools/call",
1389+
params: { name: "some_tool", arguments: {} },
1390+
}
1391+
1392+
# A Hash id is not a JSON-RPC id; the reference SDKs reject such frames at schema
1393+
# validation, so the default pong does not fire and the frame falls through.
1394+
server_ping = { jsonrpc: "2.0", id: { x: 1 }, method: "ping" }
1395+
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
1396+
sse_body = <<~BODY
1397+
event: message
1398+
data: #{server_ping.to_json}
1399+
1400+
event: message
1401+
data: #{tool_result.to_json}
1402+
1403+
BODY
1404+
1405+
stub_request(:post, url).with(
1406+
body: request.to_json,
1407+
).to_return(
1408+
status: 200,
1409+
headers: { "Content-Type" => "text/event-stream" },
1410+
body: sse_body,
1411+
)
1412+
1413+
expected_error = {
1414+
jsonrpc: "2.0",
1415+
id: { x: 1 },
1416+
error: {
1417+
code: JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND,
1418+
message: "Method not found: ping",
1419+
},
1420+
}
1421+
error_stub = stub_request(:post, url).with(
1422+
body: expected_error.to_json,
1423+
).to_return(status: 202, body: "")
1424+
1425+
response = client.send_request(request: request)
1426+
1427+
assert_equal({ "content" => [] }, response["result"])
1428+
assert_requested(error_stub)
1429+
end
1430+
13061431
def test_send_request_answers_unregistered_server_request_with_method_not_found
13071432
request = {
13081433
jsonrpc: "2.0",

0 commit comments

Comments
 (0)