mcp 1.5.1 → 1.6.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 +1 -1
- data/lib/mcp/client/oauth/bounded_body.rb +1 -1
- data/lib/mcp/client/oauth/client_credentials_provider.rb +18 -6
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +20 -1
- data/lib/mcp/client/oauth/discovery.rb +23 -0
- data/lib/mcp/client/oauth/flow.rb +358 -91
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +1 -1
- data/lib/mcp/client/oauth/provider.rb +14 -1
- data/lib/mcp/client/oauth/storage_backed_provider.rb +42 -5
- data/lib/mcp/icon.rb +35 -2
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cb0f742e469efeb15202b40310bfb2eab52b94bb208bab1f33cfd2204bcf8c9
|
|
4
|
+
data.tar.gz: 07df380a1dd652b6fd5deb0ddbafe3c3379bc6fa77c8a391f169f6a7fed11742
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c756d7552fbf3accc8eedc32876e715c1d34939ed8c44d9d540a97b46ccf4cc2d3a163f5ba5989277be6e8933bce374ccab80f27ddf62ed52f6ea27c0367a3c5
|
|
7
|
+
data.tar.gz: '0873ea9933d9b30e1c0e2bcbd6f56eb0c658f16736ae309007cd752b097cb9f75ebcead7277c89ea9f255f92f3f62d2d4f1ac5d4ae87cf36359df4e42e030d38'
|
data/README.md
CHANGED
|
@@ -101,7 +101,7 @@ stdio_transport = MCP::Client::Stdio.new(
|
|
|
101
101
|
)
|
|
102
102
|
client = MCP::Client.new(transport: stdio_transport)
|
|
103
103
|
|
|
104
|
-
#
|
|
104
|
+
# Negotiate the protocol lifecycle before sending any requests.
|
|
105
105
|
client.connect
|
|
106
106
|
|
|
107
107
|
# List available tools.
|
|
@@ -26,7 +26,7 @@ module MCP
|
|
|
26
26
|
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
|
|
27
27
|
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
|
|
28
28
|
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
|
|
29
|
-
# the connection leaves `Accept-Encoding` to the adapter; see `Flow
|
|
29
|
+
# the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
|
|
30
30
|
def on_data
|
|
31
31
|
proc do |chunk, _received_bytes, _env|
|
|
32
32
|
@buffer << chunk
|
|
@@ -37,11 +37,17 @@ module MCP
|
|
|
37
37
|
# also take the algorithm as an explicit option).
|
|
38
38
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
39
39
|
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
|
|
40
|
-
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
40
|
+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
|
|
41
|
+
# and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
|
|
42
|
+
# The `client_id` / `client_secret` are written into it so the token exchange reads
|
|
43
|
+
# them through the same path as a pre-registered authorization-code client.
|
|
44
|
+
# - `token_request_params` - Hash of String keys and values added to every
|
|
45
|
+
# token request this provider makes, for parameters the authorization
|
|
46
|
+
# server requires beyond the grant itself (Auth0's `audience`, for example).
|
|
47
|
+
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
|
|
48
|
+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
|
49
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
|
50
|
+
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
|
45
51
|
class ClientCredentialsProvider
|
|
46
52
|
include StorageBackedProvider
|
|
47
53
|
|
|
@@ -61,7 +67,9 @@ module MCP
|
|
|
61
67
|
signing_algorithm: nil,
|
|
62
68
|
scope: nil,
|
|
63
69
|
storage: nil,
|
|
64
|
-
authorization_request_validator: nil
|
|
70
|
+
authorization_request_validator: nil,
|
|
71
|
+
token_request_params: nil,
|
|
72
|
+
http_client_customizer: nil
|
|
65
73
|
)
|
|
66
74
|
if blank?(client_id)
|
|
67
75
|
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
|
@@ -99,12 +107,16 @@ module MCP
|
|
|
99
107
|
client_information["client_secret"] = client_secret
|
|
100
108
|
end
|
|
101
109
|
|
|
110
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
111
|
+
|
|
102
112
|
@client_id = client_id
|
|
103
113
|
@private_key = private_key
|
|
104
114
|
@signing_algorithm = signing_algorithm
|
|
105
115
|
@scope = scope
|
|
106
116
|
@storage = storage || InMemoryStorage.new
|
|
107
117
|
@authorization_request_validator = authorization_request_validator
|
|
118
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
119
|
+
@http_client_customizer = http_client_customizer
|
|
108
120
|
@storage.save_client_information(client_information)
|
|
109
121
|
end
|
|
110
122
|
|
|
@@ -25,6 +25,12 @@ module MCP
|
|
|
25
25
|
# the Protected Resource Metadata do not specify one.
|
|
26
26
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
|
|
27
27
|
# Defaults to an `InMemoryStorage`.
|
|
28
|
+
# - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
|
|
29
|
+
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
|
|
30
|
+
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
|
|
31
|
+
# See `StorageBackedProvider#token_request_params`.
|
|
32
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests;
|
|
33
|
+
# see `StorageBackedProvider#http_client_customizer`.
|
|
28
34
|
#
|
|
29
35
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
30
36
|
class CrossAppAccessProvider
|
|
@@ -35,7 +41,16 @@ module MCP
|
|
|
35
41
|
|
|
36
42
|
attr_reader :scope, :storage
|
|
37
43
|
|
|
38
|
-
def initialize(
|
|
44
|
+
def initialize(
|
|
45
|
+
client_id:,
|
|
46
|
+
client_secret:,
|
|
47
|
+
assertion_provider:,
|
|
48
|
+
scope: nil,
|
|
49
|
+
storage: nil,
|
|
50
|
+
authorization_request_validator: nil,
|
|
51
|
+
token_request_params: nil,
|
|
52
|
+
http_client_customizer: nil
|
|
53
|
+
)
|
|
39
54
|
if blank?(client_id)
|
|
40
55
|
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
|
41
56
|
end
|
|
@@ -48,10 +63,14 @@ module MCP
|
|
|
48
63
|
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
|
|
49
64
|
end
|
|
50
65
|
|
|
66
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
67
|
+
|
|
51
68
|
@assertion_provider = assertion_provider
|
|
52
69
|
@scope = scope
|
|
53
70
|
@storage = storage || InMemoryStorage.new
|
|
54
71
|
@authorization_request_validator = authorization_request_validator
|
|
72
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
73
|
+
@http_client_customizer = http_client_customizer
|
|
55
74
|
@storage.save_client_information(
|
|
56
75
|
"client_id" => client_id,
|
|
57
76
|
"client_secret" => client_secret,
|
|
@@ -336,6 +336,29 @@ module MCP
|
|
|
336
336
|
uri.to_s
|
|
337
337
|
end
|
|
338
338
|
|
|
339
|
+
# Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting
|
|
340
|
+
# a URL next to the request that failed, where it must still match the server's access log; `URI#to_s`
|
|
341
|
+
# still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path`
|
|
342
|
+
# it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters
|
|
343
|
+
# for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry
|
|
344
|
+
# the very credentials being dropped.
|
|
345
|
+
def redact_url(url)
|
|
346
|
+
uri = URI.parse(url.to_s)
|
|
347
|
+
|
|
348
|
+
uri.fragment = nil
|
|
349
|
+
uri.query = nil
|
|
350
|
+
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
|
|
351
|
+
# so clear the components individually.
|
|
352
|
+
if uri.respond_to?(:user) && (uri.user || uri.password)
|
|
353
|
+
uri.user = nil
|
|
354
|
+
uri.password = nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
uri.to_s
|
|
358
|
+
rescue URI::Error
|
|
359
|
+
"[unparseable URL]"
|
|
360
|
+
end
|
|
361
|
+
|
|
339
362
|
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
|
|
340
363
|
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
|
|
341
364
|
# also advertises a query string, the server's query MUST be identical to it (otherwise
|
|
@@ -14,7 +14,38 @@ module MCP
|
|
|
14
14
|
# `Provider`; this class consumes a Provider plus signal data extracted from
|
|
15
15
|
# the failing response (resource_metadata URL, scope challenge).
|
|
16
16
|
class Flow
|
|
17
|
-
|
|
17
|
+
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
|
|
18
|
+
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
|
|
19
|
+
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
|
|
20
|
+
METADATA_URL_MAX_LENGTH = 2048
|
|
21
|
+
|
|
22
|
+
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
|
23
|
+
# so a provider naming one of these is refused rather than left believing its value was sent.
|
|
24
|
+
RESERVED_TOKEN_REQUEST_PARAMS = [
|
|
25
|
+
"grant_type",
|
|
26
|
+
"client_id",
|
|
27
|
+
"client_secret",
|
|
28
|
+
"client_assertion",
|
|
29
|
+
"client_assertion_type",
|
|
30
|
+
"scope",
|
|
31
|
+
"resource",
|
|
32
|
+
"code",
|
|
33
|
+
"code_verifier",
|
|
34
|
+
"redirect_uri",
|
|
35
|
+
"refresh_token",
|
|
36
|
+
"assertion",
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
class AuthorizationError < StandardError
|
|
40
|
+
attr_reader :http_status, :error, :error_description
|
|
41
|
+
|
|
42
|
+
def initialize(message = nil, http_status: nil, error: nil, error_description: nil)
|
|
43
|
+
super(message)
|
|
44
|
+
@http_status = http_status
|
|
45
|
+
@error = error
|
|
46
|
+
@error_description = error_description
|
|
47
|
+
end
|
|
48
|
+
end
|
|
18
49
|
|
|
19
50
|
# Raised specifically when the token endpoint rejects a grant with
|
|
20
51
|
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
|
|
@@ -28,6 +59,136 @@ module MCP
|
|
|
28
59
|
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
|
29
60
|
class AuthorizationRefusedError < AuthorizationError; end
|
|
30
61
|
|
|
62
|
+
# Raised by metadata discovery when every candidate URL answered that nothing usable is published there
|
|
63
|
+
# (a `4xx` other than `429`, a redirect that was not followed, or a body that is not a JSON object).
|
|
64
|
+
# The only discovery failure that may select the legacy 2025-03-26 path.
|
|
65
|
+
class MetadataNotPublishedError < AuthorizationError; end
|
|
66
|
+
|
|
67
|
+
# Raised by metadata discovery when the answer says nothing about what is published: the request failed to
|
|
68
|
+
# reach the server, or a candidate answered `5xx` or `429`. Falling back on this would move
|
|
69
|
+
# the flow to a different authorization server because of a transient failure, so it is surfaced instead,
|
|
70
|
+
# as the TypeScript SDK does for network errors outside browsers and the Python SDK does for both.
|
|
71
|
+
class MetadataUnreachableError < AuthorizationError; end
|
|
72
|
+
|
|
73
|
+
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
|
|
74
|
+
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
|
|
75
|
+
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
|
|
76
|
+
# a failed refresh as a reason to run the interactive flow.
|
|
77
|
+
class InvalidTokenRequestParamsError < ArgumentError; end
|
|
78
|
+
|
|
79
|
+
# Raised by `RequestedOriginGuard` when middleware added through the provider's `http_client_customizer`
|
|
80
|
+
# would send a request to an origin other than the one the flow validated, or has dropped the record of
|
|
81
|
+
# the URL the flow asked for. An `ArgumentError` because the middleware is a configuration mistake,
|
|
82
|
+
# and deliberately outside `AuthorizationError`, which discovery treats as "nothing published"
|
|
83
|
+
# and `MCP::Client::HTTP` treats on a failed refresh as a reason to run the interactive flow.
|
|
84
|
+
class DestinationMismatchError < ArgumentError; end
|
|
85
|
+
|
|
86
|
+
# Faraday middleware registered on the connection `build_http_client` assembles before the customizer
|
|
87
|
+
# is invoked, so with the usual `use` it sits ahead of the customizer's middleware and sees the URL exactly
|
|
88
|
+
# as the flow requested it, which it records on the request environment for `RequestedOriginGuard`.
|
|
89
|
+
# The guard covers what happens to a request after that record; middleware inserted ahead of it with
|
|
90
|
+
# `builder.insert(0, ...)` that rewrites the URL before it or rebuilds the environment is outside the guard.
|
|
91
|
+
# The record lives on the environment, not in `env.request.context`: that slot belongs to the application,
|
|
92
|
+
# which may fill it on the connection or replace it from a middleware of its own. Only the first URL seen
|
|
93
|
+
# on an environment is recorded: a middleware inserted ahead of this one that re-enters the stack after
|
|
94
|
+
# a `3xx` with the same environment, or with its `dup`, which shares the record, cannot replace it with
|
|
95
|
+
# the redirected URL.
|
|
96
|
+
class RequestedURLStamp
|
|
97
|
+
KEY = :mcp_oauth_requested_url
|
|
98
|
+
|
|
99
|
+
def initialize(app)
|
|
100
|
+
@app = app
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def call(env)
|
|
104
|
+
env[KEY] ||= env.url.to_s
|
|
105
|
+
@app.call(env)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Faraday middleware registered last on that connection, so it sees `env.url` after any customizer-added
|
|
110
|
+
# middleware has rewritten it or followed a redirect. A request that would leave the origin the flow asked
|
|
111
|
+
# for is refused before it reaches the adapter, since every destination check ran against the URL as
|
|
112
|
+
# written; a same-origin change stays with the server those checks admitted. The record survives
|
|
113
|
+
# the `env.dup` that redirect-following middleware performs, and a request that arrives without it is
|
|
114
|
+
# refused as well, so a middleware that rebuilds the environment fails closed rather than open.
|
|
115
|
+
# The origin boundary resembles the one the Python SDK keeps for its own auth requests, which follows
|
|
116
|
+
# a redirect itself only within the origin; this flow follows none.
|
|
117
|
+
class RequestedOriginGuard
|
|
118
|
+
def initialize(app)
|
|
119
|
+
@app = app
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def call(env)
|
|
123
|
+
requested = env[RequestedURLStamp::KEY]
|
|
124
|
+
unless requested
|
|
125
|
+
raise DestinationMismatchError, <<~MESSAGE
|
|
126
|
+
Request to #{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect} carries no record of \
|
|
127
|
+
the URL the flow asked for; middleware that rebuilds the request environment is refused.
|
|
128
|
+
MESSAGE
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless Discovery.same_origin?(env.url.to_s, requested)
|
|
132
|
+
raise DestinationMismatchError, <<~MESSAGE
|
|
133
|
+
Request to #{Discovery.canonicalize_origin_and_path(requested).inspect} would be sent to \
|
|
134
|
+
#{Discovery.canonicalize_origin_and_path(env.url.to_s).inspect}, on a different origin; \
|
|
135
|
+
middleware that follows redirects or rewrites URLs is refused.
|
|
136
|
+
MESSAGE
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@app.call(env)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_constant :RequestedURLStamp, :RequestedOriginGuard
|
|
143
|
+
|
|
144
|
+
class << self
|
|
145
|
+
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
|
|
146
|
+
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
|
|
147
|
+
# so the same problem reads the same wherever it surfaces.
|
|
148
|
+
def token_request_params_problem(params)
|
|
149
|
+
return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
# Two equal keys are two entries here, which would be sent twice from a provider method
|
|
152
|
+
# or silently collapse into one when the constructors copy the Hash.
|
|
153
|
+
return "must not compare keys by identity." if params.compare_by_identity?
|
|
154
|
+
|
|
155
|
+
params.each do |key, value|
|
|
156
|
+
return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
|
|
157
|
+
return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
|
|
158
|
+
return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds the connection the flow uses for its own requests: the SDK's defaults, `RequestedURLStamp`,
|
|
165
|
+
# then `customizer` (a provider's `http_client_customizer`, called with the `Faraday::Connection`),
|
|
166
|
+
# then `RequestedOriginGuard` last so it sees what the customizer's middleware does to each request
|
|
167
|
+
# after the stamp recorded it. Every request on the connection passes through both, so a caller using
|
|
168
|
+
# it directly is held to the same origin rule.
|
|
169
|
+
#
|
|
170
|
+
# Deliberately built without redirect-following middleware. Every destination check in this class runs
|
|
171
|
+
# against the URL as written, before the request goes out, so a connection that transparently followed
|
|
172
|
+
# a `3xx` would let a server reach a host the checks just refused. The guard turns following at
|
|
173
|
+
# the middleware level into a refusal; following inside an adapter stays invisible, so a customizer must
|
|
174
|
+
# not enable it.
|
|
175
|
+
#
|
|
176
|
+
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
|
177
|
+
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
|
178
|
+
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
|
179
|
+
# after the check.
|
|
180
|
+
def build_http_client(customizer = nil)
|
|
181
|
+
require "faraday"
|
|
182
|
+
|
|
183
|
+
Faraday.new do |faraday|
|
|
184
|
+
faraday.headers["Accept"] = "application/json"
|
|
185
|
+
faraday.use(RequestedURLStamp)
|
|
186
|
+
customizer&.call(faraday)
|
|
187
|
+
faraday.use(RequestedOriginGuard)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
31
192
|
def initialize(provider:, http_client_factory: nil)
|
|
32
193
|
@provider = provider
|
|
33
194
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
@@ -75,7 +236,7 @@ module MCP
|
|
|
75
236
|
|
|
76
237
|
ensure_pkce_supported!(as_metadata)
|
|
77
238
|
|
|
78
|
-
effective_scope = resolve_scope(scope: scope, prm: prm
|
|
239
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
79
240
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
80
241
|
|
|
81
242
|
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
|
|
@@ -126,7 +287,7 @@ module MCP
|
|
|
126
287
|
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
|
127
288
|
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
|
128
289
|
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
|
129
|
-
# and no `offline_access` augmentation because the grant
|
|
290
|
+
# and no `offline_access` augmentation because the grant is not expected to issue a refresh token (OAuth 2.1 Section 4.3.3).
|
|
130
291
|
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
|
131
292
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
132
293
|
def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
|
|
@@ -219,19 +380,22 @@ module MCP
|
|
|
219
380
|
# checks before talking to it.
|
|
220
381
|
#
|
|
221
382
|
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
|
383
|
+
# when a `client_credentials` or `jwt-bearer` provider's tokens record no issuer,
|
|
222
384
|
# or when the token endpoint refuses the refresh request.
|
|
223
385
|
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
|
224
386
|
def refresh!(server_url:, resource_metadata_url: nil)
|
|
225
387
|
refresh_token = read_token("refresh_token")
|
|
226
388
|
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
|
227
389
|
|
|
390
|
+
ensure_refresh_token_issuer_recorded!
|
|
391
|
+
|
|
228
392
|
stored_client_info = @provider.client_information
|
|
229
393
|
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
|
|
230
394
|
|
|
231
395
|
# A CIMD-configured provider stores no `client_information` on purpose
|
|
232
396
|
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
|
233
397
|
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
|
234
|
-
have_cimd_url =
|
|
398
|
+
have_cimd_url = !provider_client_id_metadata_document_url.nil?
|
|
235
399
|
|
|
236
400
|
unless have_stored_client_info || have_cimd_url
|
|
237
401
|
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
|
@@ -267,7 +431,7 @@ module MCP
|
|
|
267
431
|
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
|
|
268
432
|
stored_client_info
|
|
269
433
|
elsif as_metadata["client_id_metadata_document_supported"] == true
|
|
270
|
-
{ "client_id" =>
|
|
434
|
+
{ "client_id" => provider_client_id_metadata_document_url }
|
|
271
435
|
else
|
|
272
436
|
raise AuthorizationError,
|
|
273
437
|
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
|
|
@@ -319,7 +483,12 @@ module MCP
|
|
|
319
483
|
#
|
|
320
484
|
# Legacy path (2025-03-26 backwards compatibility): when the server publishes no PRM, `prm` is nil
|
|
321
485
|
# and the MCP server's own origin acts as the authorization base URL, matching the TypeScript and Python SDKs.
|
|
322
|
-
#
|
|
486
|
+
# Only a discovery answer saying that nothing usable is published (`MetadataNotPublishedError`: a `4xx` other than `429`,
|
|
487
|
+
# a redirect that was not followed, or a body that is not a JSON object) selects the legacy path.
|
|
488
|
+
# A request that failed to reach the server, or returned a `5xx` or `429`, says nothing about what the server publishes,
|
|
489
|
+
# so once no candidate has served a usable document it is surfaced instead (`MetadataUnreachableError`),
|
|
490
|
+
# as both SDKs do for network errors (the TypeScript SDK outside browsers) and the Python SDK does for server errors.
|
|
491
|
+
# A body over the response cap is refused outright and never reaches the fallback either.
|
|
323
492
|
# https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery
|
|
324
493
|
def locate_authorization_server(server_url:, resource_metadata_url:)
|
|
325
494
|
prm = begin
|
|
@@ -327,7 +496,7 @@ module MCP
|
|
|
327
496
|
server_url: server_url,
|
|
328
497
|
resource_metadata_url: resource_metadata_url,
|
|
329
498
|
)
|
|
330
|
-
rescue
|
|
499
|
+
rescue MetadataNotPublishedError
|
|
331
500
|
nil
|
|
332
501
|
end
|
|
333
502
|
|
|
@@ -349,16 +518,26 @@ module MCP
|
|
|
349
518
|
|
|
350
519
|
# Fetches and validates the authorization server's RFC 8414 metadata.
|
|
351
520
|
#
|
|
352
|
-
#
|
|
353
|
-
# On the legacy 2025-03-26 path
|
|
354
|
-
#
|
|
355
|
-
#
|
|
521
|
+
# The metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3) on both paths.
|
|
522
|
+
# On the legacy 2025-03-26 path the discovery URL is the MCP server's origin, which that spec names as
|
|
523
|
+
# the authorization base URL and which a document may render with a trailing slash; the TypeScript and Python SDKs
|
|
524
|
+
# accept the same slash-only difference. A document naming any other issuer is refused: an unverified `issuer`
|
|
525
|
+
# would otherwise become the identity tokens and client information are bound to, assertions are minted for,
|
|
526
|
+
# and the validator is shown, so a server could claim another authorization server and unlock the credentials
|
|
527
|
+
# bound to it.
|
|
356
528
|
# When even the metadata document is absent, the legacy spec's default endpoints are used.
|
|
357
529
|
def authorization_server_metadata(authorization_server:, legacy:, server_url:)
|
|
358
530
|
metadata = if legacy
|
|
359
|
-
begin
|
|
531
|
+
fetched = begin
|
|
360
532
|
fetch_authorization_server_metadata(issuer_url: authorization_server)
|
|
361
533
|
rescue AuthorizationError
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
if fetched
|
|
538
|
+
ensure_legacy_issuer_matches!(expected: authorization_server, returned: fetched["issuer"])
|
|
539
|
+
fetched
|
|
540
|
+
else
|
|
362
541
|
default_legacy_metadata(authorization_server)
|
|
363
542
|
end
|
|
364
543
|
else
|
|
@@ -403,6 +582,14 @@ module MCP
|
|
|
403
582
|
fetch_metadata_json(urls, label: "authorization server metadata")
|
|
404
583
|
end
|
|
405
584
|
|
|
585
|
+
# The legacy authorization base is an origin, which a document may render as `https://host/`;
|
|
586
|
+
# both name the same server, and nothing else does.
|
|
587
|
+
def ensure_legacy_issuer_matches!(expected:, returned:)
|
|
588
|
+
return if returned == "#{expected}/"
|
|
589
|
+
|
|
590
|
+
ensure_issuer_matches!(expected: expected, returned: returned)
|
|
591
|
+
end
|
|
592
|
+
|
|
406
593
|
# Reads `authorization_servers` from a PRM document and returns
|
|
407
594
|
# the first entry, raising `AuthorizationError` for any of the malformed
|
|
408
595
|
# shapes a non-compliant server could emit (missing field, non-Array
|
|
@@ -430,44 +617,63 @@ module MCP
|
|
|
430
617
|
first
|
|
431
618
|
end
|
|
432
619
|
|
|
433
|
-
# Walks candidate metadata URLs and returns the parsed
|
|
434
|
-
# the
|
|
435
|
-
#
|
|
436
|
-
#
|
|
620
|
+
# Walks candidate metadata URLs and returns the parsed body of the first 2xx response that is a JSON object;
|
|
621
|
+
# the caller checks its fields. Candidates are tried until one serves such a body, since a later one may still
|
|
622
|
+
# be usable when an earlier one is broken or down (the URL from `WWW-Authenticate` against the well-known path,
|
|
623
|
+
# or the OAuth document against the OpenID one). Once the candidates are exhausted, an answer that said nothing
|
|
624
|
+
# about what is published (a network error, a `5xx`, or a `429`) outranks the rest and raises
|
|
625
|
+
# `MetadataUnreachableError`; otherwise (any other status, such as a `4xx` other than `429` or a redirect that
|
|
626
|
+
# was not followed, a body that is not JSON, or not a JSON object) `MetadataNotPublishedError`.
|
|
627
|
+
# A body over the cap is refused outright by `bounded_request` with a plain `AuthorizationError`,
|
|
628
|
+
# before any classification. Each failure is listed with its URL stripped of userinfo, query and fragment
|
|
629
|
+
# and cut to `METADATA_URL_MAX_LENGTH`, but otherwise spelled as requested, so it can be matched against
|
|
630
|
+
# a server's access log, and with exception text bounded, since the message lands in every log destination
|
|
631
|
+
# the error passes through.
|
|
437
632
|
def fetch_metadata_json(urls, label:)
|
|
438
|
-
|
|
633
|
+
failures = []
|
|
634
|
+
inconclusive = false
|
|
439
635
|
urls.each do |url|
|
|
440
636
|
response = begin
|
|
441
637
|
http_get(url)
|
|
442
638
|
rescue Faraday::Error => e
|
|
443
|
-
|
|
639
|
+
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH)
|
|
640
|
+
failures << "GET #{reported_url(url)} raised #{[e.class, detail].compact.join(": ")}"
|
|
641
|
+
inconclusive = true
|
|
444
642
|
next
|
|
445
643
|
end
|
|
446
644
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
end
|
|
645
|
+
unless response.status >= 200 && response.status < 300
|
|
646
|
+
failures << "GET #{reported_url(url)} returned #{response.status}"
|
|
647
|
+
inconclusive = true if response.status >= 500 || response.status == 429
|
|
648
|
+
next
|
|
649
|
+
end
|
|
453
650
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
#
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
raise AuthorizationError,
|
|
462
|
-
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
|
|
463
|
-
end
|
|
651
|
+
parsed = begin
|
|
652
|
+
JSON.parse(response_body_string(response))
|
|
653
|
+
rescue JSON::ParserError => e
|
|
654
|
+
detail = bounded_diagnostic(e.message, limit: METADATA_DIAGNOSTIC_MAX_LENGTH) || e.class.name
|
|
655
|
+
failures << "GET #{reported_url(url)} returned a body that is not JSON: #{detail}"
|
|
656
|
+
next
|
|
657
|
+
end
|
|
464
658
|
|
|
465
|
-
|
|
659
|
+
# Even valid JSON can be the wrong shape (a top-level array, a bare `null`, a string, ...).
|
|
660
|
+
# The discovery callers index by name (`prm["authorization_servers"]`, etc.), so anything that
|
|
661
|
+
# is not a Hash would raise `TypeError` / `NoMethodError` downstream.
|
|
662
|
+
unless parsed.is_a?(Hash)
|
|
663
|
+
failures << "GET #{reported_url(url)} returned a body that is not a JSON object (got #{parsed.class})"
|
|
664
|
+
next
|
|
466
665
|
end
|
|
467
666
|
|
|
468
|
-
|
|
667
|
+
return parsed
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
message = "Failed to fetch #{label}: #{failures.join("; ")}."
|
|
671
|
+
|
|
672
|
+
if inconclusive
|
|
673
|
+
raise MetadataUnreachableError, message
|
|
674
|
+
else
|
|
675
|
+
raise MetadataNotPublishedError, message
|
|
469
676
|
end
|
|
470
|
-
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
|
|
471
677
|
end
|
|
472
678
|
|
|
473
679
|
def ensure_pkce_supported!(as_metadata)
|
|
@@ -628,7 +834,7 @@ module MCP
|
|
|
628
834
|
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
|
|
629
835
|
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
|
|
630
836
|
# and the current `provider.client_id_metadata_document_url`.
|
|
631
|
-
cimd_url =
|
|
837
|
+
cimd_url = provider_client_id_metadata_document_url
|
|
632
838
|
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
633
839
|
return { "client_id" => cimd_url }
|
|
634
840
|
end
|
|
@@ -758,6 +964,18 @@ module MCP
|
|
|
758
964
|
MESSAGE
|
|
759
965
|
end
|
|
760
966
|
|
|
967
|
+
# `Provider` tolerates tokens stored before the issuer was recorded (see `ensure_token_issuer!`).
|
|
968
|
+
# The `client_credentials` and `jwt-bearer` providers have no such tokens, since their refresh is new,
|
|
969
|
+
# and a refresh asks no validator, so a token without an `issuer` would be presented to whatever
|
|
970
|
+
# authorization server discovery names now. Refusing it sends the transport back through the grant,
|
|
971
|
+
# which does ask.
|
|
972
|
+
def ensure_refresh_token_issuer_recorded!
|
|
973
|
+
return if provider_authorization_flow == :authorization_code
|
|
974
|
+
return unless read_token("issuer").nil?
|
|
975
|
+
|
|
976
|
+
raise AuthorizationError, "Cannot refresh: the stored tokens record no issuer; re-authorization is required."
|
|
977
|
+
end
|
|
978
|
+
|
|
761
979
|
def ensure_refreshable_client_information!(client_info, as_metadata:)
|
|
762
980
|
stored_issuer = client_info_required_value(client_info, "issuer")
|
|
763
981
|
return if stored_issuer.nil?
|
|
@@ -886,7 +1104,8 @@ module MCP
|
|
|
886
1104
|
def resolve_scope(scope:, prm:)
|
|
887
1105
|
return scope if scope && !scope.empty?
|
|
888
1106
|
|
|
889
|
-
|
|
1107
|
+
# `prm` is nil on the legacy path, where nothing advertises scopes.
|
|
1108
|
+
supported = prm && prm["scopes_supported"]
|
|
890
1109
|
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
|
|
891
1110
|
|
|
892
1111
|
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
|
@@ -948,6 +1167,31 @@ module MCP
|
|
|
948
1167
|
@provider.authorization_flow
|
|
949
1168
|
end
|
|
950
1169
|
|
|
1170
|
+
# Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
|
|
1171
|
+
# Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
|
|
1172
|
+
# or one returning `nil`, adds nothing.
|
|
1173
|
+
# A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
|
|
1174
|
+
def provider_token_request_params
|
|
1175
|
+
return {} unless @provider.respond_to?(:token_request_params)
|
|
1176
|
+
|
|
1177
|
+
params = @provider.token_request_params
|
|
1178
|
+
return {} if params.nil?
|
|
1179
|
+
|
|
1180
|
+
problem = self.class.token_request_params_problem(params)
|
|
1181
|
+
raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem
|
|
1182
|
+
|
|
1183
|
+
params
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
# The Client ID Metadata Document URL, when the provider has one. Only `Provider` exposes the reader
|
|
1187
|
+
# (CIMD replaces Dynamic Client Registration on the authorization-code flow), while `refresh!` serves
|
|
1188
|
+
# every provider that holds a `refresh_token`, so the read is duck-typed like `authorization_flow`.
|
|
1189
|
+
def provider_client_id_metadata_document_url
|
|
1190
|
+
return unless @provider.respond_to?(:client_id_metadata_document_url)
|
|
1191
|
+
|
|
1192
|
+
@provider.client_id_metadata_document_url
|
|
1193
|
+
end
|
|
1194
|
+
|
|
951
1195
|
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
952
1196
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
953
1197
|
unless authorization_endpoint
|
|
@@ -997,9 +1241,12 @@ module MCP
|
|
|
997
1241
|
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
998
1242
|
end
|
|
999
1243
|
|
|
1000
|
-
# Submits a form-encoded request
|
|
1001
|
-
#
|
|
1002
|
-
#
|
|
1244
|
+
# Submits a form-encoded token request using the authentication method
|
|
1245
|
+
# stored in `client_information`. The method determines whether client
|
|
1246
|
+
# credentials belong in the form body, a Basic header, or a JWT assertion.
|
|
1247
|
+
# A provider's `token_request_params` go underneath the flow's own parameters,
|
|
1248
|
+
# which therefore win, and are refused before the request is sent when they name
|
|
1249
|
+
# a reserved parameter or are not a Hash of Strings.
|
|
1003
1250
|
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
|
1004
1251
|
client_id = client_info_required_value(client_info, "client_id")
|
|
1005
1252
|
unless client_id
|
|
@@ -1009,13 +1256,15 @@ module MCP
|
|
|
1009
1256
|
|
|
1010
1257
|
client_secret = client_info_required_value(client_info, "client_secret")
|
|
1011
1258
|
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
|
1259
|
+
form = provider_token_request_params.merge(form)
|
|
1012
1260
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
#
|
|
1018
|
-
#
|
|
1261
|
+
# Apply one client authentication method per request (RFC 6749 Section 2.3).
|
|
1262
|
+
headers = {}
|
|
1263
|
+
form = case token_endpoint_auth_method
|
|
1264
|
+
when "private_key_jwt"
|
|
1265
|
+
# The assertion identifies the client through its `iss` and `sub`
|
|
1266
|
+
# claims, so the body needs no separate `client_id` (RFC 7521 Section 4.2).
|
|
1267
|
+
# Use the issuer already checked by `ensure_issuer_matches!` as the audience.
|
|
1019
1268
|
unless @provider.respond_to?(:client_assertion)
|
|
1020
1269
|
raise AuthorizationError,
|
|
1021
1270
|
"token_endpoint_auth_method is private_key_jwt but the provider does not " \
|
|
@@ -1026,22 +1275,24 @@ module MCP
|
|
|
1026
1275
|
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
|
|
1027
1276
|
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
|
|
1028
1277
|
)
|
|
1278
|
+
when "client_secret_post"
|
|
1279
|
+
# Send the client ID and available secret in the form body.
|
|
1280
|
+
body = form.merge("client_id" => client_id)
|
|
1281
|
+
body["client_secret"] = client_secret if client_secret
|
|
1282
|
+
body
|
|
1029
1283
|
else
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
case token_endpoint_auth_method
|
|
1036
|
-
when "client_secret_post"
|
|
1037
|
-
form["client_secret"] = client_secret
|
|
1038
|
-
when "none"
|
|
1039
|
-
# Public client; no credential.
|
|
1040
|
-
else
|
|
1041
|
-
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
|
|
1042
|
-
# both Python and TypeScript SDKs default here when
|
|
1043
|
-
# the authentication method is not explicitly stored.
|
|
1284
|
+
if client_secret && token_endpoint_auth_method != "none"
|
|
1285
|
+
# Basic is also the fallback when a secret is present but no method
|
|
1286
|
+
# is stored. A body `client_id` is optional (RFC 6749 Section 3.2.1);
|
|
1287
|
+
# omit it because some servers treat it alongside Basic as a second
|
|
1288
|
+
# authentication method and reject the request with `invalid_request`.
|
|
1044
1289
|
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
|
|
1290
|
+
form
|
|
1291
|
+
else
|
|
1292
|
+
# With `none` or no secret, identify the client using `client_id`.
|
|
1293
|
+
# This is required for unauthenticated authorization-code exchanges
|
|
1294
|
+
# (RFC 6749 Section 3.2.1).
|
|
1295
|
+
form.merge("client_id" => client_id)
|
|
1045
1296
|
end
|
|
1046
1297
|
end
|
|
1047
1298
|
|
|
@@ -1059,11 +1310,7 @@ module MCP
|
|
|
1059
1310
|
end
|
|
1060
1311
|
|
|
1061
1312
|
if response.status < 200 || response.status >= 300
|
|
1062
|
-
|
|
1063
|
-
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
|
|
1064
|
-
end
|
|
1065
|
-
|
|
1066
|
-
raise AuthorizationError, "Token endpoint returned status #{response.status}."
|
|
1313
|
+
raise token_endpoint_error(response)
|
|
1067
1314
|
end
|
|
1068
1315
|
|
|
1069
1316
|
parsed = begin
|
|
@@ -1084,17 +1331,41 @@ module MCP
|
|
|
1084
1331
|
parsed
|
|
1085
1332
|
end
|
|
1086
1333
|
|
|
1087
|
-
#
|
|
1088
|
-
#
|
|
1089
|
-
#
|
|
1090
|
-
def
|
|
1091
|
-
|
|
1092
|
-
|
|
1334
|
+
# Surface only RFC 6749 §5.2 diagnostic fields, never the raw response,
|
|
1335
|
+
# which may contain tokens or other credentials. Classify the original
|
|
1336
|
+
# code so sanitization cannot turn malformed input into invalid_grant.
|
|
1337
|
+
def token_endpoint_error(response)
|
|
1338
|
+
message = "Token endpoint returned status #{response.status}."
|
|
1339
|
+
error_class = AuthorizationError
|
|
1340
|
+
parsed = JSON.parse(response_body_string(response))
|
|
1341
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
1342
|
+
|
|
1343
|
+
error_class = parsed["error"] == "invalid_grant" ? InvalidGrantError : AuthorizationError
|
|
1344
|
+
error = bounded_diagnostic(parsed["error"], limit: TOKEN_ENDPOINT_ERROR_MAX_LENGTH)
|
|
1345
|
+
description = bounded_diagnostic(parsed["error_description"], limit: TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH)
|
|
1346
|
+
message += " #{[error, description].compact.join(": ")}" if error || description
|
|
1347
|
+
|
|
1348
|
+
error_class.new(message, http_status: response.status, error: error, error_description: description)
|
|
1349
|
+
rescue StandardError
|
|
1350
|
+
# Diagnostics must not mask the endpoint failure or change refresh recovery.
|
|
1351
|
+
error_class.new("Token endpoint returned status #{response.status}.", http_status: response.status)
|
|
1352
|
+
end
|
|
1353
|
+
|
|
1354
|
+
def bounded_diagnostic(value, limit:)
|
|
1355
|
+
return unless value.is_a?(String)
|
|
1093
1356
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1357
|
+
# RFC 6749 permits printable ASCII except double quotes and backslashes in token endpoint error fields.
|
|
1358
|
+
# Replace other characters to keep text received off the network on one log line.
|
|
1359
|
+
value = value.scrub(" ").gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, " ").strip
|
|
1360
|
+
return if value.empty?
|
|
1361
|
+
|
|
1362
|
+
value.length > limit ? "#{value[0, limit - 3]}..." : value
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
# A candidate URL as it goes into a failure string: redacted, and cut so a URL the server chose cannot
|
|
1366
|
+
# grow the message without limit.
|
|
1367
|
+
def reported_url(url)
|
|
1368
|
+
bounded_diagnostic(Discovery.redact_url(url), limit: METADATA_URL_MAX_LENGTH)
|
|
1098
1369
|
end
|
|
1099
1370
|
|
|
1100
1371
|
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
|
|
@@ -1162,22 +1433,18 @@ module MCP
|
|
|
1162
1433
|
@http_client ||= @http_client_factory.call
|
|
1163
1434
|
end
|
|
1164
1435
|
|
|
1165
|
-
#
|
|
1166
|
-
#
|
|
1167
|
-
#
|
|
1168
|
-
# A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
|
|
1169
|
-
# and the guards above only cover the first hop.
|
|
1170
|
-
#
|
|
1171
|
-
# `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
|
|
1172
|
-
# the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
|
|
1173
|
-
# which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
|
|
1174
|
-
# after the check.
|
|
1436
|
+
# A connection supplied through `http_client_factory:` replaces this one, the provider's customizer and
|
|
1437
|
+
# `RequestedOriginGuard` included, so that caller takes on the redirect responsibility described on
|
|
1438
|
+
# `build_http_client`; `bounded_request` caps its responses all the same.
|
|
1175
1439
|
def default_http_client
|
|
1176
|
-
|
|
1440
|
+
self.class.build_http_client(provider_http_client_customizer)
|
|
1441
|
+
end
|
|
1177
1442
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1443
|
+
# `nil` for a provider that predates the hook or leaves it unset.
|
|
1444
|
+
def provider_http_client_customizer
|
|
1445
|
+
return unless @provider.respond_to?(:http_client_customizer)
|
|
1446
|
+
|
|
1447
|
+
@provider.http_client_customizer
|
|
1181
1448
|
end
|
|
1182
1449
|
|
|
1183
1450
|
def response_body_string(response)
|
|
@@ -95,7 +95,7 @@ module MCP
|
|
|
95
95
|
assertion
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow
|
|
98
|
+
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`:
|
|
99
99
|
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
|
100
100
|
# onto compressed bytes.
|
|
101
101
|
def default_http_client
|
|
@@ -48,6 +48,13 @@ module MCP
|
|
|
48
48
|
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
|
49
49
|
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
50
50
|
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
51
|
+
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
|
|
52
|
+
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
|
|
53
|
+
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
|
|
54
|
+
# raises `Flow::InvalidTokenRequestParamsError`.
|
|
55
|
+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
|
56
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
|
57
|
+
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
|
51
58
|
class Provider
|
|
52
59
|
include StorageBackedProvider
|
|
53
60
|
|
|
@@ -87,7 +94,9 @@ module MCP
|
|
|
87
94
|
scope: nil,
|
|
88
95
|
storage: nil,
|
|
89
96
|
client_id_metadata_document_url: nil,
|
|
90
|
-
authorization_request_validator: nil
|
|
97
|
+
authorization_request_validator: nil,
|
|
98
|
+
token_request_params: nil,
|
|
99
|
+
http_client_customizer: nil
|
|
91
100
|
)
|
|
92
101
|
unless Discovery.secure_url?(redirect_uri)
|
|
93
102
|
raise InsecureRedirectURIError,
|
|
@@ -109,6 +118,8 @@ module MCP
|
|
|
109
118
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
110
119
|
end
|
|
111
120
|
|
|
121
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
122
|
+
|
|
112
123
|
@client_metadata = client_metadata
|
|
113
124
|
@redirect_uri = redirect_uri
|
|
114
125
|
@redirect_handler = redirect_handler
|
|
@@ -117,6 +128,8 @@ module MCP
|
|
|
117
128
|
@storage = storage || InMemoryStorage.new
|
|
118
129
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
119
130
|
@authorization_request_validator = authorization_request_validator
|
|
131
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
132
|
+
@http_client_customizer = http_client_customizer
|
|
120
133
|
end
|
|
121
134
|
|
|
122
135
|
# Identifies the OAuth flow this provider drives.
|
|
@@ -4,11 +4,11 @@ module MCP
|
|
|
4
4
|
class Client
|
|
5
5
|
module OAuth
|
|
6
6
|
# Shared token/credential persistence for the OAuth provider classes
|
|
7
|
-
# (`Provider` for the authorization-code flow
|
|
8
|
-
# for the client_credentials flow
|
|
9
|
-
# but
|
|
10
|
-
# the token response and the client information. This module supplies
|
|
11
|
-
# so the `Flow` orchestrator can treat any provider uniformly.
|
|
7
|
+
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
|
|
8
|
+
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
|
|
9
|
+
# The grants differ in how they authenticate, but all read and write the same two pieces of state
|
|
10
|
+
# through a `storage` object: the token response and the client information. This module supplies
|
|
11
|
+
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
|
|
12
12
|
#
|
|
13
13
|
# Including classes must set `@storage` to an object responding to `tokens`,
|
|
14
14
|
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
|
@@ -21,6 +21,20 @@ module MCP
|
|
|
21
21
|
# MCP SDK has today.
|
|
22
22
|
attr_reader :authorization_request_validator
|
|
23
23
|
|
|
24
|
+
# Optional Hash of String keys and values added to every token request the provider makes, for parameters
|
|
25
|
+
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
|
|
26
|
+
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
|
|
27
|
+
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
|
|
28
|
+
# with `Flow::InvalidTokenRequestParamsError`.
|
|
29
|
+
attr_reader :token_request_params
|
|
30
|
+
|
|
31
|
+
# Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests,
|
|
32
|
+
# after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can
|
|
33
|
+
# add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default)
|
|
34
|
+
# keeps the default connection. The transport's own connection and customizer block never serve these requests:
|
|
35
|
+
# they are bound to the MCP server URL and carry headers meant for that server.
|
|
36
|
+
attr_reader :http_client_customizer
|
|
37
|
+
|
|
24
38
|
def access_token
|
|
25
39
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
26
40
|
end
|
|
@@ -44,6 +58,29 @@ module MCP
|
|
|
44
58
|
def clear_tokens!
|
|
45
59
|
@storage.save_tokens(nil)
|
|
46
60
|
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
|
|
65
|
+
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
|
|
66
|
+
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
|
|
67
|
+
# because `Hash` copies and freezes only keys whose class is exactly `String`.
|
|
68
|
+
def frozen_token_request_params(params)
|
|
69
|
+
return if params.nil?
|
|
70
|
+
|
|
71
|
+
problem = Flow.token_request_params_problem(params)
|
|
72
|
+
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem
|
|
73
|
+
|
|
74
|
+
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed
|
|
78
|
+
# in place of a callable fails at construction rather than at the first `401`.
|
|
79
|
+
def validated_http_client_customizer(customizer)
|
|
80
|
+
return customizer if customizer.nil? || customizer.respond_to?(:call)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})."
|
|
83
|
+
end
|
|
47
84
|
end
|
|
48
85
|
end
|
|
49
86
|
end
|
data/lib/mcp/icon.rb
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
|
+
# An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type:
|
|
5
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic#icons
|
|
6
|
+
#
|
|
7
|
+
# Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs,
|
|
8
|
+
# so an icon that would serialize into something a client rejects fails here instead. What a value means
|
|
9
|
+
# is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
|
|
10
|
+
# is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
|
|
11
|
+
# (the specification expects `WxH` or `"any"`).
|
|
4
12
|
class Icon
|
|
5
13
|
SUPPORTED_THEMES = ["light", "dark"].freeze
|
|
6
14
|
|
|
7
15
|
attr_reader :mime_type, :sizes, :src, :theme
|
|
8
16
|
|
|
9
|
-
def initialize(mime_type: nil, sizes: nil, src
|
|
10
|
-
|
|
17
|
+
def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
|
|
18
|
+
unless src.is_a?(String) && !src.empty?
|
|
19
|
+
raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless mime_type.nil? || mime_type.is_a?(String)
|
|
23
|
+
raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if (problem = sizes_problem(sizes))
|
|
27
|
+
raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless theme.nil? || SUPPORTED_THEMES.include?(theme)
|
|
31
|
+
raise ArgumentError, 'The value of theme must specify "light" or "dark".'
|
|
32
|
+
end
|
|
11
33
|
|
|
12
34
|
@mime_type = mime_type
|
|
13
35
|
@sizes = sizes
|
|
@@ -18,5 +40,16 @@ module MCP
|
|
|
18
40
|
def to_h
|
|
19
41
|
{ mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact
|
|
20
42
|
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def sizes_problem(sizes)
|
|
47
|
+
return if sizes.nil?
|
|
48
|
+
return "got #{sizes.class}" unless sizes.is_a?(Array)
|
|
49
|
+
|
|
50
|
+
index = sizes.index { |size| !size.is_a?(String) }
|
|
51
|
+
|
|
52
|
+
"got #{sizes[index].class} inside the Array" if index
|
|
53
|
+
end
|
|
21
54
|
end
|
|
22
55
|
end
|
data/lib/mcp/tool.rb
CHANGED
|
@@ -149,7 +149,7 @@ module MCP
|
|
|
149
149
|
raise ArgumentError, "Tool names should be between 1 and 128 characters in length (inclusive)."
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
unless tool_name.match?(/\A[A-Za-z\d_
|
|
152
|
+
unless tool_name.match?(/\A[A-Za-z\d_\-.]+\z/)
|
|
153
153
|
raise ArgumentError, <<~MESSAGE
|
|
154
154
|
Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.).
|
|
155
155
|
MESSAGE
|
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -108,7 +108,7 @@ licenses:
|
|
|
108
108
|
- Apache-2.0
|
|
109
109
|
metadata:
|
|
110
110
|
allowed_push_host: https://rubygems.org
|
|
111
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.
|
|
111
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.6.0
|
|
112
112
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
113
113
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
114
114
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|
|
@@ -127,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
127
127
|
- !ruby/object:Gem::Version
|
|
128
128
|
version: '0'
|
|
129
129
|
requirements: []
|
|
130
|
-
rubygems_version: 4.0.
|
|
130
|
+
rubygems_version: 4.0.20
|
|
131
131
|
specification_version: 4
|
|
132
132
|
summary: The official Ruby SDK for Model Context Protocol servers and clients
|
|
133
133
|
test_files: []
|