ruby-mcp-client 2.1.0 → 3.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -12,20 +12,63 @@ module MCPClient
12
12
  module Auth
13
13
  # OAuth token model representing access/refresh tokens
14
14
  class Token
15
- attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at
15
+ attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at, :issuer
16
16
 
17
17
  # @param access_token [String] The access token
18
18
  # @param token_type [String] Token type (default: "Bearer")
19
19
  # @param expires_in [Integer, nil] Token lifetime in seconds
20
20
  # @param scope [String, nil] Token scope
21
21
  # @param refresh_token [String, nil] Refresh token for renewal
22
- def initialize(access_token:, token_type: 'Bearer', expires_in: nil, scope: nil, refresh_token: nil)
22
+ # @param issuer [String, nil] issuer identifier of the authorization server that issued the token
23
+ # (MCP 2026-07-28: tokens are per authorization server and never presented to another)
24
+ # @param expires_at [Time, String, nil] an already-known expiry, as persisted by {#to_h}; it
25
+ # takes precedence over expires_in
26
+ def initialize(access_token:, token_type: 'Bearer', expires_in: nil, scope: nil, refresh_token: nil,
27
+ issuer: nil, expires_at: nil)
23
28
  @access_token = access_token
24
29
  @token_type = token_type
25
- @expires_in = expires_in
30
+ # A record read back from a storage backend that persists plain
31
+ # hashes carries whatever was written (or edited) there. The lifetime
32
+ # is validated BEFORE it is added to a Time: `Time.now + "3600"` is a
33
+ # TypeError out of `OAuthProvider#access_token` — a crash in the
34
+ # request path, over a record the read-path checks were about to
35
+ # refuse anyway.
36
+ @expires_in = self.class.usable_lifetime(expires_in)
26
37
  @scope = scope
38
+ @issuer = issuer
27
39
  @refresh_token = refresh_token
28
- @expires_at = expires_in ? Time.now + expires_in : nil
40
+ @expires_at = resolve_expiry(expires_in, expires_at)
41
+ end
42
+
43
+ # An expiry this client cannot read is recorded as this instant. An
44
+ # unreadable expiry is not "no expiry": read that way, a mangled
45
+ # lifetime would make a token that never expires and is never
46
+ # refreshed. Read as an instant long past, the record is refreshed or
47
+ # re-authorized instead — and says so again after a round trip through
48
+ # storage.
49
+ UNREADABLE_EXPIRY = Time.at(0).utc.freeze
50
+
51
+ # A lifetime that can be added to a Time. RFC 6749 Section 5.1 makes
52
+ # expires_in a number of seconds; anything else says nothing.
53
+ # @param value [Object, nil]
54
+ # @return [Integer, Float, nil]
55
+ def self.usable_lifetime(value)
56
+ value if value.is_a?(Integer) || value.is_a?(Float)
57
+ end
58
+
59
+ # An expiry instant, as persisted by {#to_h} (an ISO 8601 string) or as
60
+ # given. Unparseable text, or a value of another type, is no expiry.
61
+ # @param value [Object, nil]
62
+ # @return [Time, nil]
63
+ def self.usable_expiry(value)
64
+ return value if value.is_a?(Time)
65
+ return nil unless value.is_a?(String)
66
+
67
+ begin
68
+ Time.parse(value)
69
+ rescue ArgumentError
70
+ nil
71
+ end
29
72
  end
30
73
 
31
74
  # Check if the token is expired
@@ -44,6 +87,23 @@ module MCPClient
44
87
  Time.now >= (@expires_at - 300) # 5 minutes buffer
45
88
  end
46
89
 
90
+ # Issuer value marking a token that must never be presented again
91
+ # (retired after an authorization server change, on a storage backend
92
+ # that cannot delete it).
93
+ RETIRED_ISSUER = 'urn:mcp:retired-token'
94
+
95
+ # A copy of this token bound to an authorization server (or retired).
96
+ # @param issuer [String]
97
+ # @return [Token]
98
+ def with_issuer(issuer)
99
+ self.class.from_h(to_h.merge(issuer: issuer))
100
+ end
101
+
102
+ # @return [Boolean] whether the token was retired
103
+ def retired?
104
+ @issuer == RETIRED_ISSUER
105
+ end
106
+
47
107
  # Convert token to authorization header value
