## Motivation and Context The MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00` define Client ID Metadata Documents (CIMD): a client publishes its OAuth metadata at an HTTPS URL and uses that URL as the OAuth `client_id` instead of going through Dynamic Client Registration. The Python and TypeScript SDKs already implement this; the Ruby SDK now reaches parity. The change adds: - `MCP::Client::OAuth::Provider` accepts an optional `client_id_metadata_document_url:` keyword. - A new `Discovery.client_id_metadata_document_url?` validator enforces structural requirements. Spec-required: `https` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments (including the percent-encoded form `%2E`). Stricter than the draft as an SDK policy: query strings are also rejected, because different encodings of the same query would yield distinct `client_id` strings for the same logical document. The loopback `http` carve-out used for discovery URLs does not extend to CIMD URLs, since the value is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback. Validation failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. - `Flow#ensure_client_registered` routes through CIMD only when the authorization server advertises `client_id_metadata_document_supported` as JSON `boolean true`; truthy non-boolean values (string `"true"`, `"false"`, etc.) fall back to DCR, matching the gating in the Python and TypeScript SDKs. - The CIMD `client_id` is NOT persisted to storage. AS support is re-read on every flow so a server that later drops CIMD support stops receiving the stale URL. Pre-registered `client_information` continues to win over CIMD when both are available. - `Flow#refresh!` reconstructs the CIMD `client_id` from live AS metadata when refreshing tokens obtained via CIMD. When support has been withdrawn and no other identity is stored, it raises a clear `AuthorizationError` rather than sending a `client_id` the authorization server no longer recognises. - The Provider docstring and README OAuth section explain that DCR `client_metadata` and the JSON document served at the CIMD URL are separate artifacts: DCR metadata MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. - `auth/basic-cimd` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? `Discovery` tests cover: https with path accepted; non-https rejected (http, ftp, file); root and empty path rejected; fragment, query, and userinfo rejected; dot segments rejected including the percent-encoded form `%2E`; nil, empty, and malformed inputs rejected. `Provider` tests cover: the default `nil`; rejection of http URLs (loopback included); non-https schemes; root and empty path; fragment; query; userinfo; and dot segments. `Flow#run!` tests cover: skip DCR when the authorization server advertises CIMD and the provider has a URL; fall back to DCR when the authorization server does not advertise support; fall back to DCR when the value is the string `"false"` or `"true"`; pre-registered `client_information` wins over CIMD; the CIMD `client_id` is not persisted to storage; a second flow against an authorization server that has dropped CIMD support recovers via DCR. `Flow#refresh!` tests cover: refresh reconstructs the CIMD `client_id` without a stored `client_information`; refresh raises when the authorization server no longer advertises CIMD support; stored `client_information` takes precedence over the CIMD URL during refresh. Conformance: `auth/basic-cimd` now passes 12/12 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. The `client_id_metadata_document_url:` keyword is purely opt-in and defaults to `nil`. When the keyword is omitted, the DCR flow is unchanged. Existing OAuth users see no behaviour change. The CIMD path is only entered when both the user explicitly configures a URL and the authorization server advertises support via `client_id_metadata_document_supported: true`.
122 lines
4.6 KiB
Ruby
122 lines
4.6 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
|
|
|
|
# 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
|
|
|
|
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
|
|
|
|
MCP::Client::OAuth::Provider.new(
|
|
client_metadata: {
|
|
client_name: "ruby-sdk-conformance-client",
|
|
redirect_uris: [redirect_uri],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: redirect_uri,
|
|
redirect_handler: redirect_handler,
|
|
callback_handler: callback_handler,
|
|
storage: storage,
|
|
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
|
|
)
|
|
end
|
|
|
|
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil
|
|
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
|
client = MCP::Client.new(transport: transport)
|
|
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
|
|
|
|
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 %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.
|
|
client.tools
|
|
else
|
|
abort("Unknown or unsupported scenario: #{scenario}")
|
|
end
|