## Motivation and Context Final step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release, now that the server core and both server transports can serve modern traffic. Server side, `Server#discover` reaches its spec-final shape: - `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs: legacy versions are negotiated via `initialize`, not selected from discovery. This also closes an interop hazard of the interim response: auto-negotiating TS and Python clients treat any `DiscoverResult` as proof of a modern server and do not fall back to `initialize`, so advertising only legacy versions made them fail outright. - The `ttlMs`/`cacheScope` cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list and read results), so the spec defaults (`0`, `"private"`) fill in when the server was not configured with `ttl_ms`/`cache_scope`, and the configured SEP-2549 values are reused otherwise. - The server identity rides in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp instead of a top-level `serverInfo` field, following spec PR modelcontextprotocol/modelcontextprotocol#3002 (the TypeScript SDK stamps `_meta` the same way and the Python `DiscoverResult` carries no top-level `serverInfo`, so the top-level placement was invisible to both). `Client#discover` and the `server_implementation` reader read the `_meta` stamp with a top-level fallback for servers built against the frozen SEP text, and the key is exposed as `RequestEnvelope::SERVER_INFO_META_KEY`. Client side, both bundled transports learn the modern lifecycle, and `MCP::Client` negotiates it automatically by default: - `MCP::Client#discover` sends `server/discover` and returns a `DiscoverResult` struct (`supported_versions`, `capabilities`, `server_info`, `instructions`, `ttl_ms`, `cache_scope`). It works before `connect`; the stdio transport's connect guard exempts `server/discover` and spawns the subprocess on demand. - `connect` gains `mode:` and defaults to automatic negotiation, joining the Python, Go, and C# SDKs (the TypeScript SDK keeps a legacy default): transports whose `connect` declares `mode:` (the bundled HTTP and stdio transports) receive `:auto`, which probes `server/discover` and falls back to the legacy handshake on any discovery failure, including a successful discovery without a mutually supported modern version (rollout tolerance for servers that answer discovery while only serving legacy versions). Custom transports whose `connect` does not declare `mode:` keep receiving the historical legacy call shape, detected by keyword introspection; a bare `**kwargs` deliberately does not count, so wrappers and test doubles that absorb arbitrary keywords stay on the legacy shape. An explicit `:modern`/`:auto` against such a transport raises `ArgumentError` instead of silently downgrading. - The mode vocabulary is `:legacy`/`:auto` (shared with the Python and TypeScript SDKs) plus an explicit `:modern`, instead of folding a version-string pin into `mode` as Python and TypeScript do: this client has carried the scalar `protocol_version:` keyword since the handshake landed, so a second version-bearing input would make the API ambiguous about which one wins. `mode:` stays the strategy and `protocol_version:` stays the version, the same orthogonal split as the Go and C# scalar version pins; Python's `mode="2026-07-28"` is expressed here as `mode: :modern` with `protocol_version: "2026-07-28"`. - An explicit legacy-generation `protocol_version` (e.g. `"2025-11-25"`) pins the legacy handshake without a probe, so an explicitly requested version is never overridden by the default negotiation adopting the modern lifecycle. `mode: :legacy` forces the classic handshake unconditionally; prefer it for spawn-per-invocation CLI tools and when using server-initiated requests, which exist only on the legacy lifecycle. - Because the raw `connect` return value and `server_info` mirror the wire result, their shape depends on the negotiated era (`InitializeResult` vs `DiscoverResult`). New era-independent readers absorb that difference: `MCP::Client#protocol_version` (negotiated or adopted version; backed by a new stdio transport reader and the existing HTTP one), `#server_capabilities`, `#instructions`, and `#server_implementation`, which reads the spec-final `_meta` `io.modelcontextprotocol/serverInfo` stamp with a top-level fallback and returns `nil` when a modern server does not identify itself. `server_info` remains the permanent raw window to everything the readers do not cover. - The stdio `server/discover` probe is bounded by a new `DEFAULT_DISCOVER_PROBE_TIMEOUT` (5 seconds, matching the C# SDK) when no `read_timeout` was configured: a compliant legacy server answers the probe with `-32601` immediately, but a non-compliant one that silently drops unknown methods must read as legacy evidence for the auto fallback instead of blocking `connect` forever. Regular requests keep the unbounded default. - In modern mode, the new shared `MCP::Client::ModernEnvelope` stamps the SEP-2575 `_meta` triple onto every request (never onto notifications, whose `_meta` carries no envelope), reusing the reserved key names from `MCP::RequestEnvelope`. The HTTP transport sends the matching `MCP-Protocol-Version` header and keeps the existing `Mcp-Method`/`Mcp-Name` mirror headers; no `Mcp-Session-Id` is ever attached because `initialize` is never sent. - The conformance client pins `mode: :legacy`: the referee validates the legacy `initialize` handshake and its scenario servers do not expect a preceding probe. Resolves #389. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb` pin the required cache hints (spec defaults and configured SEP-2549 values); the existing discover assertions across `server_test.rb` and both transport test files were updated to the modern-only `supportedVersions`. New tests in `test/mcp/client_test.rb` cover the `DiscoverResult` struct mapping, `ValidationError`/`ServerError` on malformed and error responses, and the mode resolution contract: the `:auto` default on transports declaring `mode:`, the unchanged legacy call shape for transports without it, the legacy pin for an explicit stable `protocol_version`, `ArgumentError` for explicit non-legacy modes on transports without `mode:`, unknown-mode rejection, and the era-independent readers against legacy results, modern results, the `_meta` `serverInfo` stamp, and absence. New tests in `test/mcp/client/http_test.rb` cover modern adoption via discovery, envelope stamping with the matching header on subsequent requests, the no-mutual-version failure, both auto-mode fallbacks, and argument validation. New tests in `test/mcp/client/stdio_test.rb` cover pre-connect `server/discover`, modern adoption with envelope stamping and no `initialize` frame, the auto-mode fallback, the probe timeout default applying only while probing, and the fallback when a server never answers the probe. ## Breaking Changes `server/discover` responses change shape (modern-only `supportedVersions` plus the required `ttlMs`/`cacheScope`); this method has not shipped in a gem release yet, so no released behavior changes. On the bundled HTTP and stdio transports, `MCP::Client#connect` without `mode:` now probes `server/discover` before the legacy handshake: against legacy servers this adds one round trip and an unknown-method entry in their logs with an unchanged final result, and once a server serves the modern lifecycle the connection adopts it, changing the raw result shape from `InitializeResult` to `DiscoverResult`. Pass `mode: :legacy` or an explicit legacy-generation `protocol_version` to keep the previous behavior unconditionally, or use the era-independent readers, which are stable across both lifecycles. Custom transports without `mode:` and direct `transport.connect` calls are unaffected.
232 lines
9.9 KiB
Ruby
232 lines
9.9 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:
|
|
# a shared secret for the basic scenario, or a PEM private key plus signing algorithm
|
|
# (default ES256, matching the TypeScript and Python conformance clients)
|
|
# for the `private_key_jwt` scenario.
|
|
def build_client_credentials_provider(context)
|
|
if context["private_key_pem"]
|
|
MCP::Client::OAuth::ClientCredentialsProvider.new(
|
|
client_id: context["client_id"],
|
|
token_endpoint_auth_method: "private_key_jwt",
|
|
private_key: context["private_key_pem"],
|
|
signing_algorithm: context["signing_algorithm"] || "ES256",
|
|
)
|
|
else
|
|
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
|
|
end
|
|
|
|
# Builds a SEP-990 Cross-App Access provider: the harness injects an IdP ID token plus the IdP token endpoint
|
|
# via context; the assertion provider exchanges them for an ID-JAG (RFC 8693), which the flow then presents to
|
|
# the MCP authorization server with the jwt-bearer grant.
|
|
def build_cross_app_access_provider(context)
|
|
assertion_provider = ->(audience:, resource:) do
|
|
MCP::Client::OAuth::IDJAGTokenExchange.request(
|
|
token_endpoint: context["idp_token_endpoint"],
|
|
id_token: context["idp_id_token"],
|
|
client_id: context["idp_client_id"],
|
|
audience: audience,
|
|
resource: resource,
|
|
)
|
|
end
|
|
|
|
MCP::Client::OAuth::CrossAppAccessProvider.new(
|
|
client_id: context["client_id"],
|
|
client_secret: context["client_secret"],
|
|
assertion_provider: assertion_provider,
|
|
)
|
|
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
|
|
# The 3-element form opts into SEP-2468 / RFC 9207 `iss` validation;
|
|
# `query["iss"]` is nil when the authorization response carried no `iss`.
|
|
[query["code"], query["state"], query["iss"]]
|
|
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 context["idp_id_token"]
|
|
build_cross_app_access_provider(context)
|
|
elsif 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: {} } : {}
|
|
# The conformance harness asserts on the observed protocol exchange, not on the client's exit status,
|
|
# and echoes the client's output only when the exit status is non-zero. Several negative auth scenarios
|
|
# (e.g. `auth/resource-mismatch`, `auth/scope-retry-limit`) are expected to drive the client into
|
|
# a terminal failure: the SDK correctly refuses a mismatched resource or stops after the scope step-up
|
|
# retry limit. Report such an expected failure as a one-line reason and exit cleanly so the run stays
|
|
# free of noise, while letting any unexpected error (a real SDK bug) still raise with a full backtrace
|
|
# and a failing exit status.
|
|
begin
|
|
# The conformance referee validates the legacy `initialize` handshake, so pin the legacy lifecycle:
|
|
# the default `:auto` negotiation would prepend a `server/discover` probe the scenario servers do not expect.
|
|
client.connect(
|
|
client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION },
|
|
capabilities: capabilities,
|
|
mode: :legacy,
|
|
)
|
|
|
|
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
|
|
rescue MCP::Client::OAuth::Flow::AuthorizationError, MCP::Client::RequestHandlerError, Faraday::Error => e
|
|
warn("#{scenario}: client ended in the expected terminal failure (#{e.class}: #{e.message})")
|
|
exit
|
|
end
|