## Motivation and Context Resolves the `elicitation-sep1034-client-defaults` client conformance scenario (SEP-1034, https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034). The Ruby HTTP client could not receive server-to-client requests at all: it never opened the spec's standalone GET SSE listening stream, and SSE events that were not responses were discarded. A server sending `elicitation/create` during a tools/call (such as the conformance harness, which is built on the TypeScript SDK server) either dropped the request silently or deadlocked the tool call waiting for an answer that could never arrive. This change adds three pieces: ### Server-to-client request dispatch `MCP::Client::HTTP#on_server_request(method) { |params| ... }` registers a handler; when a JSON-RPC request (a message with both `method` and `id`) arrives on any SSE stream, the handler's return value is POSTed back to the server as the JSON-RPC `result` (the server ACKs with 202 Accepted, per the transport spec). Methods without a handler are answered with a `-32601` method-not-found error. A handler may raise `MCP::Client::ServerRequestError` to answer with a specific JSON-RPC error code (for example `-1` for a rejected sampling request, per the sampling spec), mirroring the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return; any other error is answered with a `-32603` internal error. In every case the server is not left waiting, matching how the TypeScript and Python SDKs convert handler failures into JSON-RPC error responses. ### A standalone GET SSE listening stream Registering a handler (once connected) opens the spec's listening stream on a background thread, because servers deliver requests that are not tied to a client request on that stream - the TypeScript SDK's `StreamableHTTPServerTransport` silently drops them when no GET stream is open. Mirrors the TypeScript and Python SDK clients, which start listening right after the `initialize` handshake without awaiting stream establishment. Reconnection semantics also follow both SDKs: a gracefully closed stream is retried indefinitely after the server's `retry:` interval, while consecutive connection failures (HTTP errors and idle read timeouts) stop the listener once they reach the reconnection attempt cap, with a successful stream resetting the count; a 405 stops immediately without retrying, since it is the spec's answer from a server that offers no listening stream. The listening GET uses a 5-minute read timeout matching the Python SDK's `sse_read_timeout` default. `close` terminates the thread. ### SEP-1034 defaults `MCP::Client::Elicitation.apply_defaults` fills fields omitted from the elicitation content with the `default` values declared in `requestedSchema` properties, never overwriting provided values and skipping properties without a default. This follows the Python SDK, which applies defaults in the elicitation callback; the TypeScript SDK's automatic `applyElicitationDefaults` (gated on the `elicitation.form.applyDefaults` capability) is a possible follow-up. `MCP::Client#on_elicitation` registers a handler for `elicitation/create` as a convenience. The conformance client gains an `elicitation-sep1034-client-defaults` branch that connects with `capabilities: { elicitation: {} }`, accepts the elicitation with defaults applied, and calls the harness's `test_client_elicitation_defaults` tool; the scenario is removed from the expected failures baseline. ## How Has This Been Tested? New unit tests covering: handler dispatch from a request's POST SSE stream with the response body and 202 ACK verified, the `-32601` answer for unregistered methods, the `-32603` answer for a raising handler, the custom-code answer when a handler raises `ServerRequestError`, listener startup from both registration-after-connect and connect-with-registered-handlers, listener shutdown after consecutive connection failures with the count resetting after a successful stream and an immediate stop on 405, and `apply_defaults` for all five SEP-1034 property types (string, integer, number, enum, boolean), non-overwriting of provided values, omission of properties without defaults, and symbol-keyed schemas ## Breaking Changes None. The listening stream and its background thread are created only when a server-request handler is registered; clients that never call `on_server_request` / `on_elicitation` keep the existing single-request behavior.
180 lines
7.2 KiB
Ruby
180 lines
7.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Conformance test client for the MCP Ruby SDK.
|
|
# Invoked by the conformance runner:
|
|
# MCP_CONFORMANCE_SCENARIO=<scenario> bundle exec ruby conformance/client.rb <server-url>
|
|
#
|
|
# The server URL is passed as the last positional argument.
|
|
# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
|
|
# which is set automatically by the conformance test runner.
|
|
|
|
require "faraday"
|
|
require "json"
|
|
require_relative "../lib/mcp"
|
|
|
|
scenario = ENV["MCP_CONFORMANCE_SCENARIO"]
|
|
server_url = ARGV.last
|
|
|
|
unless scenario && server_url
|
|
abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>")
|
|
end
|
|
|
|
# URL the conformance harness expects to see as the OAuth `client_id` for the `auth/basic-cimd` scenario
|
|
# when the AS advertises `client_id_metadata_document_supported`. The harness does not fetch the document,
|
|
# only matches the value, so the URL does not need to resolve.
|
|
CONFORMANCE_CIMD_URL = "https://conformance-test.local/client-metadata.json"
|
|
|
|
# The conformance harness optionally injects scenario-specific data via
|
|
# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is
|
|
# defined by the harness, not the MCP spec, and has varied between versions:
|
|
#
|
|
# - Newer (`@modelcontextprotocol/conformance` >= 0.x): scenario fields are
|
|
# spread at the top level alongside `name`, e.g.
|
|
# `{"name":"auth/pre-registration","client_id":"...","client_secret":"..."}`.
|
|
# - Older: a nested `context` object: `{"name":"...","context":{...}}`.
|
|
#
|
|
# Both shapes are accepted so the client conforms to whichever harness version
|
|
# the developer has on hand.
|
|
def conformance_context
|
|
raw = ENV["MCP_CONFORMANCE_CONTEXT"]
|
|
return {} if raw.nil? || raw.empty?
|
|
|
|
parsed = JSON.parse(raw)
|
|
return {} unless parsed.is_a?(Hash)
|
|
|
|
if parsed["context"].is_a?(Hash)
|
|
parsed["context"]
|
|
else
|
|
parsed.reject { |key, _| key == "name" }
|
|
end
|
|
rescue JSON::ParserError
|
|
{}
|
|
end
|
|
|
|
# Saves the pre-registered `client_id` / `client_secret` the harness injects
|
|
# via context (used by pre-registration and client_credentials scenarios).
|
|
def storage_for(context)
|
|
storage = MCP::Client::OAuth::InMemoryStorage.new
|
|
if context["client_id"]
|
|
storage.save_client_information(
|
|
"client_id" => context["client_id"],
|
|
"client_secret" => context["client_secret"],
|
|
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
|
|
)
|
|
end
|
|
storage
|
|
end
|
|
|
|
# Builds a `client_credentials`-only provider (machine-to-machine, no redirect).
|
|
# The pre-registered credentials are injected by the harness via context.
|
|
def build_client_credentials_provider(context)
|
|
MCP::Client::OAuth::ClientCredentialsProvider.new(
|
|
client_id: context["client_id"],
|
|
client_secret: context["client_secret"],
|
|
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
|
|
)
|
|
end
|
|
|
|
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
|
|
# non-interactively against the conformance test's auth server. The conformance
|
|
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
|
|
# `code=test-auth-code`, so we follow it manually instead of opening a browser.
|
|
def build_oauth_provider(context, scenario:)
|
|
callback_holder = {}
|
|
redirect_uri = "http://localhost:0/callback"
|
|
|
|
redirect_handler = ->(authorization_url) do
|
|
response = Faraday.new.get(authorization_url) do |req|
|
|
req.options.params_encoder = nil
|
|
end
|
|
location = response.headers["location"] || response.headers["Location"]
|
|
abort("Authorization request did not redirect: #{response.status}.") unless location
|
|
|
|
callback_holder[:url] = URI.parse(location)
|
|
end
|
|
|
|
callback_handler = -> do
|
|
query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h
|
|
[query["code"], query["state"]]
|
|
end
|
|
|
|
MCP::Client::OAuth::Provider.new(
|
|
client_metadata: {
|
|
client_name: "ruby-sdk-conformance-client",
|
|
redirect_uris: [redirect_uri],
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: redirect_uri,
|
|
redirect_handler: redirect_handler,
|
|
callback_handler: callback_handler,
|
|
storage: storage_for(context),
|
|
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
|
|
)
|
|
end
|
|
|
|
def build_provider_for(scenario, context)
|
|
if scenario.start_with?("auth/client-credentials")
|
|
build_client_credentials_provider(context)
|
|
else
|
|
build_oauth_provider(context, scenario: scenario)
|
|
end
|
|
end
|
|
|
|
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
|
|
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
|
client = MCP::Client.new(transport: transport)
|
|
capabilities = scenario == "elicitation-sep1034-client-defaults" ? { elicitation: {} } : {}
|
|
client.connect(
|
|
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
|
capabilities: capabilities,
|
|
)
|
|
|
|
case scenario
|
|
when "initialize"
|
|
client.tools
|
|
when "tools_call"
|
|
tools = client.tools
|
|
add_numbers = tools.find { |t| t.name == "add_numbers" }
|
|
abort("Tool add_numbers not found") unless add_numbers
|
|
client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 })
|
|
when "sse-retry"
|
|
# SEP-1699: the server closes the tools/call SSE stream right after a priming event.
|
|
# The transport waits the server's `retry:` interval, reconnects with a GET carrying `Last-Event-ID`,
|
|
# and receives the tool result on the resumed stream; the harness verifies the reconnect,
|
|
# its timing, and the header.
|
|
tools = client.tools
|
|
test_reconnection = tools.find { |t| t.name == "test_reconnection" }
|
|
abort("Tool test_reconnection not found") unless test_reconnection
|
|
client.call_tool(tool: test_reconnection, arguments: {})
|
|
when "elicitation-sep1034-client-defaults"
|
|
# SEP-1034: the server sends `elicitation/create` on the tools/call SSE stream with every property declaring
|
|
# a `default` and none required. The handler accepts and fills the omitted fields from those defaults.
|
|
client.on_elicitation do |params|
|
|
{ action: "accept", content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"] || {}) }
|
|
end
|
|
|
|
tools = client.tools
|
|
defaults_tool = tools.find { |t| t.name == "test_client_elicitation_defaults" }
|
|
|
|
abort("Tool test_client_elicitation_defaults not found") unless defaults_tool
|
|
|
|
client.call_tool(tool: defaults_tool, arguments: {})
|
|
when %r|\Aauth/|
|
|
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
|
|
# are observed by the conformance server during `connect` and the subsequent request below.
|
|
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
|
|
tools = client.tools
|
|
|
|
# `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`,
|
|
# so the client must actually invoke a tool to drive the second authorization request
|
|
# the scenario asserts on.
|
|
if scenario == "auth/scope-step-up"
|
|
tool = tools.find { |t| t.name == "test-tool" } || tools.first
|
|
abort("No tool exposed by conformance server for #{scenario}") unless tool
|
|
client.call_tool(tool: tool, arguments: {})
|
|
end
|
|
else
|
|
abort("Unknown or unsupported scenario: #{scenario}")
|
|
end
|