Skip to content

Commit f75abaa

Browse files
committed
Add OAuth 2.1 client support for MCP authorization flow
## Motivation and Context The MCP Authorization spec (2025-11-25) requires clients to support Protected Resource Metadata discovery (RFC 9728), Authorization Server Metadata discovery (RFC 8414), Dynamic Client Registration, and OAuth 2.1 Authorization Code grant with PKCE (S256). The Ruby SDK previously had no client-side OAuth support: the HTTP transport raised `RequestHandlerError` on a 401 with no recovery, and 21 `auth/*` scenarios were listed as known failures in `conformance/expected_failures.yml`. This aligns the Ruby SDK with the Python and TypeScript SDKs, both of which expose a pluggable OAuth client provider that drives the full discovery + DCR + authorization + token exchange flow. `MCP::Client::OAuth::Provider` mirrors that surface: callers supply `client_metadata`, a `redirect_uri`, and pluggable `redirect_handler` / `callback_handler` hooks plus a token/client information storage. `MCP::Client::OAuth::InMemoryStorage` is provided as a default. `MCP::Client::HTTP` accepts a new optional `oauth:` keyword. When present, the transport attaches `Authorization: Bearer` to every request, and on a 401 parses `WWW-Authenticate` for the `resource_metadata` URL and `scope` challenge, drives the discovery + DCR + authorization code flow through the provider, and retries the failed request once with the acquired token. Spec compliance details: - The `WWW-Authenticate` parser handles multiple challenges (e.g. `Basic ..., Bearer ...` or `Bearer ..., DPoP ...`), extracting only the parameters that belong to the Bearer challenge. - The PRM `resource` value is required to cover the MCP server URL (same scheme/host/port, with the PRM path as a prefix of the server path) before the flow continues, preventing redirection of issued tokens to a different audience. - Both authorization and token requests always include a `resource` parameter (RFC 8707), defaulting to a canonicalized form of the MCP server URL when PRM does not advertise one. - `client_secret_basic`, `client_secret_post`, and `none` token endpoint authentication methods are supported. When pre-registered client information specifies no `token_endpoint_auth_method`, the transport defaults to `client_secret_basic` per RFC 6749 §2.3.1 an dthe TS/Python SDK convention. Pre-registered client credentials supplied via `Storage#save_client_information` with either string or symbol keys are honored, skipping DCR. - Scope resolution follows the spec order: the `WWW-Authenticate` challenge scope wins first, then PRM `scopes_supported`, and `Provider#scope` is the last-resort fallback only if neither is supplied (the provider-supplied scope must not pre-empt server-advertised scope). - Token endpoint error responses (RFC 6749 §5.2) are parsed: a 4x with `error: "invalid_grant"` raises `Flow::InvalidGrantError`, so `MCP::Client::HTTP` can discard the stored refresh token only when the AS truly says it is dead. Generic `AuthorizationError` (5xx, network failures, other error codes) leaves the refresh token intact, so a transient AS outage does not force interactive reauth on the next attempt. - The OAuth `state` parameter is compared via `OpenSSL.fixed_length_secure_compare` (with a `bytesize` guard) to prevent timing-based discovery of the expected value. - The 401 retry guard is a request-scoped local variable, so an MCP server that keeps returning 401 is surfaced as an error after one retry rather than triggering an infinite re-authorization loop. ## How Has This Been Tested? Unit tests cover PKCE generation, multi-challenge `WWW-Authenticate` parsing, PRM and Authorization Server metadata URL builders, the full Flow orchestrator (success path, state mismatch, missing PKCE support, skipping DCR for both string and symbol-keyed preregistered client information, `client_secret_basic` token endpoint authentication, PRM `resource` mismatch rejection, fallback to the server URL when PRM omits `resource`, scope resolution preferring PRM `scopes_supported` over `Provider#scope`, default `client_secret_basic` for confidential clients without an explicit `token_endpoint_auth_method`, and the new `InvalidGrantError` vs generic `AuthorizationError` split for token endpoint rejections), and the HTTP transport behavior (401 recovery + retry, no-provider fallthrough, no infinite loop on repeated 401, no infinite loop on flow failure, refresh-token retention on transient 5xx from the token endpoint). Conformance: `bundle exec rake conformance` newly passes 16 `auth/*` scenarios beyond the existing baseline, including `auth/metadata-default`, `metadata-var{1,2,3}`, `scope-from-www-authenticate`, `scope-from-scopes-supported`, `scope-omitted-when-undefined`, `scope-retry-limit`, `token-endpoint-auth-{basic,post,none}`, `pre-registration`, `resource-mismatch`, and `offline-access-not-supported`. Remaining `auth/*` scenarios stay in `expected_failures.yml`. `bundle exec rake test rubocop` is clean. ## Breaking Changes None. `MCP::Client::HTTP#initialize` accepts a new optional `oauth:` keyword that defaults to `nil`, preserving existing behavior. When unset, the transport raises `MCP::Client::RequestHandlerError` on a 401 exactly as before.
1 parent d6b5392 commit f75abaa

16 files changed

Lines changed: 4585 additions & 70 deletions