48
108
  # @return [String] Authorization header value
49
109
  def to_header
@@ -60,34 +120,55 @@ module MCPClient
60
120
  scope: @scope,
61
121
  refresh_token: @refresh_token,
62
122
  expires_at: @expires_at&.iso8601
63
- }
123
+ }.tap { |hash| hash[:issuer] = @issuer if @issuer }
64
124
  end
65
125
 
126
+ # The instant this token expires: the recorded one, else the lifetime
127
+ # added to now, else none at all — and {UNREADABLE_EXPIRY} when the
128
+ # record carries an expiry, or a lifetime, that is neither.
129
+ #
130
+ # A record that carries an expires_at is answered by that expires_at
131
+ # alone. `expires_in` is the lifetime of a token at the moment it was
132
+ # ISSUED (RFC 6749 Section 5.1), and a record read back from storage was
133
+ # not issued now: reading `{ expires_in: 3600, expires_at: 'not a time' }`
134
+ # — the exact shape {#to_h} persists, with the one field this client
135
+ # depends on mangled — as an hour from now hands back a token that is
136
+ # very likely expired and is never refreshed until a request fails with
137
+ # it. An expiry that cannot be read is not a fresh lifetime; it is an
138
+ # expiry this client cannot vouch for, so the record fails closed and
139
+ # is refreshed or re-authorized instead.
140
+ # @param expires_in [Object, nil] the lifetime as given
141
+ # @param expires_at [Object, nil] the expiry as given
142
+ # @return [Time, nil]
143
+ def resolve_expiry(expires_in, expires_at)
144
+ return self.class.usable_expiry(expires_at) || UNREADABLE_EXPIRY unless expires_at.nil?
145
+ return Time.now + @expires_in if @expires_in
146
+ return nil if expires_in.nil?
147
+
148
+ UNREADABLE_EXPIRY
149
+ end
150
+ private :resolve_expiry
151
+
66
152
  # Create token from hash
67
153
  # @param data [Hash] Token data
68
154
  # @return [Token] New token instance
69
155
  def self.from_h(data)
70
- token = new(
156
+ new(
71
157
  access_token: data[:access_token] || data['access_token'],
72
158
  token_type: data[:token_type] || data['token_type'] || 'Bearer',
73
159
  expires_in: data[:expires_in] || data['expires_in'],
74
160
  scope: data[:scope] || data['scope'],
75
- refresh_token: data[:refresh_token] || data['refresh_token']
161
+ refresh_token: data[:refresh_token] || data['refresh_token'],
162
+ issuer: data[:issuer] || data['issuer'],
163
+ expires_at: data[:expires_at] || data['expires_at']
76
164
  )
77
-
78
- # Set expires_at if provided
79
- if (expires_at_str = data[:expires_at] || data['expires_at'])
80
- token.instance_variable_set(:@expires_at, Time.parse(expires_at_str))
81
- end
82
-
83
- token
84
165
  end
85
166
  end
86
167
 
87
168
  # OAuth client metadata for registration and authorization
88
169
  class ClientMetadata
89
170
  attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope,
90
- :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts
171
+ :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri, :contacts, :application_type
91
172
 
92
173
  # @param redirect_uris [Array<String>] List of valid redirect URIs
93
174
  # @param token_endpoint_auth_method [String] Authentication method for token endpoint
@@ -100,11 +181,13 @@ module MCPClient
100
181
  # @param tos_uri [String, nil] URL of the client terms of service
101
182
  # @param policy_uri [String, nil] URL of the client privacy policy
102
183
  # @param contacts [Array<String>, nil] List of contact emails for the client
184
+ # @param application_type [String, nil] OIDC application type ('native' or 'web'), required in
185
+ # Dynamic Client Registration by MCP 2026-07-28
103
186
  def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
104
187
  grant_types: %w[authorization_code refresh_token],
105
188
  response_types: ['code'], scope: nil,
106
189
  client_name: nil, client_uri: nil, logo_uri: nil,
107
- tos_uri: nil, policy_uri: nil, contacts: nil)
190
+ tos_uri: nil, policy_uri: nil, contacts: nil, application_type: nil)
108
191
  @redirect_uris = redirect_uris
109
192
  @token_endpoint_auth_method = token_endpoint_auth_method
110
193
  @grant_types = grant_types
