mcp 0.18.0 → 0.19.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: 7268234a54e4c0b422a916aabc08de04a4cde157b9a6b3909274ab4629885137
4
- data.tar.gz: 1f48ca777ef0269c8c1f946a23efed1cd990e9ac1898568d8143d84e6c8d7fcb
3
+ metadata.gz: 959e38a26541c9681c11e8bc227e84c42d20e3d98de1568c0afd45c80f3b4474
4
+ data.tar.gz: e2bf6b0c7926e1803f9dff1279c1f5a35d96f1cf8a1e3c6141b22a97871c8ec2
5
5
  SHA512:
6
- metadata.gz: bb859fc7e68f54f1a1d7c3d523a3487799c7b5ff8e3ddb5c01cd84101be79c9b5f81eeeb65b8fb06d620b87898f6c3466492e57ad921eac729c113cd5a609f8d
7
- data.tar.gz: 0bf7b67aa29e8211c97168a9e9472e5fdfb1ea5a97db16d917ab1602f584db6076854e354abe0456384c70e712a797832c0949f12d5a5e76bea6761fcecab0c1
6
+ metadata.gz: 5f347a7259f4e728df5edad217d6963cb3428188e46244efd9be9908e84bf4a7134600c6f453249eedc87e33f998f3f51811922659f116811251a64d78f1bbee
7
+ data.tar.gz: 3d8d25f65c590d07157db74d87423bbde82250b5d99cf2e3182c8adbc198f534f96809265c8615160f49f963a3b2dc2a07a85db2f6f2cc83e11b068583f405de
data/README.md CHANGED
@@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
1913
1913
  - On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
1914
1914
  perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
1915
1915
  - On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
1916
+ - Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
1917
+ `scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
1918
+ `offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
1916
1919
 
1917
1920
  ```ruby
1918
1921
  require "mcp"
@@ -1958,6 +1961,17 @@ Optional keyword arguments:
1958
1961
  - `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