File tree

README.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,6 +1885,113 @@ client.tools # will make the call using Bearer auth
18851885

18861886
You can add any custom headers needed for your authentication scheme, or for any other purpose. The client will include these headers on every request.
18871887

1888+
#### OAuth 2.1 Authorization
1889+
1890+
When an MCP server enforces the [MCP Authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization),
1891+
pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Authorization` header. The transport will:
1892+
1893+
- Send `Authorization: Bearer <access_token>` on every request when a token is available.
1894+
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
1895+
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
1896+
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
1897+
1898+
```ruby
1899+
require "mcp"
1900+
1901+
provider = MCP::Client::OAuth::Provider.new(
1902+
client_metadata: {
1903+
client_name: "My MCP App",
1904+
redirect_uris: ["http://localhost:3030/callback"],
1905+
grant_types: ["authorization_code", "refresh_token"],
1906+
response_types: ["code"],
1907+
token_endpoint_auth_method: "none",
1908+
},
1909+
redirect_uri: "http://localhost:3030/callback",
1910+
redirect_handler: ->(authorization_url) {
1911+
# Send the user to the authorization URL - typically `Launchy.open(authorization_url)`
1912+
# or a manual `puts authorization_url` in CLI tools.
1913+
},
1914+
callback_handler: -> {
1915+
# Capture the redirect (for example, by running a small HTTP listener on
1916+
# `redirect_uri`) and return [code, state] from the query string.
1917+
},
1918+
)
1919+
1920+
transport = MCP::Client::HTTP.new(
1921+
url: "https://api.example.com/mcp",
1922+
oauth: provider,
1923+
)
1924+
client = MCP::Client.new(transport: transport)
1925+
client.connect # `initialize` is sent here; if the server replies 401 the OAuth flow runs and the handshake is retried with the acquired token
1926+
client.tools
1927+
```
1928+
1929+
Required keyword arguments to `Provider.new`:
1930+
1931+
- `client_metadata`: Hash sent to the authorization server's Dynamic Client Registration endpoint. Must include `redirect_uris`, `grant_types`, `response_types`,
1932+
`token_endpoint_auth_method`. `redirect_uri` (below) must appear in this list, otherwise the constructor raises `Provider::UnregisteredRedirectURIError`.
1933+
- `redirect_uri`: String. Must use HTTPS or be a loopback URL (`localhost`, `127.0.0.0/8`, `::1`); other values raise `Provider::InsecureRedirectURIError`.
1934+
- `redirect_handler`: Callable invoked with the fully-built authorization `URI`. Typically opens the user's browser.
1935+
- `callback_handler`: Callable that returns `[code, state]` after the user is redirected back to `redirect_uri`.
1936+
1937+
Optional keyword arguments:
1938+
1939+
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
1940+
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
1941+
which keeps credentials in process memory only.
1942+
1943+
To persist credentials across restarts, supply your own storage:
1944+
1945+
```ruby
1946+
class FileTokenStorage
1947+
def initialize(path)
1948+
@path = path
1949+
end
1950+
1951+
def tokens
1952+
read["tokens"]
1953+
end
1954+
1955+
def save_tokens(value)
1956+
write("tokens" => value)
1957+
end
1958+
1959+
def client_information
1960+
read["client"]
1961+
end
1962+
1963+
def save_client_information(value)
1964+
write("client" => value)
1965+
end
1966+
1967+
private
1968+
1969+
def read
1970+
File.exist?(@path) ? JSON.parse(File.read(@path)) : {}
1971+
end
1972+
1973+
def write(updates)
1974+
File.write(@path, JSON.dump(read.merge(updates)))
1975+
end
1976+
end
1977+
1978+
provider = MCP::Client::OAuth::Provider.new(
1979+
# ... required keywords ...
1980+
storage: FileTokenStorage.new(File.expand_path("~/.config/my-app/oauth.json")),
1981+
)
1982+
```
1983+
1984+
##### Communication Security
1985+
1986+
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
1987+
`redirect_uri`) must use HTTPS or a loopback host. Non-loopback `http://` URLs are rejected at the SDK boundary so a bearer token is never sent over plain HTTP to a remote host.
1988+
1989+
The transport also snapshots the canonicalized origin, path, and query string of the MCP URL at `initialize` time and re-checks them on every outgoing request through
1990+
a Faraday middleware that runs after any user-supplied customizer. That means any URL swap raises `MCP::Client::HTTP::InsecureURLError` before the request reaches the adapter,
1991+
whether the swap was triggered by
1992+
`instance_variable_set(:@url, ...)`, by a Faraday customizer rewriting `url_prefix`, or by a custom middleware rewriting `env.url` (including just `env.url.query`) at request time,
1993+
and whether the new URL is `http://` *or* `https://` to a different host or tenant.
1994+
18881995
#### Customizing the Faraday Connection
18891996

18901997
You can pass a block to `MCP::Client::HTTP.new` to customize the underlying Faraday connection.

conformance/client.rb

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
# The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable,
99
# which is set automatically by the conformance test runner.
1010