@@ -116,6 +199,7 @@ module MCPClient
116
199
  @tos_uri = tos_uri
117
200
  @policy_uri = policy_uri
118
201
  @contacts = contacts
202
+ @application_type = application_type
119
203
  end
120
204
 
121
205
  # Convert to hash for HTTP requests
@@ -132,33 +216,84 @@ module MCPClient
132
216
  logo_uri: @logo_uri,
133
217
  tos_uri: @tos_uri,
134
218
  policy_uri: @policy_uri,
135
- contacts: @contacts
219
+ contacts: @contacts,
220
+ application_type: @application_type
136
221
  }.compact
137
222
  end
138
223
  end
139
224
 
140
225
  # Registered OAuth client information
141
226
  class ClientInfo
142
- attr_reader :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at, :metadata
227
+ # How the client id was obtained (MCP 2026-07-28 client registration):
228
+ # 'pre_registered' credentials belong to one authorization server and
229
+ # must not be reused with another; 'dynamic' registrations are redone
230
+ # for a new authorization server; 'cimd' (Client ID Metadata Document)
231
+ # client ids are portable across authorization servers.
232
+ REGISTRATION_TYPES = %w[pre_registered dynamic cimd].freeze
233
+
234
+ attr_reader :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at, :metadata,
235
+ :issuer, :registration_type
143
236
 
144
237
  # @param client_id [String] OAuth client ID
145
238
  # @param client_secret [String, nil] OAuth client secret (for confidential clients)
146
239
  # @param client_id_issued_at [Integer, nil] Unix timestamp when client ID was issued
147
240
  # @param client_secret_expires_at [Integer, nil] Unix timestamp when client secret expires
148
241
  # @param metadata [ClientMetadata] Client metadata
242
+ # @param issuer [String, nil] issuer identifier of the authorization server these credentials
243
+ # belong to (MCP 2026-07-28 "Authorization Server Binding")
244
+ # @param registration_type [String, nil] one of REGISTRATION_TYPES (nil: unknown, treated as a
245
+ # dynamic registration; credentials a host pre-registered should say 'pre_registered')
149
246
  def initialize(client_id:, metadata:, client_secret: nil, client_id_issued_at: nil,
150
- client_secret_expires_at: nil)
247
+ client_secret_expires_at: nil, issuer: nil, registration_type: nil)
248
+ unless registration_type.nil? || REGISTRATION_TYPES.include?(registration_type)
249
+ raise ArgumentError, "registration_type must be one of #{REGISTRATION_TYPES.join(', ')}"
250
+ end
251
+
151
252
  @client_id = client_id
152
253
  @client_secret = client_secret
153
254
  @client_id_issued_at = client_id_issued_at
154
255
  @client_secret_expires_at = client_secret_expires_at
155
256
  @metadata = metadata
257
+ @issuer = issuer
258
+ @registration_type = registration_type
156
259
  end
157
260
 
158
- # Check if client secret is expired
261
+ # @return [Boolean] whether the client id is portable across authorization servers
262
+ def portable?
263
+ @registration_type == 'cimd'
264
+ end
265
+
266
+ # @return [Boolean] whether these are pre-registered (static) credentials
267
+ def pre_registered?
268
+ effective_registration_type == 'pre_registered'
269
+ end
270
+
271
+ # The registration type. Credentials persisted before the field existed
272
+ # count as a dynamic registration: RFC 7591's client_id_issued_at is
273
+ # optional, so its absence proves nothing, and this library only ever
274
+ # stored the registrations it made. Credentials a host pre-registered
275
+ # are recorded as such explicitly (registration_type: 'pre_registered').
276
+ # @return [String]
277
+ def effective_registration_type
278
+ @registration_type || 'dynamic'
279
+ end
280
+
281
+ # A copy bound to an authorization server.
282
+ # @param issuer [String] the issuer identifier
283
+ # @param registration_type [String] the type to record (defaults to the effective type)
284
+ # @return [ClientInfo]
285
+ def with_issuer(issuer, registration_type: effective_registration_type)
286
+ self.class.new(client_id: @client_id, metadata: @metadata, client_secret: @client_secret,
287
+ client_id_issued_at: @client_id_issued_at, client_secret_expires_at: @client_secret_expires_at,
288
+ issuer: issuer, registration_type: registration_type)
289
+ end
290
+
291
+ # Check if client secret is expired. RFC 7591 Section 3.2.1 gives 0 the
292
+ # meaning "this secret does not expire", so it is not an expiry in the
293
+ # past.
159
294
  # @return [Boolean] true if client secret is expired
