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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|