Skip to content

Commit 6d272df

Browse files
authored
Merge pull request #541 from koic/respond_to_server_pings_in_the_client
Answer server-to-client pings in the client
2 parents eeab784 + 088f4b9 commit 6d272df

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)