160
295
  def client_secret_expired?
161
- return false unless @client_secret_expires_at
296
+ return false if @client_secret_expires_at.nil? || @client_secret_expires_at.zero?
162
297
 
163
298
  Time.now.to_i >= @client_secret_expires_at
164
299
  end
@@ -171,7 +306,9 @@ module MCPClient
171
306
  client_secret: @client_secret,
172
307
  client_id_issued_at: @client_id_issued_at,
173
308
  client_secret_expires_at: @client_secret_expires_at,
174
- metadata: @metadata.to_h
309
+ metadata: @metadata.to_h,
310
+ issuer: @issuer,
311
+ registration_type: @registration_type
175
312
  }.compact
176
313
  end
177
314
 
@@ -187,7 +324,9 @@ module MCPClient
187
324
  client_secret: data[:client_secret] || data['client_secret'],
188
325
  client_id_issued_at: data[:client_id_issued_at] || data['client_id_issued_at'],
189
326
  client_secret_expires_at: data[:client_secret_expires_at] || data['client_secret_expires_at'],
190
- metadata: metadata
327
+ metadata: metadata,
328
+ issuer: data[:issuer] || data['issuer'],
329
+ registration_type: data[:registration_type] || data['registration_type']
191
330
  )
192
331
  end
193
332
 
@@ -207,7 +346,8 @@ module MCPClient
207
346
  logo_uri: metadata_data[:logo_uri] || metadata_data['logo_uri'],
208
347
  tos_uri: metadata_data[:tos_uri] || metadata_data['tos_uri'],
209
348
  policy_uri: metadata_data[:policy_uri] || metadata_data['policy_uri'],
210
- contacts: metadata_data[:contacts] || metadata_data['contacts']
349
+ contacts: metadata_data[:contacts] || metadata_data['contacts'],
350
+ application_type: metadata_data[:application_type] || metadata_data['application_type']
211
351
  )
212
352
  end
213
353
 
@@ -224,7 +364,8 @@ module MCPClient
224
364
  class ServerMetadata
225
365
  attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
226
366
  :scopes_supported, :response_types_supported, :grant_types_supported,
227
- :code_challenge_methods_supported, :client_id_metadata_document_supported
367
+ :code_challenge_methods_supported, :client_id_metadata_document_supported,
368
+ :authorization_response_iss_parameter_supported
228
369
 
229
370
  # @param issuer [String] Issuer identifier URL
230
371
  # @param authorization_endpoint [String] Authorization endpoint URL
@@ -236,9 +377,14 @@ module MCPClient
236
377
  # @param code_challenge_methods_supported [Array<String>, nil] Supported PKCE code challenge methods (RFC 8414)
237
378
  # @param client_id_metadata_document_supported [Boolean, nil] Whether the server accepts
238
379
  # Client ID Metadata Document client IDs (MCP 2025-11-25 / SEP-991)
380
+ # @param authorization_response_iss_parameter_supported [Boolean, nil] Whether the server includes
381
+ # the `iss` parameter in authorization responses (RFC 9207 Section 2.3, MCP 2026-07-28).
382
+ # Defaults to the RFC 8414 default (false, "not advertised"); an explicit nil means the
383
+ # record carries no answer at all — see {#iss_parameter_recorded?}
239
384
  def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
240
385
  scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil,
241
- code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil)
386
+ code_challenge_methods_supported: nil, client_id_metadata_document_supported: nil,
387
+ authorization_response_iss_parameter_supported: false)
242
388
  @issuer = issuer
243
389
  @authorization_endpoint = authorization_endpoint
244
390
  @token_endpoint = token_endpoint
@@ -248,6 +394,45 @@ module MCPClient
248
394
  @grant_types_supported = grant_types_supported
249
395
  @code_challenge_methods_supported = code_challenge_methods_supported
250
396
  @client_id_metadata_document_supported = client_id_metadata_document_supported
