## Motivation and Context SEP-2207 describes how an OAuth client obtains refresh tokens: it declares the `refresh_token` grant type in its client metadata and requests the `offline_access` scope, but only when the authorization server advertises `offline_access` in its metadata `scopes_supported`. Requesting `offline_access` when the server does not list it is forbidden. The Ruby SDK already supports the refresh grant (`Flow#refresh!`) but never requested `offline_access`, so it could not obtain a refresh token from a server that gates it behind that scope. This aligns the SDK with the Python and TypeScript SDKs and makes the `auth/offline-access-scope` conformance scenario pass (previously it reported one warning, `sep-2207-client-metadata-grant-types`, and was listed in the expected-failures baseline). The change adds: - `Flow#augment_scope_with_offline_access`, called from `run!` after `resolve_scope`, appends `offline_access` to the requested scope when both the client opted into refresh tokens (its registered `grant_types` include `refresh_token`) and the authorization server advertises `offline_access` in its metadata `scopes_supported`. Already-present `offline_access` is not duplicated. Gating on the server advertisement keeps the SDK from ever requesting the scope where it is not supported. - `conformance/client.rb` declares `refresh_token` in `grant_types`, an honest reflection of the SDK's refresh support, which clears the SEP-2207 grant-types warning. - `auth/offline-access-scope` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? New `Flow` tests cover: `offline_access` is requested when the server advertises it and the client declared the `refresh_token` grant; `offline_access` is NOT requested when the client did not declare the grant; `offline_access` is NOT requested when the server does not advertise it (even with the grant declared); and `offline_access` is not duplicated when it is already part of the resolved scope. Conformance: `auth/offline-access-scope` now passes 14/14 with no warnings, and `auth/offline-access-not-supported` still passes 13/13 (no regression). `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. `offline_access` is appended only when the client's own `grant_types` opt into refresh tokens and the authorization server advertises the scope, so existing clients that do neither see no change in the scope they request.
116 lines
4.2 KiB
Ruby
116 lines
4.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
|
|
|
|
# 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)
|
|
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", "refresh_token"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: redirect_uri,
|
|
redirect_handler: redirect_handler,
|
|
callback_handler: callback_handler,
|
|
storage: storage,
|
|
)
|
|
end
|
|
|
|
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context) : 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
|