Skip to content

Commit 2832bc0

Browse files
authored
Merge pull request #353 from koic/add_oauth_2_1_client_support_for_mcp_authorization_flow
Add OAuth 2.1 client support for MCP authorization flow
2 parents 7db22f4 + 5766069 commit 2832bc0

16 files changed

Lines changed: 4600 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)