397
+ @authorization_response_iss_parameter_supported =
398
+ self.class.normalize_iss_advertisement(authorization_response_iss_parameter_supported)
399
+ end
400
+
401
+ # Whether the server advertises the RFC 9207 `iss` authorization
402
+ # response parameter; when it does, a response without `iss` MUST be
403
+ # rejected (MCP 2026-07-28 "Authorization Response Validation").
404
+ # @return [Boolean]
405
+ def iss_parameter_supported?
406
+ @authorization_response_iss_parameter_supported == true
407
+ end
408
+
409
+ # RFC 8414 makes authorization_response_iss_parameter_supported a JSON
410
+ # boolean. A document (or a persisted record) carrying anything else —
411
+ # "true", 1, {} — says nothing this client can act on, and "says
412
+ # nothing" must never be read as "not advertised": that is the reading
413
+ # that accepts a callback with no `iss` from a server that does send
414
+ # one, which is exactly the mix-up RFC 9207 exists to stop. An
415
+ # unusable answer is therefore treated as an advertisement — the check
416
+ # fails closed, refusing a response without `iss` — and is recorded as
417
+ # one, so a round trip through storage keeps the same reading. Only an
418
+ # absent value (nil) stays "no answer at all" (see
419
+ # {#iss_parameter_recorded?}).
420
+ # @param value [Object, nil] the value as given
421
+ # @return [Boolean, nil]
422
+ def self.normalize_iss_advertisement(value)
423
+ return value if value.nil? || value == true || value == false
424
+
425
+ true
426
+ end
427
+
428
+ # Whether this record actually carries an answer about the RFC 9207
429
+ # `iss` parameter. A record persisted before this client read the field
430
+ # carries none, and "no answer" must not be read as "not supported":
431
+ # that would accept a response without `iss` from a server that
432
+ # advertises it.
433
+ # @return [Boolean]
434
+ def iss_parameter_recorded?
435
+ !@authorization_response_iss_parameter_supported.nil?
251
436
  end
252
437
 
253
438
  # Check if dynamic client registration is supported
@@ -275,7 +460,8 @@ module MCPClient
275
460
  response_types_supported: @response_types_supported,
276
461
  grant_types_supported: @grant_types_supported,
277
462
  code_challenge_methods_supported: @code_challenge_methods_supported,
278
- client_id_metadata_document_supported: @client_id_metadata_document_supported
463
+ client_id_metadata_document_supported: @client_id_metadata_document_supported,
464
+ authorization_response_iss_parameter_supported: @authorization_response_iss_parameter_supported
279
465
  }.compact
280
466
  end
281
467
 
@@ -293,10 +479,27 @@ module MCPClient
293
479
  grant_types_supported: data[:grant_types_supported] || data['grant_types_supported'],
294
480
  code_challenge_methods_supported: data[:code_challenge_methods_supported] ||
295
481
  data['code_challenge_methods_supported'],
296
- client_id_metadata_document_supported: fetch_boolean(data, :client_id_metadata_document_supported)
482
+ client_id_metadata_document_supported: fetch_boolean(data, :client_id_metadata_document_supported),
483
+ authorization_response_iss_parameter_supported:
484
+ fetch_boolean(data, :authorization_response_iss_parameter_supported)
297
485
  )
298
486
  end
299
487
 
488
+ # Read an authorization server's own metadata document. An absent
489
+ # authorization_response_iss_parameter_supported in a FETCHED document
490
+ # is the server's own answer ("no", the RFC 8414 default), so it is
491
+ # recorded as an explicit false; only a record PERSISTED before this
492
+ # client read the field ({.from_h} of a hash without the key) is left
493
+ # without an answer.
494
+ # @param data [Hash] the parsed metadata document
495
+ # @return [ServerMetadata]
496
+ def self.from_discovery_document(data)
497
+ metadata = from_h(data)
498
+ return metadata if metadata.iss_parameter_recorded?
499
+
500
+ from_h(data.merge(authorization_response_iss_parameter_supported: false))
501
+ end
502
+
300
503
  # Fetch a possibly-false value from a hash by symbol or string key.
301
504
  # Unlike the `||` chains above, this preserves an explicit false.
302
505
  # @param data [Hash] Source hash
@@ -348,26 +551,78 @@ module MCPClient
348
551
 
349
552
  # PKCE (Proof Key for Code Exchange) helper
350
553
  class PKCE
