mcp 0.18.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +131 -3
- data/lib/json_rpc_handler.rb +7 -2
- data/lib/mcp/annotations.rb +2 -0
- data/lib/mcp/client/http.rb +68 -7
- data/lib/mcp/client/oauth/client_credentials_provider.rb +89 -0
- data/lib/mcp/client/oauth/discovery.rb +86 -1
- data/lib/mcp/client/oauth/flow.rb +241 -25
- data/lib/mcp/client/oauth/provider.rb +45 -27
- data/lib/mcp/client/oauth/storage_backed_provider.rb +43 -0
- data/lib/mcp/client/oauth.rb +3 -1
- data/lib/mcp/client.rb +74 -80
- data/lib/mcp/configuration.rb +1 -0
- data/lib/mcp/resource.rb +6 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/capabilities.rb +14 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +78 -50
- data/lib/mcp/server.rb +38 -2
- data/lib/mcp/tool/input_schema.rb +2 -2
- data/lib/mcp/tool/schema.rb +38 -19
- data/lib/mcp/trace_context.rb +23 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -5
|
@@ -38,27 +38,29 @@ module MCP
|
|
|
38
38
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
prm =
|
|
41
|
+
prm, authorization_server = locate_authorization_server(
|
|
42
42
|
server_url: server_url,
|
|
43
43
|
resource_metadata_url: resource_metadata_url,
|
|
44
44
|
)
|
|
45
|
-
authorization_server = first_authorization_server(prm)
|
|
46
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
47
45
|
|
|
48
46
|
# Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
|
|
49
47
|
# both the authorization and token requests. When PRM advertises a `resource`,
|
|
50
48
|
# it MUST identify the same MCP server we are talking to; otherwise we are
|
|
51
49
|
# being redirected to credentials minted for a different audience.
|
|
52
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
50
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
51
|
+
|
|
52
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
53
|
+
|
|
54
|
+
if provider_authorization_flow == :client_credentials
|
|
55
|
+
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
56
|
+
end
|
|
53
57
|
|
|
54
|
-
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
55
|
-
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
56
|
-
ensure_secure_endpoints!(as_metadata)
|
|
57
58
|
ensure_pkce_supported!(as_metadata)
|
|
58
59
|
|
|
59
60
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
60
61
|
|
|
61
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
62
|
+
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
|
63
|
+
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
62
64
|
pkce = PKCE.generate
|
|
63
65
|
state = SecureRandom.urlsafe_base64(32)
|
|
64
66
|
|
|
@@ -91,21 +93,60 @@ module MCP
|
|
|
91
93
|
:authorized
|
|
92
94
|
end
|
|
93
95
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# token endpoint
|
|
97
|
-
#
|
|
96
|
+
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
|
97
|
+
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
|
98
|
+
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
|
99
|
+
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
|
|
100
|
+
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
|
101
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
102
|
+
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
|
103
|
+
client_info = client_credentials_client_info
|
|
104
|
+
|
|
105
|
+
form = { "grant_type" => "client_credentials" }
|
|
106
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
107
|
+
form["scope"] = effective_scope if effective_scope
|
|
108
|
+
form["resource"] = resource if resource
|
|
109
|
+
|
|
110
|
+
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
111
|
+
@provider.save_tokens(tokens)
|
|
112
|
+
:authorized
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
|
|
116
|
+
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
|
|
117
|
+
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
|
|
118
|
+
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
|
|
119
|
+
# to dynamic registration.
|
|
120
|
+
def client_credentials_client_info
|
|
121
|
+
info = @provider.client_information
|
|
122
|
+
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
|
|
123
|
+
raise AuthorizationError,
|
|
124
|
+
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
info
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
|
|
131
|
+
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
|
|
132
|
+
# checks before talking to it.
|
|
98
133
|
#
|
|
99
|
-
# Returns `:refreshed` on success. Raises `AuthorizationError` when
|
|
100
|
-
#
|
|
101
|
-
# the token endpoint refuses the refresh request.
|
|
134
|
+
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
|
135
|
+
# or when the token endpoint refuses the refresh request.
|
|
102
136
|
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
|
103
137
|
def refresh!(server_url:, resource_metadata_url: nil)
|
|
104
138
|
refresh_token = read_token("refresh_token")
|
|
105
139
|
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
|
106
140
|
|
|
107
|
-
|
|
108
|
-
|
|
141
|
+
stored_client_info = @provider.client_information
|
|
142
|
+
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
|
|
143
|
+
|
|
144
|
+
# A CIMD-configured provider stores no `client_information` on purpose
|
|
145
|
+
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
|
146
|
+
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
|
147
|
+
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
|
148
|
+
|
|
149
|
+
unless have_stored_client_info || have_cimd_url
|
|
109
150
|
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
|
110
151
|
end
|
|
111
152
|
|
|
@@ -113,18 +154,26 @@ module MCP
|
|
|
113
154
|
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
|
|
114
155
|
end
|
|
115
156
|
|
|
116
|
-
prm =
|
|
157
|
+
prm, authorization_server = locate_authorization_server(
|
|
117
158
|
server_url: server_url,
|
|
118
159
|
resource_metadata_url: resource_metadata_url,
|
|
119
160
|
)
|
|
120
|
-
authorization_server = first_authorization_server(prm)
|
|
121
|
-
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
122
161
|
|
|
123
|
-
resource = canonical_resource(server_url: server_url, prm_resource: prm
|
|
162
|
+
resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
|
|
124
163
|
|
|
125
|
-
as_metadata =
|
|
126
|
-
|
|
127
|
-
|
|
164
|
+
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
165
|
+
|
|
166
|
+
client_info = if have_stored_client_info
|
|
167
|
+
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
168
|
+
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
|
|
169
|
+
stored_client_info
|
|
170
|
+
elsif as_metadata["client_id_metadata_document_supported"] == true
|
|
171
|
+
{ "client_id" => @provider.client_id_metadata_document_url }
|
|
172
|
+
else
|
|
173
|
+
raise AuthorizationError,
|
|
174
|
+
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
|
|
175
|
+
"`client_id_metadata_document_supported: true`."
|
|
176
|
+
end
|
|
128
177
|
|
|
129
178
|
new_tokens = exchange_refresh_token(
|
|
130
179
|
as_metadata: as_metadata,
|
|
@@ -164,6 +213,87 @@ module MCP
|
|
|
164
213
|
fetch_metadata_json(urls, label: "protected resource metadata")
|
|
165
214
|
end
|
|
166
215
|
|
|
216
|
+
# Locates the authorization server for `server_url` and returns `[prm, authorization_server]`.
|
|
217
|
+
#
|
|
218
|
+
# Modern path (2025-06-18+): Protected Resource Metadata names the authorization server in
|
|
219
|
+
# `authorization_servers`.
|
|
220
|
+
#
|
|
221
|
+
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
|
222
|
+
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
|
223
|
+
# Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
|
|
224
|
+
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
|
225
|
+
def locate_authorization_server(server_url:, resource_metadata_url:)
|
|
226
|
+
prm = begin
|
|
227
|
+
fetch_protected_resource_metadata(
|
|
228
|
+
server_url: server_url,
|
|
229
|
+
resource_metadata_url: resource_metadata_url,
|
|
230
|
+
)
|
|
231
|
+
rescue AuthorizationError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if prm
|
|
236
|
+
authorization_server = first_authorization_server(prm)
|
|
237
|
+
ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
|
|
238
|
+
[prm, authorization_server]
|
|
239
|
+
else
|
|
240
|
+
authorization_base = server_origin!(server_url)
|
|
241
|
+
ensure_secure_url!(authorization_base, label: "MCP server origin (legacy authorization base URL)")
|
|
242
|
+
[nil, authorization_base]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Fetches and validates the authorization server's RFC 8414 metadata.
|
|
247
|
+
#
|
|
248
|
+
# On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
|
|
249
|
+
# On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
|
|
250
|
+
# and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
|
|
251
|
+
# the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
|
|
252
|
+
# When even the metadata document is absent, the legacy spec's default endpoints are used.
|
|
253
|
+
def authorization_server_metadata(authorization_server:, legacy:)
|
|
254
|
+
metadata = if legacy
|
|
255
|
+
begin
|
|
256
|
+
fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
257
|
+
rescue AuthorizationError
|
|
258
|
+
default_legacy_metadata(authorization_server)
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
fetch_authorization_server_metadata(issuer_url: authorization_server).tap do |fetched|
|
|
262
|
+
ensure_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
ensure_secure_endpoints!(metadata)
|
|
267
|
+
metadata
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The 2025-03-26 spec's "Fallbacks for Servers without Metadata Discovery": clients MUST use these default endpoint paths
|
|
271
|
+
# relative to the authorization base URL. PKCE S256 is assumed because the legacy spec mandates PKCE and there is no metadata
|
|
272
|
+
# to advertise it (the TypeScript and Python SDKs hardcode S256 on this path too).
|
|
273
|
+
def default_legacy_metadata(authorization_base)
|
|
274
|
+
{
|
|
275
|
+
"issuer" => authorization_base,
|
|
276
|
+
"authorization_endpoint" => "#{authorization_base}/authorize",
|
|
277
|
+
"token_endpoint" => "#{authorization_base}/token",
|
|
278
|
+
"registration_endpoint" => "#{authorization_base}/register",
|
|
279
|
+
"code_challenge_methods_supported" => ["S256"],
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Returns `scheme://host[:port]` of `server_url`, the legacy 2025-03-26 authorization base URL for servers without PRM.
|
|
284
|
+
def server_origin!(server_url)
|
|
285
|
+
uri = URI.parse(server_url.to_s)
|
|
286
|
+
unless uri.is_a?(URI::HTTP) && uri.host
|
|
287
|
+
raise AuthorizationError,
|
|
288
|
+
"Cannot derive a legacy authorization base URL from MCP server URL #{server_url.inspect}."
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
port_part = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
292
|
+
"#{uri.scheme}://#{uri.host}#{port_part}"
|
|
293
|
+
rescue URI::InvalidURIError => e
|
|
294
|
+
raise AuthorizationError, "MCP server URL #{server_url.inspect} is not a valid URI: #{e.message}."
|
|
295
|
+
end
|
|
296
|
+
|
|
167
297
|
def fetch_authorization_server_metadata(issuer_url:)
|
|
168
298
|
urls = Discovery.authorization_server_metadata_urls(issuer_url)
|
|
169
299
|
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
@@ -285,6 +415,24 @@ module MCP
|
|
|
285
415
|
existing = @provider.client_information
|
|
286
416
|
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
|
287
417
|
|
|
418
|
+
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
|
|
419
|
+
# if the authorization server advertises Client ID Metadata Document support and the provider has
|
|
420
|
+
# a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
|
|
421
|
+
#
|
|
422
|
+
# The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
|
|
423
|
+
# A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
|
|
424
|
+
# otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
|
|
425
|
+
# a server that has not actually adopted it.
|
|
426
|
+
#
|
|
427
|
+
# The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
|
|
428
|
+
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
|
|
429
|
+
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
|
|
430
|
+
# and the current `provider.client_id_metadata_document_url`.
|
|
431
|
+
cimd_url = @provider.client_id_metadata_document_url
|
|
432
|
+
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
433
|
+
return { "client_id" => cimd_url }
|
|
434
|
+
end
|
|
435
|
+
|
|
288
436
|
registration_endpoint = as_metadata["registration_endpoint"]
|
|
289
437
|
unless registration_endpoint
|
|
290
438
|
raise AuthorizationError,
|
|
@@ -292,7 +440,7 @@ module MCP
|
|
|
292
440
|
end
|
|
293
441
|
|
|
294
442
|
response = begin
|
|
295
|
-
http_post_json(registration_endpoint,
|
|
443
|
+
http_post_json(registration_endpoint, registration_client_metadata)
|
|
296
444
|
rescue Faraday::Error => e
|
|
297
445
|
raise AuthorizationError,
|
|
298
446
|
"Dynamic client registration failed: #{e.class}: #{e.message}."
|
|
@@ -318,6 +466,20 @@ module MCP
|
|
|
318
466
|
info
|
|
319
467
|
end
|
|
320
468
|
|
|
469
|
+
# Returns the client metadata to submit on Dynamic Client Registration.
|
|
470
|
+
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
|
|
471
|
+
# so the authorization server can apply the matching redirect URI policy.
|
|
472
|
+
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
|
|
473
|
+
# the registered `redirect_uris`; an explicit value always wins.
|
|
474
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
|
|
475
|
+
def registration_client_metadata
|
|
476
|
+
metadata = @provider.client_metadata
|
|
477
|
+
return metadata if metadata[:application_type] || metadata["application_type"]
|
|
478
|
+
|
|
479
|
+
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
|
|
480
|
+
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
|
|
481
|
+
end
|
|
482
|
+
|
|
321
483
|
# Reads `key` from a `client_information` hash that may use either string or
|
|
322
484
|
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
|
323
485
|
# `{ client_id:, client_secret: }` and have both work.
|
|
@@ -403,6 +565,60 @@ module MCP
|
|
|
403
565
|
nil
|
|
404
566
|
end
|
|
405
567
|
|
|
568
|
+
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
|
|
569
|
+
#
|
|
570
|
+
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
|
|
571
|
+
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
|
|
572
|
+
# when the authorization server advertises it in metadata `scopes_supported`.
|
|
573
|
+
# When the server advertises it and the client opted in, add it if absent.
|
|
574
|
+
#
|
|
575
|
+
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
|
|
576
|
+
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
|
|
577
|
+
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
|
|
578
|
+
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
|
|
579
|
+
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
|
|
580
|
+
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
|
|
581
|
+
# own request consistent with the AS's advertisement.
|
|
582
|
+
#
|
|
583
|
+
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
|
|
584
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
|
|
585
|
+
def normalize_offline_access_scope(scope, as_metadata:)
|
|
586
|
+
scopes = scope.to_s.split
|
|
587
|
+
|
|
588
|
+
if server_supports_offline_access?(as_metadata)
|
|
589
|
+
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
|
|
590
|
+
else
|
|
591
|
+
scopes.delete("offline_access")
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
scopes.empty? ? nil : scopes.join(" ")
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def server_supports_offline_access?(as_metadata)
|
|
598
|
+
supported = as_metadata["scopes_supported"]
|
|
599
|
+
|
|
600
|
+
supported.is_a?(Array) && supported.include?("offline_access")
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def wants_refresh_token?
|
|
604
|
+
metadata = @provider.client_metadata
|
|
605
|
+
grant_types = metadata[:grant_types] || metadata["grant_types"]
|
|
606
|
+
|
|
607
|
+
Array(grant_types).include?("refresh_token")
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# The OAuth flow the provider drives. Dispatching on the provider's
|
|
611
|
+
# declared flow keeps `Flow` from second-guessing intent by parsing
|
|
612
|
+
# `client_metadata[:grant_types]` (which is protocol metadata for the
|
|
613
|
+
# authorization server, not an SDK control signal). A provider that
|
|
614
|
+
# predates this method is treated as the interactive authorization-code
|
|
615
|
+
# flow it was the only option for.
|
|
616
|
+
def provider_authorization_flow
|
|
617
|
+
return :authorization_code unless @provider.respond_to?(:authorization_flow)
|
|
618
|
+
|
|
619
|
+
@provider.authorization_flow
|
|
620
|
+
end
|
|
621
|
+
|
|
406
622
|
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
407
623
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
408
624
|
unless authorization_endpoint
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
module MCP
|
|
4
4
|
class Client
|
|
5
5
|
module OAuth
|
|
6
|
-
# Pluggable OAuth client configuration
|
|
7
|
-
# the `oauth:` keyword.
|
|
8
|
-
#
|
|
6
|
+
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
|
|
7
|
+
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
|
|
8
|
+
# Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
|
|
9
|
+
# in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
|
|
10
|
+
# use `ClientCredentialsProvider` instead.
|
|
9
11
|
#
|
|
10
12
|
# Required keyword arguments:
|
|
11
13
|
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
|
12
14
|
# Registration endpoint. Must include at minimum `redirect_uris`,
|
|
13
15
|
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
|
16
|
+
# When `application_type` is omitted, the SDK infers `"native"` or `"web"`
|
|
17
|
+
# from `redirect_uris` per SEP-837 before registering; an explicit value
|
|
18
|
+
# always wins.
|
|
14
19
|
# - `redirect_uri` - String: the redirect URI used for the authorization
|
|
15
20
|
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
16
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
@@ -25,7 +30,19 @@ module MCP
|
|
|
25
30
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
26
31
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
27
32
|
# an `InMemoryStorage`.
|
|
33
|
+
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
|
34
|
+
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
|
35
|
+
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
36
|
+
# the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
|
|
37
|
+
# Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
|
|
38
|
+
# The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
|
|
39
|
+
# but different encodings of the same query would yield different `client_id` strings for the same document).
|
|
40
|
+
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
|
41
|
+
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
42
|
+
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
28
43
|
class Provider
|
|
44
|
+
include StorageBackedProvider
|
|
45
|
+
|
|
29
46
|
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
|
30
47
|
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
|
31
48
|
# authorization spec's Communication Security requirement.
|
|
@@ -38,12 +55,21 @@ module MCP
|
|
|
38
55
|
# runtime; failing at construction surfaces the bug earlier.
|
|
39
56
|
class UnregisteredRedirectURIError < ArgumentError; end
|
|
40
57
|
|
|
58
|
+
# Raised when `client_id_metadata_document_url` is provided but does not meet
|
|
59
|
+
# the structural requirements for a Client ID Metadata Document URL:
|
|
60
|
+
# HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
|
|
61
|
+
# The CIMD URL is sent to the authorization server as the OAuth `client_id`,
|
|
62
|
+
# so the same Communication Security guarantee that protects the redirect URI
|
|
63
|
+
# applies and the value must unambiguously identify the document.
|
|
64
|
+
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
|
|
65
|
+
|
|
41
66
|
attr_reader :client_metadata,
|
|
42
67
|
:redirect_uri,
|
|
43
68
|
:scope,
|
|
44
69
|
:storage,
|
|
45
70
|
:redirect_handler,
|
|
46
|
-
:callback_handler
|
|
71
|
+
:callback_handler,
|
|
72
|
+
:client_id_metadata_document_url
|
|
47
73
|
|
|
48
74
|
def initialize(
|
|
49
75
|
client_metadata:,
|
|
@@ -51,7 +77,8 @@ module MCP
|
|
|
51
77
|
redirect_handler:,
|
|
52
78
|
callback_handler:,
|
|
53
79
|
scope: nil,
|
|
54
|
-
storage: nil
|
|
80
|
+
storage: nil,
|
|
81
|
+
client_id_metadata_document_url: nil
|
|
55
82
|
)
|
|
56
83
|
unless Discovery.secure_url?(redirect_uri)
|
|
57
84
|
raise InsecureRedirectURIError,
|
|
@@ -66,36 +93,27 @@ module MCP
|
|
|
66
93
|
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
|
|
67
94
|
end
|
|
68
95
|
|
|
96
|
+
if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
|
|
97
|
+
raise InvalidClientIDMetadataDocumentURLError,
|
|
98
|
+
"client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
|
|
99
|
+
"with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
|
|
100
|
+
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
101
|
+
end
|
|
102
|
+
|
|
69
103
|
@client_metadata = client_metadata
|
|
70
104
|
@redirect_uri = redirect_uri
|
|
71
105
|
@redirect_handler = redirect_handler
|
|
72
106
|
@callback_handler = callback_handler
|
|
73
107
|
@scope = scope
|
|
74
108
|
@storage = storage || InMemoryStorage.new
|
|
109
|
+
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
75
110
|
end
|
|
76
111
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@storage.tokens
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def save_tokens(tokens)
|
|
86
|
-
@storage.save_tokens(tokens)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def client_information
|
|
90
|
-
@storage.client_information
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def save_client_information(info)
|
|
94
|
-
@storage.save_client_information(info)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def clear_tokens!
|
|
98
|
-
@storage.save_tokens(nil)
|
|
112
|
+
# Identifies the OAuth flow this provider drives.
|
|
113
|
+
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
|
|
114
|
+
# which is protocol metadata for the authorization server, not an SDK control signal.
|
|
115
|
+
def authorization_flow
|
|
116
|
+
:authorization_code
|
|
99
117
|
end
|
|
100
118
|
end
|
|
101
119
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# Shared token/credential persistence for the OAuth provider classes
|
|
7
|
+
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
|
|
8
|
+
# for the client_credentials flow). The two grants differ in how they authenticate,
|
|
9
|
+
# but both read and write the same two pieces of state through a `storage` object:
|
|
10
|
+
# the token response and the client information. This module supplies that delegation
|
|
11
|
+
# so the `Flow` orchestrator can treat any provider uniformly.
|
|
12
|
+
#
|
|
13
|
+
# Including classes must set `@storage` to an object responding to `tokens`,
|
|
14
|
+
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
|
15
|
+
# (see `InMemoryStorage`).
|
|
16
|
+
module StorageBackedProvider
|
|
17
|
+
def access_token
|
|
18
|
+
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tokens
|
|
22
|
+
@storage.tokens
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def save_tokens(tokens)
|
|
26
|
+
@storage.save_tokens(tokens)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def client_information
|
|
30
|
+
@storage.client_information
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def save_client_information(info)
|
|
34
|
+
@storage.save_client_information(info)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def clear_tokens!
|
|
38
|
+
@storage.save_tokens(nil)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/mcp/client/oauth.rb
CHANGED
|
@@ -4,13 +4,15 @@ require_relative "oauth/discovery"
|
|
|
4
4
|
require_relative "oauth/flow"
|
|
5
5
|
require_relative "oauth/in_memory_storage"
|
|
6
6
|
require_relative "oauth/pkce"
|
|
7
|
+
require_relative "oauth/storage_backed_provider"
|
|
7
8
|
require_relative "oauth/provider"
|
|
9
|
+
require_relative "oauth/client_credentials_provider"
|
|
8
10
|
|
|
9
11
|
module MCP
|
|
10
12
|
class Client
|
|
11
13
|
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
|
12
14
|
# Authorization Server metadata discovery, Dynamic Client Registration,
|
|
13
|
-
# OAuth 2.1 Authorization Code + PKCE).
|
|
15
|
+
# OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
|
|
14
16
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
15
17
|
module OAuth
|
|
16
18
|
end
|