11+
require "faraday"
12+
require "json"
1113
require_relative "../lib/mcp"
1214

1315
scenario = ENV["MCP_CONFORMANCE_SCENARIO"]
@@ -17,7 +19,82 @@
1719
abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>")
1820
end
1921

20-
transport = MCP::Client::HTTP.new(url: server_url)
22+
# The conformance harness optionally injects scenario-specific data via
23+
# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is
24+
# defined by the harness, not the MCP spec, and has varied between versions:
25+
#
26+
# - Newer (`@modelcontextprotocol/conformance` >= 0.x): scenario fields are
27+
# spread at the top level alongside `name`, e.g.
28+
# `{"name":"auth/pre-registration","client_id":"...","client_secret":"..."}`.
29+
# - Older: a nested `context` object: `{"name":"...","context":{...}}`.
30+
#
31+
# Both shapes are accepted so the client conforms to whichever harness version
32+
# the developer has on hand.
33+
def conformance_context
34+
raw = ENV["MCP_CONFORMANCE_CONTEXT"]
35+
return {} if raw.nil? || raw.empty?
36+
37+
parsed = JSON.parse(raw)
38+
return {} unless parsed.is_a?(Hash)
39+
40+
if parsed["context"].is_a?(Hash)
41+
parsed["context"]
42+
else
43+
parsed.reject { |key, _| key == "name" }
44+
end
45+
rescue JSON::ParserError
46+
{}
47+
end
48+
49+
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
50+
# non-interactively against the conformance test's auth server. The conformance
51+
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
52+
# `code=test-auth-code`, so we follow it manually instead of opening a browser.
53+
def build_oauth_provider(context)
54+
callback_holder = {}
55+
redirect_uri = "http://localhost:0/callback"
56+
57+
redirect_handler = ->(authorization_url) do
58+
response = Faraday.new.get(authorization_url) do |req|
59+
req.options.params_encoder = nil
60+
end
61+
location = response.headers["location"] || response.headers["Location"]
62+
abort("Authorization request did not redirect: #{response.status}.") unless location
63+
64+
callback_holder[:url] = URI.parse(location)
65+
end
66+
67+
callback_handler = -> do
68+
query = URI.decode_www_form(callback_holder.fetch(:url).query).to_h
69+
[query["code"], query["state"]]
70+
end
71+
72+
storage = MCP::Client::OAuth::InMemoryStorage.new
73+
if context["client_id"]
74+
storage.save_client_information(
75+
"client_id" => context["client_id"],
76+
"client_secret" => context["client_secret"],
77+
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
78+
)
79+
end
80+
81+
MCP::Client::OAuth::Provider.new(
82+
client_metadata: {
83+
client_name: "ruby-sdk-conformance-client",
84+
redirect_uris: [redirect_uri],
85+
grant_types: ["authorization_code"],
86+
response_types: ["code"],
87+
token_endpoint_auth_method: "none",
88+
},
89+
redirect_uri: redirect_uri,
90+
redirect_handler: redirect_handler,
91+
callback_handler: callback_handler,
92+
storage: storage,
93+
)
94+
end
95+
96+
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context) : nil
97+
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
2198
client = MCP::Client.new(transport: transport)
2299
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
23100

@@ -29,6 +106,11 @@
29106
add_numbers = tools.find { |t| t.name == "add_numbers" }
30107
abort("Tool add_numbers not found") unless add_numbers
31108
client.call_tool(tool: add_numbers, arguments: { a: 1, b: 2 })
109+
when %r|\Aauth/|
110+
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
111+
# are observed by the conformance server during `connect` and the subsequent request below.
112+
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
113+
client.tools
32114
else
33115
abort("Unknown or unsupported scenario: #{scenario}")
34116
end

conformance/expected_failures.yml

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,12 @@ client:
44
- sse-retry
55
# TODO: Elicitation not implemented in Ruby client.
66
- elicitation-sep1034-client-defaults
7-
# TODO: OAuth/auth not implemented in Ruby client.
8-
- auth/metadata-default
9-
- auth/metadata-var1
10-
- auth/metadata-var2
11-
- auth/metadata-var3
7+
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
128
- auth/basic-cimd
13-
- auth/scope-from-www-authenticate
14-
- auth/scope-from-scopes-supported
15-
- auth/scope-omitted-when-undefined
169
- auth/scope-step-up
17-
- auth/scope-retry-limit
18-
- auth/token-endpoint-auth-basic
19-
- auth/token-endpoint-auth-post
20-
- auth/token-endpoint-auth-none
21-
- auth/pre-registration
2210
- auth/2025-03-26-oauth-metadata-backcompat
2311
- auth/2025-03-26-oauth-endpoint-fallback
2412
- auth/client-credentials-jwt
2513
- auth/client-credentials-basic
2614
- auth/cross-app-access-complete-flow
2715
- auth/offline-access-scope
28-
- auth/offline-access-not-supported

lib/mcp/client.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22

3+
require_relative "client/oauth"
34
require_relative "client/stdio"
45
require_relative "client/http"
56
require_relative "client/paginated_result"

0 commit comments

Comments
 (0)