351
- attr_reader :code_verifier, :code_challenge, :code_challenge_method
554
+ attr_reader :code_verifier, :code_challenge, :code_challenge_method, :issuer, :iss_parameter_supported,
555
+ :client_id, :redirect_uri, :state, :resource, :scope
556
+
557
+ # Issuer value marking an authorization request that can no longer
558
+ # complete as this resource's: the resource left the authorization
559
+ # server the request was made with (see
560
+ # {OAuthProvider::PendingRequests}). The record stays in the pending
561
+ # slot, so a late callback is refused for that reason rather than
562
+ # mistaken for a stranger's, on every storage backend alike.
563
+ ENDED_ISSUER = 'urn:mcp:ended-request'
352
564
 
353
565
  # Generate PKCE parameters
354
566
  # @param code_verifier [String, nil] Existing code verifier (for deserialization)
355
567
  # @param code_challenge [String, nil] Existing code challenge (for deserialization)
356
568
  # @param code_challenge_method [String] Challenge method (default: 'S256')
357
- def initialize(code_verifier: nil, code_challenge: nil, code_challenge_method: nil)
569
+ # @param issuer [String, nil] the selected authorization server's issuer, recorded with this
570
+ # per-request record for RFC 9207 validation of the authorization response (MCP 2026-07-28)
571
+ # @param iss_parameter_supported [Boolean, nil] whether that authorization server advertised
572
+ # authorization_response_iss_parameter_supported, recorded with the request so the response
573
+ # is judged by the server the request went to
574
+ # @param client_id [String, nil] the client id the authorization request was made with, so the
575
+ # code is redeemed with the same credentials
576
+ # @param redirect_uri [String, nil] the redirect URI the authorization request was made with, so
577
+ # the code is redeemed with the same value (RFC 6749 Section 4.1.3)
578
+ # @param state [String, nil] the `state` of the authorization request. MCP 2026-07-28 requires the
579
+ # issuer to be associated with "the same per-request record used to store the PKCE code verifier
580
+ # (and the `state` value, if used)": keeping the state in a slot of its own lets two flows sharing
581
+ # one storage backend interleave their writes until one flow's state names another flow's record,
582
+ # so it is recorded here as well and checked against the callback's state
583
+ # @param resource [String, nil] the resource (the MCP server URL) the authorization request named,
584
+ # so the token the code buys is only ever kept for that resource — a provider retargeted at
585
+ # another resource of the same authorization server while the exchange is in flight must not
586
+ # store it as the other resource's token (MCP 2026-07-28 token audience binding)
587
+ # @param scope [String, nil] the scope the authorization request asked for: a token response
588
+ # that omits `scope` granted exactly that (RFC 6749 Section 5.1), and the step-up union of a
589
+ # rebuilt provider must not lose it
590
+ def initialize(code_verifier: nil, code_challenge: nil, code_challenge_method: nil, issuer: nil,
591
+ iss_parameter_supported: nil, client_id: nil, redirect_uri: nil, state: nil,
592
+ resource: nil, scope: nil)
358
593
  @code_verifier = code_verifier || generate_code_verifier
359
594
  @code_challenge = code_challenge || generate_code_challenge(@code_verifier)
360
595
  @code_challenge_method = code_challenge_method || 'S256'
596
+ @issuer = issuer
597
+ # A record read back from a hash-persisting backend can carry
598
+ # anything here. Not a boolean is not an answer: the record says
599
+ # nothing about the `iss` parameter, so the authorization server's own
600
+ # metadata decides (and that reading fails closed), rather than a
601
+ # mangled value silently meaning "not advertised".
602
+ @iss_parameter_supported = iss_parameter_supported if [true, false].include?(iss_parameter_supported)
603
+ @client_id = client_id
604
+ @redirect_uri = redirect_uri
605
+ @state = state
606
+ @resource = resource
607
+ @scope = scope
361
608
  end
362
609
 
363
610
  # Convert to hash for serialization
364
611
  # @return [Hash] Hash representation
365
612
  def to_h