1959
1962
  - `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
1960
1963
  which keeps credentials in process memory only.
1964
+ - `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
1965
+ (`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
1966
+ When the authorization server advertises `client_id_metadata_document_supported: true`,
1967
+ the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
1968
+ Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
1969
+ userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
1970
+ them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
1971
+ Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
1972
+ served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
1973
+ the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
1974
+ `client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
1961
1975
 
1962
1976
  To persist credentials across restarts, supply your own storage:
1963
1977
 
@@ -183,6 +183,50 @@ module MCP
183
183
  false
184
184
  end
185
185
 
186
+ # Returns true when `url` satisfies the structural requirements for
187
+ # a Client ID Metadata Document URL per the MCP 2025-11-25
188
+ # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
189
+ #
190
+ # Spec-required:
191
+ #
192
+ # - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
193
+ # the document URL is sent verbatim to the authorization server as the OAuth `client_id`
194
+ # and travels off-loopback)
195
+ # - host MUST be present
196
+ # - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
197
+ # not the origin
198
+ # - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
199
+ # would leak credentials into every `client_id` log line
200
+ # - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
201
+ # the same effective path do not produce different `client_id` strings
202
+ #
203
+ # SDK policy (stricter than the draft):
204
+ #
205
+ # - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
206
+ # but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
207
+ # `client_id` values for the same logical document.
208
+ def client_id_metadata_document_url?(url)
209
+ return false if url.nil? || url.to_s.empty?
210
+
211
+ uri = URI.parse(url.to_s)
212
+ return false unless uri.scheme&.downcase == "https"
213
+ return false if uri.host.nil? || uri.host.empty?
214
+ return false unless uri.fragment.nil?
215
+ return false unless uri.query.nil?
216
+ return false if uri.respond_to?(:user) && (uri.user || uri.password)
217
+
218
+ path = uri.path.to_s
219
+ return false if path.empty? || path == "/"
220
+
221
+ decoded = path.gsub(/%2[eE]/, ".")
222
+ segments = decoded.split("/", -1)
223
+ return false if segments.any? { |segment| segment == "." || segment == ".." }
224
+
225
+ true
226
+ rescue URI::InvalidURIError
227
+ false
228
+ end
229
+
186
230
  # Like `canonicalize_url` but also strips query string, fragment, and
187
231
  # userinfo. This variant is used for identity comparison against
188
232
  # the request URL Faraday actually sends, which differs from the value
@@ -59,6 +59,7 @@ module MCP
59
59
  client_info = ensure_client_registered(as_metadata: as_metadata)
60
60
 
61
61
  effective_scope = resolve_scope(scope: scope, prm: prm)
62
+ effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
62
63
  pkce = PKCE.generate
63
64
  state = SecureRandom.urlsafe_base64(32)
64
65
 
@@ -104,8 +105,16 @@ module MCP
104
105
  refresh_token = read_token("refresh_token")
105
106
  raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
106
107
 
107
- client_info = @provider.client_information
108
- unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
108
+ stored_client_info = @provider.client_information
109
+ have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
110
+
111
+ # A CIMD-configured provider stores no `client_information` on purpose
112
+ # (the CIMD URL is re-resolved against the live AS metadata on every flow).
113
+ # Allow refresh to proceed in that case so the `refresh_token` obtained via
114
+ # the CIMD flow remains usable.
115
+ have_cimd_url = !@provider.client_id_metadata_document_url.nil?
116
+
117
+ unless have_stored_client_info || have_cimd_url
109
118
  raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
110
119
  end
111
120
 
@@ -126,6 +135,18 @@ module MCP
126
135
  ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
127
136
  ensure_secure_endpoints!(as_metadata)
128
137
 
138
+ client_info = if have_stored_client_info
139
+ # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
140
+ # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
141
+ stored_client_info
142
+ elsif as_metadata["client_id_metadata_document_supported"] == true
143
+ { "client_id" => @provider.client_id_metadata_document_url }
144
+ else
145
+ raise AuthorizationError,
146
+ "Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
147
+ "`client_id_metadata_document_supported: true`."
148
+ end
149
+
129
150
  new_tokens = exchange_refresh_token(
130
151
  as_metadata: as_metadata,
131
152
  client_info: client_info,
@@ -285,6 +306,24 @@ module MCP
285
306
  existing = @provider.client_information
286
307
  return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
287
308
 
309
+ # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
310
+ # if the authorization server advertises Client ID Metadata Document support and the provider has
311
+ # a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
312
+ #
313
+ # The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
314
+ # A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
315
+ # otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
316
+ # a server that has not actually adopted it.
317
+ #
318
+ # The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
319
+ # (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
320
+ # keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
321
+ # and the current `provider.client_id_metadata_document_url`.
322
+ cimd_url = @provider.client_id_metadata_document_url
323
+ if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
324
+ return { "client_id" => cimd_url }
325
+ end
326
+
288
327
  registration_endpoint = as_metadata["registration_endpoint"]
289
328
  unless registration_endpoint
290
329
  raise AuthorizationError,
@@ -403,6 +442,48 @@ module MCP
403
442
  nil
404
443
  end
405
444
 
445
+ # Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
446
+ #
447
+ # - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
448
+ # `refresh_token` in its registered `grant_types`) MAY request `offline_access`
449
+ # when the authorization server advertises it in metadata `scopes_supported`.
450
+ # When the server advertises it and the client opted in, add it if absent.
451
+ #
452
+ # - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
453
+ # strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
454
+ # PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
455
+ # request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
456
+ # or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
457
+ # the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
458
+ # own request consistent with the AS's advertisement.
459
+ #
460
+ # Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
461
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
462
+ def normalize_offline_access_scope(scope, as_metadata:)
463
+ scopes = scope.to_s.split
464
+
465
+ if server_supports_offline_access?(as_metadata)
466
+ scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
467
+ else
468
+ scopes.delete("offline_access")
469
+ end
470
+
471
+ scopes.empty? ? nil : scopes.join(" ")
472
+ end
473
+
474
+ def server_supports_offline_access?(as_metadata)
475
+ supported = as_metadata["scopes_supported"]
476
+
477
+ supported.is_a?(Array) && supported.include?("offline_access")
478
+ end
479
+
480
+ def wants_refresh_token?
481
+ metadata = @provider.client_metadata
482
+ grant_types = metadata[:grant_types] || metadata["grant_types"]
483
+
484
+ Array(grant_types).include?("refresh_token")
485
+ end
486
+
406
487
  def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
407
488
  authorization_endpoint = as_metadata["authorization_endpoint"]
408
489
  unless authorization_endpoint
@@ -25,6 +25,16 @@ module MCP
25
25
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
26
26
  # `client_information`, and `save_client_information(info)`. Defaults to
27
27
  # an `InMemoryStorage`.
28
+ # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
29
+ # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
30
+ # When the authorization server advertises `client_id_metadata_document_supported: true`,
31
+ # the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
32
+ # Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
33
+ # The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
34
+ # but different encodings of the same query would yield different `client_id` strings for the same document).
35
+ # The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
36
+ # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
37
+ # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
28
38
  class Provider
29
39
  # Raised when `Provider#initialize` is called with a `redirect_uri` that
30
40
  # is neither HTTPS nor a loopback `http://` URL, per the MCP
@@ -38,12 +48,21 @@ module MCP
38
48
  # runtime; failing at construction surfaces the bug earlier.
39
49
  class UnregisteredRedirectURIError < ArgumentError; end
40
50
 
51
+ # Raised when `client_id_metadata_document_url` is provided but does not meet
52
+ # the structural requirements for a Client ID Metadata Document URL:
53
+ # HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
54
+ # The CIMD URL is sent to the authorization server as the OAuth `client_id`,
55
+ # so the same Communication Security guarantee that protects the redirect URI
56
+ # applies and the value must unambiguously identify the document.
57
+ class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
58
+
41
59
  attr_reader :client_metadata,
42
60
  :redirect_uri,
43
61
  :scope,
44
62
  :storage,
45
63
  :redirect_handler,
46
- :callback_handler
64
+ :callback_handler,
65
+ :client_id_metadata_document_url
47
66
 
48
67
  def initialize(
49
68
  client_metadata:,
@@ -51,7 +70,8 @@ module MCP
51
70
  redirect_handler:,
52
71
  callback_handler:,
53
72
  scope: nil,
54
- storage: nil
73
+ storage: nil,
74
+ client_id_metadata_document_url: nil
55
75
  )
56
76
  unless Discovery.secure_url?(redirect_uri)
57
77
  raise InsecureRedirectURIError,
@@ -66,12 +86,20 @@ module MCP
66
86
  "(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
67
87
  end
68
88
 
89
+ if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
90
+ raise InvalidClientIDMetadataDocumentURLError,
91
+ "client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
92
+ "with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
93
+ "per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
94
+ end
95
+
69
96
  @client_metadata = client_metadata
70
97
  @redirect_uri = redirect_uri
71
98
  @redirect_handler = redirect_handler
72
99
  @callback_handler = callback_handler
73
100
  @scope = scope
74
101
  @storage = storage || InMemoryStorage.new
102
+ @client_id_metadata_document_url = client_id_metadata_document_url
75
103
  end
76
104
 
77
105
  def access_token
data/lib/mcp/client.rb CHANGED
@@ -146,21 +146,7 @@ module MCP
146
146
  # end
147
147
  def tools
148
148
  # TODO: consider renaming to `list_all_tools`.
149
- all_tools = []
150
- seen = Set.new
151
- cursor = nil
152
-
153
- loop do
154
- page = list_tools(cursor: cursor)
155
- all_tools.concat(page.tools)
156
- next_cursor = page.next_cursor
157
- break if next_cursor.nil? || seen.include?(next_cursor)
158
-
159
- seen << next_cursor
160
- cursor = next_cursor
161
- end
162
-
163
- all_tools
149
+ fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
164
150
  end
165
151
 
166
152
  # Returns a single page of resources from the server.
@@ -189,21 +175,7 @@ module MCP
189
175
  # @return [Array<Hash>] An array of available resources.
190
176
  def resources
191
177
  # TODO: consider renaming to `list_all_resources`.
192
- all_resources = []
193
- seen = Set.new
194
- cursor = nil
195
-
196
- loop do
197
- page = list_resources(cursor: cursor)
198
- all_resources.concat(page.resources)
199
- next_cursor = page.next_cursor
200
- break if next_cursor.nil? || seen.include?(next_cursor)
201
-
202
- seen << next_cursor
203
- cursor = next_cursor
204
- end
205
-
206
- all_resources
178
+ fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
207
179
  end
208
180
 
209
181
  # Returns a single page of resource templates from the server.
@@ -232,21 +204,7 @@ module MCP
232
204
  # @return [Array<Hash>] An array of available resource templates.
233
205
  def resource_templates
234
206
  # TODO: consider renaming to `list_all_resource_templates`.
235
- all_templates = []
236
- seen = Set.new
237
- cursor = nil
238
-
239
- loop do
240
- page = list_resource_templates(cursor: cursor)
241
- all_templates.concat(page.resource_templates)
242
- next_cursor = page.next_cursor
243
- break if next_cursor.nil? || seen.include?(next_cursor)
244
-
245
- seen << next_cursor
246
- cursor = next_cursor
247
- end
248
-
249
- all_templates
207
+ fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
250
208
  end
251
209
 
252
210
  # Returns a single page of prompts from the server.
@@ -275,21 +233,7 @@ module MCP
275
233
  # @return [Array<Hash>] An array of available prompts.
276
234
  def prompts
277
235
  # TODO: consider renaming to `list_all_prompts`.
278
- all_prompts = []
279
- seen = Set.new
280
- cursor = nil
281
-
282
- loop do
283
- page = list_prompts(cursor: cursor)
284
- all_prompts.concat(page.prompts)
285
- next_cursor = page.next_cursor
286
- break if next_cursor.nil? || seen.include?(next_cursor)
287
-
288
- seen << next_cursor
289
- cursor = next_cursor
290
- end
291
-
292
- all_prompts
236
+ fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
293
237
  end
294
238
 
295
239
  # Calls a tool via the transport layer and returns the full response from the server.
@@ -380,6 +324,27 @@ module MCP
380
324
 
381
325
  private
382
326
 
327
+ # Walks every page of a list endpoint, following `next_cursor`, and returns
328
+ # the page results. The `seen` set guards against a server that repeats or
329
+ # cycles cursors, so the loop always terminates.
330
+ def fetch_all_pages
331
+ pages = []
332
+ seen = Set.new
333
+ cursor = nil
334
+
335
+ loop do
336
+ page = yield(cursor)
337
+ pages << page
338
+ next_cursor = page.next_cursor
339
+ break if next_cursor.nil? || seen.include?(next_cursor)
340
+
341
+ seen << next_cursor
342
+ cursor = next_cursor
343
+ end
344
+
345
+ pages
346
+ end
347
+
383
348
  def request(method:, params: nil)
384
349
  request_body = {
385
350
  jsonrpc: JsonRpcHandler::Version::V2_0,
data/lib/mcp/resource.rb CHANGED
@@ -5,15 +5,16 @@ require_relative "resource/embedded"
5
5
 
6
6
  module MCP
7
7
  class Resource
8
- attr_reader :uri, :name, :title, :description, :icons, :mime_type, :meta
8
+ attr_reader :uri, :name, :title, :description, :icons, :mime_type, :size, :meta
9
9
 
10
- def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil)
10
+ def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, size: nil, meta: nil)
11
11
  @uri = uri
12
12
  @name = name
13
13
  @title = title
14
14
  @description = description
15
15
  @icons = icons
16
16
  @mime_type = mime_type
17
+ @size = size
17
18
  @meta = meta
18
19
  end
19
20
 
@@ -25,6 +26,7 @@ module MCP
25
26
  description: description,
26
27
  icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
27
28
  mimeType: mime_type,
29
+ size: size,
28
30
  _meta: meta,
29
31
  }.compact
30
32
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.18.0"
4
+ VERSION = "0.19.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: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -87,7 +87,7 @@ licenses:
87
87
  - Apache-2.0
88
88
  metadata:
89
89
  allowed_push_host: https://rubygems.org
90
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.18.0
90
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.19.0
91
91
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
92
92
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
93
93
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues