Skip to content

Commit 355c904

Browse files
committed
Support Client ID Metadata Documents in OAuth client
## 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`.
1 parent c4c1f2b commit 355c904

9 files changed

Lines changed: 727 additions & 7 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1958,6 +1958,17 @@ Optional keyword arguments:
19581958
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
19591959
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
19601960
which keeps credentials in process memory only.
1961+
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
1962+
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
1963+
When the authorization server advertises `client_id_metadata_document_supported: true`,
1964+
the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
1965+
Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
1966+
userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
1967+
them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
1968+
Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
1969+
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
1970+
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
1971+
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
19611972

19621973
To persist credentials across restarts, supply your own storage:
19631974

conformance/client.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
abort("Usage: MCP_CONFORMANCE_SCENARIO=<scenario> ruby conformance/client.rb <server-url>")
2020
end
2121

22+
# URL the conformance harness expects to see as the OAuth `client_id` for the `auth/basic-cimd` scenario
23+
# when the AS advertises `client_id_metadata_document_supported`. The harness does not fetch the document,
24+
# only matches the value, so the URL does not need to resolve.
25+
CONFORMANCE_CIMD_URL = "https://conformance-test.local/client-metadata.json"
26+
2227
# The conformance harness optionally injects scenario-specific data via
2328
# the `MCP_CONFORMANCE_CONTEXT` environment variable as a JSON document. The shape is
2429
# defined by the harness, not the MCP spec, and has varied between versions:
@@ -50,7 +55,7 @@ def conformance_context
5055
# non-interactively against the conformance test's auth server. The conformance
5156
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
5257
# `code=test-auth-code`, so we follow it manually instead of opening a browser.
53-
def build_oauth_provider(context)
58+
def build_oauth_provider(context, scenario:)
5459
callback_holder = {}
5560
redirect_uri = "http://localhost:0/callback"
5661

@@ -90,10 +95,11 @@ def build_oauth_provider(context)
9095
redirect_handler: redirect_handler,
9196
callback_handler: callback_handler,
9297
storage: storage,
98+
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
9399
)
94100
end
95101

96-
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context) : nil
102+
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil
97103
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
98104
client = MCP::Client.new(transport: transport)
99105
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })

conformance/expected_failures.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ client:
55
# TODO: Elicitation not implemented in Ruby client.
66
- elicitation-sep1034-client-defaults
77
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
8-
- auth/basic-cimd
98
- auth/scope-step-up
109
- auth/2025-03-26-oauth-metadata-backcompat
1110
- auth/2025-03-26-oauth-endpoint-fallback

lib/mcp/client/oauth/discovery.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,50 @@ def secure_url?(url)
183183
false
184184
end
185185

186+
# Returns true when `url` satisfies the structural requirements for
187+
# a Client ID Metadata Document URL per the MCP 2025-11-25
188+
# authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
189+
#
190+
# Spec-required:
191+
#
192+
# - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
193+
# the document URL is sent verbatim to the authorization server as the OAuth `client_id`
194+
# and travels off-loopback)
195+
# - host MUST be present
196+
# - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
197+
# not the origin
198+
# - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
199+
# would leak credentials into every `client_id` log line
200+
# - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
201+
# the same effective path do not produce different `client_id` strings
202+
#
203+
# SDK policy (stricter than the draft):
204+
#
205+
# - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
206+
# but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
207+
# `client_id` values for the same logical document.
208+
def client_id_metadata_document_url?(url)
209+
return false if url.nil? || url.to_s.empty?
210+
211+
uri = URI.parse(url.to_s)
212+
return false unless uri.scheme&.downcase == "https"
213+
return false if uri.host.nil? || uri.host.empty?
214+
return false unless uri.fragment.nil?
215+
return false unless uri.query.nil?
216+
return false if uri.respond_to?(:user) && (uri.user || uri.password)
217+
218+
path = uri.path.to_s
219+
return false if path.empty? || path == "/"
220+
221+
decoded = path.gsub(/%2[eE]/, ".")
222+
segments = decoded.split("/", -1)
223+
return false if segments.any? { |segment| segment == "." || segment == ".." }
224+
225+
true
226+
rescue URI::InvalidURIError
227+
false
228+
end
229+
186230
# Like `canonicalize_url` but also strips query string, fragment, and
187231
# userinfo. This variant is used for identity comparison against
188232
# the request URL Faraday actually sends, which differs from the value

lib/mcp/client/oauth/flow.rb

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,16 @@ def refresh!(server_url:, resource_metadata_url: nil)
104104
refresh_token = read_token("refresh_token")
105105
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
106106

107-
client_info = @provider.client_information
108-
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
107+
stored_client_info = @provider.client_information
108+
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
109+
110+
# A CIMD-configured provider stores no `client_information` on purpose
111+
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
112+
# Allow refresh to proceed in that case so the `refresh_token` obtained via
113+
# the CIMD flow remains usable.
114+
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
115+
116+
unless have_stored_client_info || have_cimd_url
109117
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
110118
end
111119

@@ -126,6 +134,18 @@ def refresh!(server_url:, resource_metadata_url: nil)
126134
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
127135
ensure_secure_endpoints!(as_metadata)
128136

137+
client_info = if have_stored_client_info
138+
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
139+
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
140+
stored_client_info
141+
elsif as_metadata["client_id_metadata_document_supported"] == true
142+
{ "client_id" => @provider.client_id_metadata_document_url }
143+
else
144+
raise AuthorizationError,
145+
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
146+
"`client_id_metadata_document_supported: true`."
147+
end
148+
129149
new_tokens = exchange_refresh_token(
130150
as_metadata: as_metadata,
131151
client_info: client_info,
@@ -285,6 +305,24 @@ def ensure_client_registered(as_metadata:)
285305
existing = @provider.client_information
286306
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
287307

308+
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
309+
# if the authorization server advertises Client ID Metadata Document support and the provider has
310+
# a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
311+
#
312+
# The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
313+
# A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
314+
# otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
315+
# a server that has not actually adopted it.
316+
#
317+
# The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
318+
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
319+
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
320+
# and the current `provider.client_id_metadata_document_url`.
321+
cimd_url = @provider.client_id_metadata_document_url
322+
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
323+
return { "client_id" => cimd_url }
324+
end
325+
288326
registration_endpoint = as_metadata["registration_endpoint"]
289327
unless registration_endpoint
290328
raise AuthorizationError,

lib/mcp/client/oauth/provider.rb

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ module OAuth
2525
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
2626
# `client_information`, and `save_client_information(info)`. Defaults to
2727
# an `InMemoryStorage`.
28+
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
29+
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
30+
# When the authorization server advertises `client_id_metadata_document_supported: true`,
31+
# the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
32+
# Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
33+
# The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
34+
# but different encodings of the same query would yield different `client_id` strings for the same document).
35+
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
36+
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
37+
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
2838
class Provider
2939
# Raised when `Provider#initialize` is called with a `redirect_uri` that
3040
# is neither HTTPS nor a loopback `http://` URL, per the MCP
@@ -38,20 +48,30 @@ class InsecureRedirectURIError < ArgumentError; end
3848
# runtime; failing at construction surfaces the bug earlier.
3949
class UnregisteredRedirectURIError < ArgumentError; end
4050

51+
# Raised when `client_id_metadata_document_url` is provided but does not meet
52+
# the structural requirements for a Client ID Metadata Document URL:
53+
# HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
54+
# The CIMD URL is sent to the authorization server as the OAuth `client_id`,
55+
# so the same Communication Security guarantee that protects the redirect URI
56+
# applies and the value must unambiguously identify the document.
57+
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
58+
4159
attr_reader :client_metadata,
4260
:redirect_uri,
4361
:scope,
4462
:storage,
4563
:redirect_handler,
46-
:callback_handler
64+
:callback_handler,
65+
:client_id_metadata_document_url
4766

4867
def initialize(
4968
client_metadata:,
5069
redirect_uri:,
5170
redirect_handler:,
5271
callback_handler:,
5372
scope: nil,
54-
storage: nil
73+
storage: nil,
74+
client_id_metadata_document_url: nil
5575
)
5676
unless Discovery.secure_url?(redirect_uri)
5777
raise InsecureRedirectURIError,
@@ -66,12 +86,20 @@ def initialize(
6686
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
6787
end
6888

89+
if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
90+
raise InvalidClientIDMetadataDocumentURLError,
91+
"client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
92+
"with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
93+
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
94+
end
95+
6996
@client_metadata = client_metadata
7097
@redirect_uri = redirect_uri
7198
@redirect_handler = redirect_handler
7299
@callback_handler = callback_handler
73100
@scope = scope
74101
@storage = storage || InMemoryStorage.new
102+
@client_id_metadata_document_url = client_id_metadata_document_url
75103
end
76104

77105
def access_token

test/mcp/client/oauth/discovery_test.rb

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,68 @@ def test_secure_url_rejects_malformed_or_hostless_urls
367367
refute(Discovery.secure_url?("not a url"))
368368
end
369369

370+
def test_client_id_metadata_document_url_accepts_https_with_path
371+
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json"))
372+
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm"))
373+
assert(Discovery.client_id_metadata_document_url?("https://app.example.com:8443/cm"))
374+
end
375+
376+
def test_client_id_metadata_document_url_accepts_boundary_forms
377+
# An uppercase scheme is `https` after case folding (URIs are scheme-insensitive, RFC 3986 Section 3.1).
378+
assert(Discovery.client_id_metadata_document_url?("HTTPS://app.example.com/cm"))
379+
380+
# A trailing slash and consecutive slashes are non-root, non-dot paths.
381+
# RFC 3986 `remove_dot_segments` does not collapse them, so they remain
382+
# stable `client_id` strings rather than aliasing another URL.
383+
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/"))
384+
assert(Discovery.client_id_metadata_document_url?("https://app.example.com//cm"))
385+
386+
# The default https port is equivalent to omitting it; either form is a valid origin.
387+
assert(Discovery.client_id_metadata_document_url?("https://app.example.com:443/cm"))
388+
389+
# IPv6 literal hosts and Punycode (IDN) hosts are ordinary hosts.
390+
assert(Discovery.client_id_metadata_document_url?("https://[2001:db8::1]/cm"))
391+
assert(Discovery.client_id_metadata_document_url?("https://[::1]/cm"))
392+
assert(Discovery.client_id_metadata_document_url?("https://xn--e1afmkfd.example.com/cm"))
393+
end
394+
395+
def test_client_id_metadata_document_url_rejects_non_https_schemes
396+
refute(Discovery.client_id_metadata_document_url?("http://localhost/cm"))
397+
refute(Discovery.client_id_metadata_document_url?("http://app.example.com/cm"))
398+
refute(Discovery.client_id_metadata_document_url?("ftp://app.example.com/cm"))
399+
refute(Discovery.client_id_metadata_document_url?("file:///cm"))
400+
end
401+
402+
def test_client_id_metadata_document_url_rejects_root_and_empty_path
403+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com"))
404+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/"))
405+
end
406+
407+
def test_client_id_metadata_document_url_rejects_fragment_query_userinfo
408+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm#frag"))
409+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm?x=1"))
410+
refute(Discovery.client_id_metadata_document_url?("https://user:pass@app.example.com/cm"))
411+
refute(Discovery.client_id_metadata_document_url?("https://user@app.example.com/cm"))
412+
end
413+
414+
def test_client_id_metadata_document_url_rejects_dot_segments
415+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/./cm"))
416+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/a/../cm"))
417+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/."))
418+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/.."))
419+
# Percent-encoded `.` would otherwise let `%2E` and `.` produce
420+
# different `client_id` values for the same document.
421+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2E/cm"))
422+
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2e/cm"))
423+
end
424+
425+
def test_client_id_metadata_document_url_rejects_nil_or_empty_or_malformed
426+
refute(Discovery.client_id_metadata_document_url?(nil))
427+
refute(Discovery.client_id_metadata_document_url?(""))
428+
refute(Discovery.client_id_metadata_document_url?("not a url"))
429+
refute(Discovery.client_id_metadata_document_url?("https://"))
430+
end
431+
370432
def test_resource_covers_blocks_percent_encoded_dot_segment_bypass
371433
server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp")
372434
prm = Discovery.canonicalize_url("https://srv.example.com/api")

0 commit comments

Comments
 (0)