366
- {
613
+ hash = {
367
614
  code_verifier: @code_verifier,
368
615
  code_challenge: @code_challenge,
369
616
  code_challenge_method: @code_challenge_method
370
617
  }
618
+ hash[:issuer] = @issuer if @issuer
619
+ hash[:iss_parameter_supported] = @iss_parameter_supported unless @iss_parameter_supported.nil?
620
+ hash[:client_id] = @client_id if @client_id
621
+ hash[:redirect_uri] = @redirect_uri if @redirect_uri
622
+ hash[:state] = @state if @state
623
+ hash[:resource] = @resource if @resource
624
+ hash[:scope] = @scope if @scope
625
+ hash
371
626
  end
372
627
 
373
628
  # Create PKCE instance from hash
@@ -381,11 +636,22 @@ module MCPClient
381
636
  verifier = data[:code_verifier] || data['code_verifier']
382
637
  challenge = data[:code_challenge] || data['code_challenge']
383
638
  method = data[:code_challenge_method] || data['code_challenge_method']
639
+ issuer = data[:issuer] || data['issuer']
640
+ supported = if data.key?(:iss_parameter_supported)
641
+ data[:iss_parameter_supported]
642
+ else
643
+ data['iss_parameter_supported']
644
+ end
384
645
 
385
646
  raise ArgumentError, 'Missing code_verifier' unless verifier
386
647
  raise ArgumentError, 'Missing code_challenge' unless challenge
387
648
 
388
- new(code_verifier: verifier, code_challenge: challenge, code_challenge_method: method)
649
+ new(code_verifier: verifier, code_challenge: challenge, code_challenge_method: method, issuer: issuer,
650
+ iss_parameter_supported: supported, client_id: data[:client_id] || data['client_id'],
651
+ redirect_uri: data[:redirect_uri] || data['redirect_uri'],
652
+ state: data[:state] || data['state'],
653
+ resource: data[:resource] || data['resource'],
654
+ scope: data[:scope] || data['scope'])
389
655
  end
390
656
 
391
657
  private
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # A cached server result with its freshness hint (MCP 2026-07-28
5
+ # server/utilities/caching): `ttlMs` says how long the client MAY consider
6
+ # the result fresh after receipt (0 = immediately stale; absent = no hint,
7
+ # only older servers), and `cacheScope` whether the response may be shared
8
+ # across authorization contexts ("public") or not ("private").
9
+ class CachedResult
10
+ SCOPES = %w[public private].freeze
11
+
12
+ # @return [Object] the cached value (whatever the caller stored)
13
+ attr_accessor :value
14
+
15
+ # The context of a private list whose pages were fetched under different
16
+ # credentials: it belongs to no context and never matches one.
17
+ MIXED_CONTEXT = Object.new.freeze
18
+ # The context of an entry whose request nothing could record: an
19
+ # unknown request is not an anonymous one, so this belongs to no
20
+ # context either and is never served across one.
21
+ UNKNOWN_CONTEXT = Object.new.freeze
22
+ # The params fingerprint of a list whose pages were fetched under
23
+ # differing effective parameters: no request's parameters match it.
24
+ MIXED_PARAMS = Object.new.freeze
25
+ # @return [Float] monotonic receipt time (seconds)
26
+ attr_reader :received_at
27
+ # @return [Integer, Float, nil] the server's ttlMs (nil when it sent none)
28
+ attr_reader :ttl_ms
29
+ # @return [String, nil] "public", "private", or nil when absent/unknown
30
+ attr_reader :cache_scope
31
+
32
+ # The authorization context (the Authorization header) of the request
33
+ # that produced a privately scoped entry; such an entry is served only
34
+ # in that context ("MUST NOT be shared across authorization contexts").
35
+ # @return [String, nil]
36
+ attr_accessor :authorization_context
37
+
38
+ # Identity of the fetch that recorded this entry: the list that fetch
39
+ # converts afterwards attaches to this entry and to no other.
40
+ # @return [Object, nil]
41
+ attr_accessor :fetch_token
42
+
43
+ # Fingerprint of the effective request parameters (host `_meta`) the
44
+ # request that produced this entry went out with; the entry is served
45
+ # only to requests that would carry the same.
46
+ # @return [String, nil]
47
+ attr_accessor :params_fingerprint
48
+
49
+ # Build an entry from a CacheableResult.
50
+ # @param result [Hash, nil] the JSON-RPC result carrying ttlMs/cacheScope
51
+ # @param value [Object] what to cache
52
+ # @param now [Float] monotonic receipt time
53
+ # @return [CachedResult]
54
+ # @param assume_zero [Boolean] treat an absent ttlMs as 0 ("if ttlMs is absent, clients SHOULD
55
+ # assume 0"): the rule for a 2026-07-28 server; an older server keeps the client's own heuristic
56
+ def self.from_result(result, value, now:, assume_zero: false)
57
+ ttl = if result.is_a?(Hash) && result.key?('ttlMs')
58
+ normalize_ttl(result['ttlMs'])
59
+ elsif assume_zero
60
+ 0
61
+ end
62
+ # Cross-context reuse needs an explicit "public": an absent or unknown
63
+ # cacheScope keeps the entry within the authorization context that
64
+ # produced it.
65
+ scope = result.is_a?(Hash) ? result['cacheScope'] : nil
66
+ new(value: value, received_at: now, ttl_ms: ttl, cache_scope: SCOPES.include?(scope) ? scope : 'private')
67
+ end
68
+
69
+ # Combine the hints of several pages of one list into one entry: the
70
+ # shortest TTL wins, and one private page makes the whole list private.
71
+ # @param entries [Array<CachedResult>] per-page entries
72
+ # @param value [Object] the combined value
73
+ # @param now [Float] monotonic receipt time
74
+ # @return [CachedResult]
75
+ def self.combine(entries, value, now:)
76
+ hinted = entries.select(&:hint?)
77
+ # Each page expires at its own received_at + ttlMs; the combined entry
78
+ # (received now) lives until the earliest of those.
79
+ ttl = hinted.map { |e| [e.ttl_ms - ((now - e.received_at) * 1000.0), 0].max }.min
80
+ scope = if entries.any? { |e| e.cache_scope == 'private' }
81
+ 'private'
82
+ else
83
+ entries.map(&:cache_scope).compact.first
84
+ end
85
+ combined = new(value: value, received_at: now, ttl_ms: ttl, cache_scope: scope)
86
+ combined.params_fingerprint = entries.first&.params_fingerprint
87
+ combined
88
+ end
89
+
90
+ # An entry that is stale from the start: what a change notification
91
+ # leaves behind so the kind reads as "known and stale", not "unknown".
92
+ # @param now [Float] monotonic time
93
+ # @return [CachedResult]
94
+ # @param like [CachedResult, nil] the entry being replaced: its scope and
95
+ # authorization context are kept, so a stale private entry stays private
96
+ def self.stale(now:, like: nil)
97
+ entry = new(value: nil, received_at: now, ttl_ms: 0, cache_scope: like&.cache_scope)
98
+ entry.authorization_context = like&.authorization_context
99
+ entry.params_fingerprint = like&.params_fingerprint
100
+ entry
101
+ end
102
+
103
+ # "Servers MUST provide a ttlMs value that is >= 0"; anything else is
104
+ # treated as 0 (immediately stale).
105
+ # @param raw [Object] the ttlMs member
106
+ # @return [Integer, Float]
107
+ def self.normalize_ttl(raw)
108
+ return 0 unless raw.is_a?(Numeric) && raw.finite?
109
+
110
+ [raw, 0].max
111
+ end
112
+
113
+ def initialize(value:, received_at:, ttl_ms:, cache_scope:)
114
+ @value = value
115
+ @received_at = received_at
116
+ @ttl_ms = ttl_ms
117
+ # Kept frozen: the scope is compared against the exact sentinels and
118
+ # is handed out through #to_info.
119
+ @cache_scope = cache_scope.is_a?(String) ? -cache_scope : cache_scope
120
+ end
121
+
122
+ # @return [Boolean] whether the server gave a ttlMs at all
123
+ def hint?
124
+ !@ttl_ms.nil?
125
+ end
126
+
127
+ # Fresh while now < t_received + ttlMs. Without a hint the client keeps
128
+ # its own heuristic (cache until a change notification), so the entry
129
+ # counts as fresh.
130
+ # @param now [Float] monotonic time
131
+ # @return [Boolean]
132
+ def fresh?(now:)
133
+ return true unless hint?
134
+
135
+ now < @received_at + (@ttl_ms / 1000.0)
136
+ end
137
+
138
+ # @param now [Float] monotonic time
139
+ # @return [Hash] ttl_ms, cache_scope, received_at, fresh
140
+ def to_info(now:)
141
+ # Detached values: nothing a caller does to them reaches the entry.
142
+ { ttl_ms: @ttl_ms, cache_scope: @cache_scope&.dup, received_at: @received_at, fresh: fresh?(now: now) }
143
+ end
144
+ end
145
+ end