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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a1945319168ba9681dadea47a487f973d718e7f3504418a2b4b6e60bc0a1ba4
4
- data.tar.gz: 6523b4e444a94354adc9421c54c1091acbbb82e6d2789625fca5b47c8df60457
3
+ metadata.gz: 3cb0f742e469efeb15202b40310bfb2eab52b94bb208bab1f33cfd2204bcf8c9
4
+ data.tar.gz: 07df380a1dd652b6fd5deb0ddbafe3c3379bc6fa77c8a391f169f6a7fed11742
5
5
  SHA512:
6
- metadata.gz: 9f67b440f824785842c513c350dcc6ffbe6753f6854924ae1655efce948dfde31fb29f5d431c763d314cef9863951722049aa45f1b2f3ccae667e6d621440007
7
- data.tar.gz: 8fc66cc8e4f90c59eaeb3033ff0797735a6c0a1d91f0a2663f23b5250840f5a70b6854f24d55096cfb7288c66cda4c8d82b2616573af85677a35702a07c9070c
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
- # Perform the MCP initialization handshake before sending any requests.
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#default_http_client`.
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
- # `client_information`, and `save_client_information(info)`. Defaults to
42
- # an `InMemoryStorage`. The `client_id` / `client_secret` are written
43
- # into it so the token exchange reads them through the same path as
44
- # a pre-registered authorization-code client.
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(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
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
- class AuthorizationError < StandardError; end
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 does not issue a refresh token (OAuth 2.1 Section 4.3.3).
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 = !@provider.client_id_metadata_document_url.nil?
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" => @provider.client_id_metadata_document_url }
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
- # Any PRM discovery failure (404s, network errors, malformed documents) selects the legacy path, mirroring both SDKs' behavior.
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 AuthorizationError
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
- # On the modern path the metadata `issuer` must be byte-identical to the discovery URL (RFC 8414 Section 3.3).
353
- # On the legacy 2025-03-26 path that validation is skipped: the legacy spec predates the requirement,
354
- # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
355
- # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
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 JSON body of
434
- # the first 2xx response. Raises `AuthorizationError` for transport
435
- # failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
436
- # so callers do not have to handle raw Faraday/JSON exceptions.
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
- last_error = nil
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
- last_error = "GET #{url} raised #{e.class}: #{e.message}"
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
- if response.status >= 200 && response.status < 300
448
- parsed = begin
449
- JSON.parse(response_body_string(response))
450
- rescue JSON::ParserError => e
451
- raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
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
- # Even valid JSON can be the wrong shape (a top-level array,
455
- # a bare `null`, a string, ...). The discovery callers index by
456
- # name (`prm["authorization_servers"]`, etc.), so anything that
457
- # is not a Hash would raise `TypeError` / `NoMethodError`
458
- # downstream. Surface that as `AuthorizationError` instead so
459
- # callers see a single, documented error type.
460
- unless parsed.is_a?(Hash)
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
- return parsed
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
- last_error = "GET #{url} returned #{response.status}"
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 = @provider.client_id_metadata_document_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
- supported = prm["scopes_supported"]
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 to the token endpoint, applying
1001
- # the client authentication method advertised in `client_information` and
1002
- # adding `client_id` (and `client_secret` when not using HTTP Basic).
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
- form = if token_endpoint_auth_method == "private_key_jwt"
1014
- # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
1015
- # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
1016
- # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
1017
- # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
1018
- # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
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
- form.merge("client_id" => client_id)
1031
- end
1032
-
1033
- headers = {}
1034
- if client_secret
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
- if token_endpoint_error_code(response) == "invalid_grant"
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
- # Extracts the `error` code from an RFC 6749 §5.2 error response body
1088
- # when one is parseable. Returns nil on any parse failure or when
1089
- # the body is not JSON.
1090
- def token_endpoint_error_code(response)
1091
- body = response_body_string(response).to_s
1092
- return if body.empty?
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
- parsed = JSON.parse(body)
1095
- parsed["error"] if parsed.is_a?(Hash)
1096
- rescue JSON::ParserError
1097
- nil
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
- # Deliberately built without redirect-following middleware. Every destination check in
1166
- # this class runs against the URL as written, before the request goes out, so a connection
1167
- # that transparently followed a `3xx` would let a server reach a host the checks just refused.
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
- require "faraday"
1440
+ self.class.build_http_client(provider_http_client_customizer)
1441
+ end
1177
1442
 
1178
- Faraday.new do |faraday|
1179
- faraday.headers["Accept"] = "application/json"
1180
- end
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#default_http_client`:
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 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.
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: nil, theme: nil)
10
- raise ArgumentError, 'The value of theme must specify "light" or "dark".' if theme && !SUPPORTED_THEMES.include?(theme)
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_\-\.]+\z/)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.5.1"
4
+ VERSION = "1.6.0"
5
5
  end
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.5.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.5.1
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.16
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: []