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
5 changes: 5 additions & 0 deletions docs/_client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ nav_order: 2
This page covers the bundled [stdio](#stdio-transport-layer) and [Streamable HTTP](#http-transport-layer) transports,
their sessions and server-to-client request handling, and the interface a custom transport must implement.

Both bundled transports check the `jsonrpc` member of the messages a server sends: the response to a pending request fails
that request with `MCP::Client::RequestHandlerError` instead of being returned when the member is missing or is not exactly `"2.0"`,
and a server-to-client request with such a member is ignored rather than answered.
A custom transport does its own checking; `MCP::Client` uses the Hash it returns as is.

## Stdio Transport Layer

Use the `MCP::Client::Stdio` transport to interact with MCP servers running as subprocesses over standard input/output.
Expand Down
25 changes: 22 additions & 3 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,10 @@ def feed(chunk)

if parsed.key?("result") || parsed.key?("error")
@response ||= parsed
elsif parsed["method"] && parsed.key?("id")
# A server-to-client request (e.g. `elicitation/create`) delivered
# on the stream while the original request is still pending.
elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
# A server-to-client request (e.g. `elicitation/create`) delivered on the stream while
# the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered,
# as the TypeScript client drops it at schema validation.
@on_request&.call(parsed)
end
end
Expand Down Expand Up @@ -426,6 +427,10 @@ def send_request(request:)

body = resolve_response_body(stream, response, method, params)

# Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before
# anything is learned from the body, and only for a request: a notification awaits no response.
reject_invalid_response!(body, method, params) if request[:id] || request["id"]

capture_session_info(method, response, body) if response
capture_mcp_param_declarations(method, params, body)

Expand Down Expand Up @@ -1172,6 +1177,20 @@ def parse_json_buffer(buffer, method, params)
)
end

# A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200)
# is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`.
# The received value is left out of the message because it can be any JSON value.
def reject_invalid_response!(body, method, params)
return if body.nil?
return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"])

raise RequestHandlerError.new(
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
{ method: method, params: params },
error_type: :parse_error,
)
end

# SEP-1699 resumability: the server closed the SSE stream after a priming event
# without delivering the response. Treat the graceful close like a network failure:
# wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
Expand Down
27 changes: 23 additions & 4 deletions lib/mcp/client/stdio.rb
Original file line number Diff line number Diff line change
Expand Up @@ -442,17 +442,22 @@ def read_response(request)
# 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"])
# A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject
# other shapes at schema validation, so a ping carrying one is skipped rather than echoed back.
answer_ping(parsed) if answerable_ping?(parsed)
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")
next unless parsed["id"] == request_id

return parsed if parsed["id"] == request_id
# Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it
# instead of being skipped: without a `read_timeout`, skipping would wait forever.
raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"])

return parsed
end
rescue JSON::ParserError => e
raise RequestHandlerError.new(
Expand All @@ -475,6 +480,10 @@ def answer_ping(parsed)
# surface as an error of the unrelated request whose response the loop is reading.
end

def answerable_ping?(parsed)
parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
end

def json_rpc_id?(id)
id.is_a?(String) || id.is_a?(Numeric)
end
Expand Down Expand Up @@ -532,6 +541,16 @@ def raise_connection_error!(method, params)
error_type: :internal_error,
)
end

# The line framing is intact, so the transport stays usable; the received value is left out of
# the message because it can be any JSON value.
def raise_invalid_response!(method, params)
raise RequestHandlerError.new(
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
{ method: method, params: params },
error_type: :internal_error,
)
end
end
end
end
Loading
Loading