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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module Auth
5
+ class OAuthProvider
6
+ # The authorization requests still pending for one MCP server, as every
7
+ # provider sharing the storage sees them: the pending-flow slot is read
8
+ # back before a code exchange is accepted, and emptied when the resource
9
+ # is known to have left the authorization server the requests were made
10
+ # with. Mixed into {OAuthProvider}; every method is private there.
11
+ module PendingRequests
12
+ private
13
+
14
+ # Whether the authorization request a response answers is still
15
+ # pending: the record in the pending slot was made with the same
16
+ # authorization server — this request's own, or a newer request's at
17
+ # that server (an older completion does not lose to a newer start
18
+ # there). The slot is the one thing every provider reads back before
19
+ # it accepts a response, so a record marked as ended is how a
20
+ # validated change of authorization server reaches a provider whose
21
+ # own view of the server never changed.
22
+ # @param pkce [PKCE] the per-request record the exchange was made with
23
+ # @return [Boolean]
24
+ def request_still_pending?(pkce)
25
+ pending = stored_pkce
26
+ pending.respond_to?(:issuer) && pending.issuer == pkce.issuer
27
+ end
28
+
29
+ # End the authorization request pending with an authorization server
30
+ # this resource is known to have left: its code exchange, arriving
31
+ # later at any provider sharing the storage, would otherwise be judged
32
+ # against the server it went to and store that server's token as the
33
+ # resource's. The record is kept, marked ({PKCE::ENDED_ISSUER}) rather
34
+ # than deleted, so a late callback is refused for the reason that
35
+ # ended it — and on a backend that cannot delete as on any other.
36
+ # @param issuer [String] the authorization server the resource left
37
+ # @return [void]
38
+ def end_pending_requests_of(issuer)
39
+ with_authorization_state_lock do
40
+ pending = stored_pkce
41
+ return unless pending.respond_to?(:issuer) && pending.issuer == issuer
42
+
43
+ storage.set_pkce(server_url, PKCE.from_h(pending.to_h.merge(issuer: PKCE::ENDED_ISSUER)))
44
+ end
45
+ rescue StandardError => e
46
+ logger.debug("The pending authorization request could not be ended: #{e.class}")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,486 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module Auth
5
+ class OAuthProvider
6
+ # Where OAuth client registration state lives, and which record answers
7
+ # for the authorization server in use.
8
+ #
9
+ # MCP 2026-07-28 (SEP-2352) makes registration state per authorization
10
+ # server: a client_id (and the secret that may come with it) is issued
11
+ # by one authorization server and means nothing at another. One MCP
12
+ # server can be served by more than one authorization server over its
13
+ # lifetime — a migration, a challenge that names another one, a host
14
+ # that configures a second — so credentials keyed by the resource URL
15
+ # alone cannot hold both: configuring the second replaces the first, and
16
+ # coming back to the first reports "these credentials belong to another
17
+ # authorization server" instead of finding its registration.
18
+ #
19
+ # So every record is additionally kept under a key of its own
20
+ # authorization server ({#client_registration_key}), while the resource
21
+ # URL stays the key of the registration currently in use. That keeps the
22
+ # documented storage interface intact — a backend still sees opaque
23
+ # string keys, and records written by earlier versions are found where
24
+ # they were left — while giving each authorization server registration
25
+ # state of its own.
26
+ #
27
+ # Mixed into OAuthProvider; every method relies on its state.
28
+ module RegistrationStore
29
+ # The storage key the registration state of one authorization server
30
+ # is kept under. The resource URL itself is the key of the record in
31
+ # use (and of records persisted before this layout existed); each
32
+ # authorization server additionally has a key derived from the
33
+ # resource URL and its issuer identifier, so a host can configure
34
+ # credentials for several authorization servers behind one MCP server:
35
+ #
36
+ # storage.set_client_info(provider.client_registration_key(issuer), credentials)
37
+ #
38
+ # A normalized server URL never carries a fragment, so the separator
39
+ # cannot collide with a resource URL.
40
+ # @param issuer [String, nil] the issuer identifier of the authorization server
41
+ # @return [String] the storage key for that server's registration state
42
+ def client_registration_key(issuer)
43
+ return server_url unless issuer.is_a?(String) && !issuer.empty? && issuer != Token::RETIRED_ISSUER
44
+
45
+ "#{server_url}#authorization_server=#{issuer}"
46
+ end
47
+
48
+ private
49
+
50
+ # Get or register OAuth client, following the MCP 2025-11-25 client
51
+ # registration priority order: pre-registered/cached client information
52
+ # first, then Client ID Metadata Documents (SEP-991) when the
53
+ # authorization server advertises support and a metadata URL is
54
+ # configured, then Dynamic Client Registration as a fallback.
55
+ # @param server_metadata [ServerMetadata] Authorization server metadata
56
+ # @return [ClientInfo] Client information
57
+ # @raise [MCPClient::Errors::ConnectionError] if registration fails
58
+ def get_or_register_client(server_metadata)
59
+ # 1. Credentials for the authorization server in use: its own
60
+ # registration state first, then the resource slot.
61
+ if (client_info = usable_client_info(server_metadata))
62
+ logger.debug("Using cached OAuth client for #{server_url}")
63
+ return client_info
64
+ end
65
+
66
+ # 2. Client ID Metadata Documents (SEP-991): the HTTPS metadata URL is
67
+ # itself the client_id — no registration request is needed.
68
+ if client_id_metadata_url && server_metadata.supports_client_id_metadata_documents?
69
+ return client_info_from_metadata_url
70
+ end
71
+
72
+ # 3. Dynamic Client Registration (RFC 7591) fallback
73
+ logger.debug('No cached client found, registering new OAuth client...')
74
+ if server_metadata.supports_registration?
75
+ register_client(server_metadata)
76
+ else
77
+ raise MCPClient::Errors::ConnectionError,
78
+ 'Dynamic client registration not supported and no client credentials found'
79
+ end
80
+ end
81
+
82
+ # The stored credentials the authorization server in use accepts, or
83
+ # nil when the client has to register with it.
84
+ # @param server_metadata [ServerMetadata] the authorization server in use
85
+ # @return [ClientInfo, nil]
86
+ # @raise [MCPClient::Errors::ConnectionError] for pre-registered credentials of another issuer
87
+ def usable_client_info(server_metadata)
88
+ issuer = server_metadata.issuer
89
+ in_use = stored_client_info
90
+ in_use = nil if in_use&.client_secret_expired?
91
+ # Credentials a host pre-registered WITH THIS authorization server
92
+ # come first, as the MCP 2026-07-28 client registration priority
93
+ # order says they should: "pre-registered/cached client information
94
+ # first", then Client ID Metadata Documents, then Dynamic Client
95
+ # Registration. A record this client registered for itself is the
96
+ # last of those three, so it never answers ahead of credentials the
97
+ # host configured for the very server in use — and a portable
98
+ # Client ID Metadata Document id, which answers for every
99
+ # authorization server, does not either. The one record that does
100
+ # come first is a pre-registered record for THIS server already in
101
+ # the slot: that is the slot a host writes to, so a secret rotated
102
+ # there is not overruled by the older copy under the server's key.
103
+ if !pre_registered_for?(in_use, issuer) && (pre_registered = pre_registered_for_issuer(issuer))
104
+ return adopt_client_info(pre_registered, issuer)
105
+ end
106
+
107
+ # The registration in use answers whenever the authorization server
108
+ # in use can be asked to accept it. It is the slot a host writes to,
109
+ # so credentials rotated there are never overruled by an older copy
110
+ # kept under the same authorization server's key.
111
+ if answers_for_issuer?(in_use, issuer) && !unbound_static?(in_use) &&
112
+ (accepted = client_info_for_issuer(in_use, issuer, server_metadata))
113
+ return accepted
114
+ end
115
+
116
+ # Otherwise the registration made with THIS authorization server, if
117
+ # one was kept: another server having taken the resource slot does
118
+ # not revoke it (SEP-2352).
119
+ own = registration_for_issuer(issuer)
120
+ return adopt_client_info(own, issuer) if own
121
+
122
+ refuse_unbound_static_credentials!(issuer) if unbound_static?(in_use)
123
+ # A record of another authorization server, with nothing kept for
124
+ # this one, is reported (pre-registered) or discarded (dynamic).
125
+ return nil if in_use.nil? || answers_for_issuer?(in_use, issuer)
126
+
127
+ client_info_for_issuer(in_use, issuer, server_metadata)
128
+ end
129
+
130
+ # Whether a record is credentials the host pre-registered with one
131
+ # particular authorization server.
132
+ # @param client_info [ClientInfo, nil]
133
+ # @param issuer [String] the issuer of the authorization server in use
134
+ # @return [Boolean]
135
+ def pre_registered_for?(client_info, issuer)
136
+ client_info.respond_to?(:pre_registered?) && client_info.pre_registered? &&
137
+ record_bound_to?(client_info, issuer)
138
+ end
139
+
140
+ # Credentials a host pre-registered but never said which authorization
141
+ # server issued them.
142
+ #
143
+ # MCP 2026-07-28 "Authorization Server Binding" keys credentials by
144
+ # the authorization server that ISSUED them, and whichever server
145
+ # discovery happens to return does not establish that: a resource
146
+ # that starts advertising another authorization server would relabel
147
+ # the host's credentials as belonging to it, and the code exchange
148
+ # would then post the client secret registered with one server to a
149
+ # different one. A client id and secret cannot be re-derived by this
150
+ # client the way a dynamic registration can, so they are not
151
+ # discarded either — the host is told to name their authorization
152
+ # server, and nothing is sent anywhere until it does.
153
+ # @param client_info [ClientInfo, nil]
154
+ # @return [Boolean]
155
+ def unbound_static?(client_info)
156
+ client_info.respond_to?(:pre_registered?) && client_info.pre_registered? &&
157
+ client_info.issuer.nil? && !portable_client?(client_info)
158
+ end
159
+
160
+ # @param issuer [String] the authorization server discovery found
161
+ # @return [void]
162
+ # @raise [MCPClient::Errors::ConnectionError]
163
+ def refuse_unbound_static_credentials!(issuer)
164
+ raise MCPClient::Errors::ConnectionError,
165
+ 'Pre-registered OAuth client credentials record no authorization server, so the one this ' \
166
+ "resource advertises (#{safe_error_text(issuer)}) cannot be assumed to have issued them; " \
167
+ 'store them with issuer: naming the authorization server that issued them, or under ' \
168
+ 'storage.set_client_info(provider.client_registration_key(issuer), credentials)'
169
+ end
170
+
171
+ # Whether the registration in use is one the authorization server in
172
+ # use can be asked to accept: bound to it, not bound at all (a record
173
+ # persisted before issuers were recorded), or portable.
174
+ # @param client_info [ClientInfo, nil]
175
+ # @param issuer [String] the issuer of the authorization server in use
176
+ # @return [Boolean]
177
+ def answers_for_issuer?(client_info, issuer)
178
+ return false unless client_info
179
+ return true unless client_info.respond_to?(:issuer)
180
+
181
+ client_info.issuer.nil? || client_info.issuer == issuer || portable_client?(client_info)
182
+ end
183
+
184
+ # The registration state kept for one authorization server, if it is
185
+ # usable: bound to that server and with a secret that has not expired.
186
+ # @param issuer [String, nil] the issuer identifier
187
+ # @return [ClientInfo, nil]
188
+ def registration_for_issuer(issuer)
189
+ key = client_registration_key(issuer)
190
+ return nil if key == server_url
191
+
192
+ record = registration_under_key(key, issuer)
193
+ return nil unless record && record_bound_to?(record, issuer) && !record.client_secret_expired?
194
+
195
+ record
196
+ end
197
+
198
+ # The record kept under one authorization server's key, bound to that
199
+ # server. The key names the server, so credentials a host seeded there
200
+ # without `issuer:` — the documented alternative to naming it on the
201
+ # record — belong to it as much as a record that says so itself.
202
+ # @param key [String] the per-issuer storage key
203
+ # @param issuer [String] the authorization server the key is derived from
204
+ # @return [ClientInfo, nil]
205
+ def registration_under_key(key, issuer)
206
+ record = read_client_info(key)
207
+ return record unless record.respond_to?(:issuer) && record.issuer.nil? && record.respond_to?(:with_issuer)
208
+ return record if portable_client?(record)
209
+
210
+ record.with_issuer(issuer)
211
+ end
212
+
213
+ # One read of a per-issuer key. A backend given a key it has never
214
+ # seen answers nil, but one that validates its keys may object, and
215
+ # not having that record is not a reason to fail a flow: it is the
216
+ # same answer as an empty slot, and the resource slot is consulted
217
+ # next.
218
+ # @param key [String] the storage key
219
+ # @return [ClientInfo, nil]
220
+ def read_client_info(key)
221
+ stored_client_info(key)
222
+ rescue StandardError => e
223
+ logger.debug("The OAuth client registration under #{key.inspect} could not be read (#{e.class})")
224
+ nil
225
+ end
226
+
227
+ # Make these credentials the ones the resource slot holds, so every
228
+ # resource-keyed read — the code exchange, the refresh, the check that
229
+ # the credentials did not change during a flow — sees the registration
230
+ # in use. Whatever they displace is kept under its own authorization
231
+ # server's key first.
232
+ # @param client_info [ClientInfo] the credentials to put in use
233
+ # @param issuer [String] the authorization server they belong to
234
+ # @return [ClientInfo] the same credentials
235
+ def adopt_client_info(client_info, issuer)
236
+ current = stored_client_info
237
+ return client_info if current && same_credentials?(current, client_info) && record_bound_to?(current, issuer)
238
+
239
+ preserve_client_registration(current)
240
+ write_client_info!(client_info)
241
+ client_info
242
+ end
243
+
244
+ # Whether two records are the same credentials: the same client id
245
+ # is not enough, since a dynamic registration and a pre-registered
246
+ # client of one authorization server may share it with different
247
+ # secrets — and the code is redeemed with whatever the slot holds.
248
+ # @param one [ClientInfo]
249
+ # @param other [ClientInfo]
250
+ # @return [Boolean]
251
+ def same_credentials?(one, other)
252
+ one.client_id == other.client_id && one.client_secret == other.client_secret &&
253
+ resolved_registration_type(one) == resolved_registration_type(other)
254
+ end
255
+
256
+ # MCP 2026-07-28 "Authorization Server Binding": credentials are keyed
257
+ # by the issuer that produced them. A Client ID Metadata Document
258
+ # client id is portable; unbound credentials are bound to the current
259
+ # issuer on first use; pre-registered credentials for another issuer
260
+ # are an error rather than silently reused; a dynamic registration for
261
+ # another issuer is discarded so the caller re-registers — and is kept
262
+ # under its own authorization server's key either way, so returning to
263
+ # that server finds it again.
264
+ # @param client_info [ClientInfo] the cached credentials
265
+ # @param issuer [String] the issuer of the authorization server in use
266
+ # @return [ClientInfo, nil] usable credentials, or nil when a new registration is needed
267
+ # @raise [MCPClient::Errors::ConnectionError] for pre-registered credentials of another issuer
268
+ # @param server_metadata [ServerMetadata, nil] the authorization server in use
269
+ def client_info_for_issuer(client_info, issuer, server_metadata = nil)
270
+ return client_info unless client_info.respond_to?(:issuer)
271
+
272
+ if portable_client?(client_info)
273
+ # A portable id is only usable where Client ID Metadata Documents
274
+ # are accepted; elsewhere the caller registers or reports that no
275
+ # credentials exist.
276
+ return nil if server_metadata && !server_metadata.supports_client_id_metadata_documents?
277
+ # A Client ID Metadata Document client persisted before the type
278
+ # was recorded is migrated so later checks need no inference.
279
+ return client_info if client_info.registration_type == 'cimd'
280
+
281
+ migrated = client_info.with_issuer(client_info.issuer, registration_type: 'cimd')
282
+ store_client_info(migrated)
283
+ return migrated
284
+ end
285
+
286
+ if client_info.issuer.nil?
287
+ bound = client_info.with_issuer(issuer, registration_type: resolved_registration_type(client_info))
288
+ store_client_info(bound)
289
+ return bound
290
+ end
291
+ return preserved_client_info(client_info) if client_info.issuer == issuer
292
+
293
+ if client_info.pre_registered?
294
+ preserve_client_registration(client_info)
295
+ raise MCPClient::Errors::ConnectionError,
296
+ 'Pre-registered OAuth client credentials belong to authorization server ' \
297
+ "#{safe_error_text(client_info.issuer)}, but the server now uses #{safe_error_text(issuer)}; " \
298
+ 'register the client with the new authorization server'
299
+ end
300
+
301
+ logger.warn("Discarding the OAuth client registered with #{safe_error_text(client_info.issuer)}: " \
302
+ "the authorization server is now #{safe_error_text(issuer)}")
303
+ preserve_client_registration(client_info)
304
+ delete_client_info
305
+ withdraw_token(client_info.issuer)
306
+ nil
307
+ end
308
+
309
+ # Storage backends may persist plain hashes (the FileTokenStorage
310
+ # example does); records are normalized before any field is read.
311
+ # @param key [String] the storage key to read (the resource slot by default)
312
+ # @return [ClientInfo, nil]
313
+ def stored_client_info(key = server_url)
314
+ client_info = normalize_record(storage.get_client_info(key), ClientInfo)
315
+ # A backend without delete_client_info is asked to store nil, and one
316
+ # that persists plain hashes writes `nil.to_h` — `{}`. Read back that
317
+ # is a record without a client id, which would be bound to the current
318
+ # issuer and reused instead of registering, making an authorization
319
+ # request with an empty client_id. A hash-persisting backend can just
320
+ # as well read back a client_id of any other JSON type, which would go
321
+ # into the authorization URL `to_s`-mangled and be rejected only on
322
+ # the way back, after the browser had been opened. Neither is a
323
+ # client: both are the absence storage meant to express, so
324
+ # registration happens first. The bytes are required exactly as a
325
+ # token's are.
326
+ return nil if client_info.respond_to?(:client_id) && !client_id_bytes?(client_info.client_id)
327
+
328
+ client_info
329
+ end
330
+
331
+ # Persist credentials as the registration in use and, when they are
332
+ # bound to an authorization server, as that server's registration
333
+ # state, so a later return to it finds them.
334
+ # @param client_info [ClientInfo]
335
+ # @return [void]
336
+ def store_client_info(client_info)
337
+ write_client_info!(client_info)
338
+ preserve_client_registration(client_info)
339
+ end
340
+
341
+ # Keep a record under its own authorization server's key. Records that
342
+ # name no authorization server (a portable Client ID Metadata Document
343
+ # client, a retired one) have no key of their own and stay where they
344
+ # are.
345
+ #
346
+ # A registration this client made for itself never replaces
347
+ # credentials the host pre-registered with the same authorization
348
+ # server: that key is where a host seeds them, and overwriting it
349
+ # would lose configuration this client cannot re-create — the next
350
+ # flow would register dynamically again and the pre-registered client
351
+ # would never be used at that server again.
352
+ # @param client_info [ClientInfo, nil]
353
+ # @return [void]
354
+ def preserve_client_registration(client_info)
355
+ return unless client_info.respond_to?(:issuer)
356
+
357
+ key = client_registration_key(client_info.issuer)
358
+ return if key == server_url
359
+ if !pre_registered_for?(client_info, client_info.issuer) &&
360
+ pre_registered_for?(registration_under_key(key, client_info.issuer), client_info.issuer)
361
+ return
362
+ end
363
+
364
+ write_client_info(key, client_info)
365
+ end
366
+
367
+ # @param client_info [ClientInfo] credentials that are already in use
368
+ # @return [ClientInfo] the same credentials, kept under their own key too
369
+ def preserved_client_info(client_info)
370
+ preserve_client_registration(client_info)
371
+ client_info
372
+ end
373
+
374
+ # One write to the storage backend under a per-authorization-server
375
+ # key. A backend that refuses a key it has not seen before must not
376
+ # take down a flow whose credentials are in hand: this copy is a
377
+ # convenience for a later switch, and the registration in use is
378
+ # written by {#write_client_info!}.
379
+ # @param key [String] the storage key
380
+ # @param client_info [ClientInfo, nil]
381
+ # @return [void]
382
+ def write_client_info(key, client_info)
383
+ storage.set_client_info(key, client_info)
384
+ rescue StandardError => e
385
+ logger.debug("The OAuth client registration could not be stored under #{key.inspect} (#{e.class})")
386
+ end
387
+
388
+ # The write the flow depends on. {#complete_authorization_flow} reads
389
+ # the resource slot to redeem the code, so credentials that never
390
+ # reached it produce an authorization URL the user follows and a
391
+ # callback that answers "Missing PKCE or client info" — a failure
392
+ # after consent, blamed on the callback. A backend that cannot store
393
+ # them says so now, before the browser is opened. (The optional
394
+ # per-issuer copy is still best-effort; only this one is essential.)
395
+ # @param client_info [ClientInfo] the credentials the flow will use
396
+ # @return [void]
397
+ # @raise [MCPClient::Errors::ConnectionError] when the backend refuses the write
398
+ def write_client_info!(client_info)
399
+ storage.set_client_info(server_url, client_info)
400
+ rescue StandardError => e
401
+ raise MCPClient::Errors::ConnectionError,
402
+ "The OAuth client registration could not be stored (#{e.class}: " \
403
+ "#{safe_error_text(e.message)}); the authorization cannot continue"
404
+ end
405
+
406
+ # The registration type of stored credentials, recognizing a Client
407
+ # ID Metadata Document client persisted before the type was recorded
408
+ # by its client id (the configured metadata URL).
409
+ # @param client_info [ClientInfo]
410
+ # @return [String]
411
+ def resolved_registration_type(client_info)
412
+ return client_info.registration_type if client_info.registration_type
413
+ return 'cimd' if client_id_metadata_url && client_info.client_id == client_id_metadata_url
414
+
415
+ client_info.effective_registration_type
416
+ end
417
+
418
+ # @param client_info [ClientInfo]
419
+ # @return [Boolean] whether the client id is portable across authorization servers
420
+ def portable_client?(client_info)
421
+ resolved_registration_type(client_info) == 'cimd'
422
+ end
423
+
424
+ # The credentials a host pre-registered with one authorization server,
425
+ # if it did. A dynamic registration is not one: it is this client's
426
+ # own record of a registration it made, which a portable id does not
427
+ # displace.
428
+ # @param issuer [String, nil] the issuer identifier
429
+ # @return [ClientInfo, nil]
430
+ def pre_registered_for_issuer(issuer)
431
+ record = registration_for_issuer(issuer)
432
+ record if record.respond_to?(:pre_registered?) && record.pre_registered?
433
+ end
434
+
435
+ # Forget the registration in use. The per-issuer record of the
436
+ # authorization server it belonged to is deliberately kept: that
437
+ # registration is still valid at that server (SEP-2352), and it is the
438
+ # resource slot — the answer to "which credentials does this MCP
439
+ # server use now" — that a server change invalidates.
440
+ # @return [void]
441
+ def delete_client_info
442
+ # Prefer an explicit delete; fall back to the always-available
443
+ # set_client_info(nil) so custom storage backends are handled too.
444
+ # A backend that refuses either must not stop the authorization
445
+ # server switch: the credentials stay bound to the previous issuer
446
+ # and are discarded again by the next flow.
447
+ if storage.respond_to?(:delete_client_info)
448
+ storage.delete_client_info(server_url)
449
+ else
450
+ storage.set_client_info(server_url, nil)
451
+ end
452
+ rescue StandardError => e
453
+ logger.warn('The OAuth client registration for the previous authorization server could not be removed ' \
454
+ "from storage (#{e.class}); implement delete_client_info(server_url) on the storage backend.")
455
+ end
456
+
457
+ # Build client information for a Client ID Metadata Document client
458
+ # (SEP-991): the configured HTTPS metadata URL is used directly as the
459
+ # client_id in authorization and token requests, without a dynamic
460
+ # registration POST. Serving the metadata JSON at that URL is the
461
+ # application's responsibility, not this library's.
462
+ # @return [ClientInfo] Client information with the metadata URL as client_id
463
+ def client_info_from_metadata_url
464
+ logger.debug("Using Client ID Metadata Document URL as client_id: #{client_id_metadata_url}")
465
+
466
+ metadata = ClientMetadata.new(
467
+ redirect_uris: [redirect_uri],
468
+ token_endpoint_auth_method: 'none', # Public client
469
+ grant_types: %w[authorization_code refresh_token],
470
+ response_types: ['code'],
471
+ scope: resolved_scope,
472
+ **@extra_client_metadata
473
+ )
474
+
475
+ client_info = ClientInfo.new(client_id: client_id_metadata_url, metadata: metadata,
476
+ registration_type: 'cimd')
477
+
478
+ # Persist so complete_authorization_flow and token refresh can find it
479
+ store_client_info(client_info)
480
+
481
+ client_info
482
+ end
483
+ end
484
+ end
485
+ end
486
+ end