Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/_client/ping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/_client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,19 @@ def dispatch_server_request(message)
},
}
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,
Expand Down
28 changes: 28 additions & 0 deletions lib/mcp/client/stdio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,18 @@ def read_response(request)

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")
Expand All @@ -451,6 +463,22 @@ def read_response(request)
)
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?

Expand Down
125 changes: 125 additions & 0 deletions test/mcp/client/http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,131 @@ def test_send_request_dispatches_sampling_request_to_registered_handler
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",
Expand Down
Loading