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,441 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
module Auth
|
|
7
|
+
class OAuthProvider
|
|
8
|
+
# Type checks for the four peer-controlled JSON documents a flow reads:
|
|
9
|
+
# the token response of RFC 6749 Section 5.1, the client registration
|
|
10
|
+
# response of RFC 7591 Section 3.2.1 (whose client metadata fields keep
|
|
11
|
+
# the types RFC 7591 Section 2 gives them), the protected resource
|
|
12
|
+
# metadata of RFC 9728 Section 2 and the authorization server metadata
|
|
13
|
+
# of RFC 8414 Section 2.
|
|
14
|
+
#
|
|
15
|
+
# Every document is read field by field and every field ends up
|
|
16
|
+
# somewhere that assumes its RFC type: `token_type` is capitalized into
|
|
17
|
+
# an `Authorization` header, `expires_in` is added to a `Time`,
|
|
18
|
+
# `redirect_uris` is asked for its first element,
|
|
19
|
+
# `client_secret_expires_at` is compared with a Unix timestamp,
|
|
20
|
+
# `scopes_supported` is joined into a scope parameter and
|
|
21
|
+
# `code_challenge_methods_supported` is asked whether it includes
|
|
22
|
+
# "S256". A peer that answers with the right names and the wrong JSON
|
|
23
|
+
# types would therefore crash the client with a `NoMethodError` or a
|
|
24
|
+
# `TypeError` deep inside the flow — sometimes only after a still-valid
|
|
25
|
+
# token had been overwritten — or, worse, be believed: a
|
|
26
|
+
# `code_challenge_methods_supported` of `"S256 plain"` is a String, and
|
|
27
|
+
# a String answers `include?("S256")` with true, so a server that
|
|
28
|
+
# supports no PKCE at all would read as one that does. A document whose
|
|
29
|
+
# fields are not of their RFC types is a protocol error and is refused
|
|
30
|
+
# as a whole, exactly as a token response without an access token is:
|
|
31
|
+
# no partial acceptance, no coercion of whatever JSON arrived.
|
|
32
|
+
#
|
|
33
|
+
# Mixed into OAuthProvider.
|
|
34
|
+
module ResponseValidation
|
|
35
|
+
# Where the token response's field types are specified.
|
|
36
|
+
TOKEN_RESPONSE_REFERENCE = 'RFC 6749 Section 5.1'
|
|
37
|
+
|
|
38
|
+
# RFC 6749 Section 5.1 makes BOTH access_token and token_type REQUIRED
|
|
39
|
+
# in a successful token response, and defines no default for either:
|
|
40
|
+
# "Bearer" is one value token_type may carry (RFC 6750), not what its
|
|
41
|
+
# absence means. A response that names no type says nothing about how
|
|
42
|
+
# the credential it carries may be presented, and RFC 6749 Section
|
|
43
|
+
# 7.1 is explicit that "the client MUST NOT use an access token if it
|
|
44
|
+
# does not understand the token type" — which a client that was told
|
|
45
|
+
# no type does not. So an omitted type is refused exactly as an
|
|
46
|
+
# omitted access_token is, and exactly as the DPoP and MAC types this
|
|
47
|
+
# client cannot present are, rather than guessed at and sent out
|
|
48
|
+
# behind `Authorization: Bearer`. (access_token has a check of its
|
|
49
|
+
# own, {#issued_access_token?}, which also asks for usable bytes.)
|
|
50
|
+
REQUIRED_TOKEN_RESPONSE_FIELDS = %w[token_type].freeze
|
|
51
|
+
|
|
52
|
+
# Where the registration response's field types are specified.
|
|
53
|
+
REGISTRATION_RESPONSE_REFERENCE = 'RFC 7591 Section 3.2.1'
|
|
54
|
+
|
|
55
|
+
# The fields of a successful token response and the types RFC 6749
|
|
56
|
+
# Section 5.1 gives them. access_token and token_type are REQUIRED and
|
|
57
|
+
# end up in an `Authorization` header, so they must be bytes a header
|
|
58
|
+
# can carry: an empty string is no more usable than a JSON array, and
|
|
59
|
+
# a CR or an LF would not be part of the value at all but the start of
|
|
60
|
+
# another header line. token_type must moreover name a type this
|
|
61
|
+
# client can present (see {SUPPORTED_TOKEN_TYPE}), and it is REQUIRED:
|
|
62
|
+
# see {REQUIRED_TOKEN_RESPONSE_FIELDS}.
|
|
63
|
+
# refresh_token is OPTIONAL but is a credential
|
|
64
|
+
# too: bytes or nothing, because "" would be persisted over the
|
|
65
|
+
# refresh token the client already holds. scope is free text.
|
|
66
|
+
# Fields the RFC does not name are ignored: an authorization server
|
|
67
|
+
# may return anything else it likes.
|
|
68
|
+
TOKEN_RESPONSE_FIELDS = {
|
|
69
|
+
'access_token' => :header_value,
|
|
70
|
+
'token_type' => :token_type,
|
|
71
|
+
'expires_in' => :integer,
|
|
72
|
+
'refresh_token' => :non_empty_string,
|
|
73
|
+
'scope' => :string
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
# The fields of a client registration response and their types: the
|
|
77
|
+
# registration-specific fields of RFC 7591 Section 3.2.1 followed by
|
|
78
|
+
# the client metadata of Section 2 that the server echoes back.
|
|
79
|
+
REGISTRATION_RESPONSE_FIELDS = {
|
|
80
|
+
'client_id' => :non_empty_string,
|
|
81
|
+
'client_secret' => :string,
|
|
82
|
+
'client_id_issued_at' => :integer,
|
|
83
|
+
'client_secret_expires_at' => :integer,
|
|
84
|
+
'redirect_uris' => :redirect_uri_array,
|
|
85
|
+
'token_endpoint_auth_method' => :string,
|
|
86
|
+
'grant_types' => :string_array,
|
|
87
|
+
'response_types' => :string_array,
|
|
88
|
+
'scope' => :string,
|
|
89
|
+
'client_name' => :string,
|
|
90
|
+
'client_uri' => :string,
|
|
91
|
+
'logo_uri' => :string,
|
|
92
|
+
'tos_uri' => :string,
|
|
93
|
+
'policy_uri' => :string,
|
|
94
|
+
'contacts' => :string_array,
|
|
95
|
+
'application_type' => :string
|
|
96
|
+
}.freeze
|
|
97
|
+
|
|
98
|
+
# Where the protected resource document's field types are specified.
|
|
99
|
+
RESOURCE_METADATA_REFERENCE = 'RFC 9728 Section 2'
|
|
100
|
+
|
|
101
|
+
# Where the authorization server document's field types are specified.
|
|
102
|
+
SERVER_METADATA_REFERENCE = 'RFC 8414 Section 2'
|
|
103
|
+
|
|
104
|
+
# RFC 9728 Section 2 makes `resource` REQUIRED, and this client cannot
|
|
105
|
+
# do without it: it is the identifier the confused-deputy check
|
|
106
|
+
# compares with the server URL, and a document that omits it is not a
|
|
107
|
+
# protected resource's metadata at all. Refusing it here rather than
|
|
108
|
+
# at the comparison keeps the document out of the copy
|
|
109
|
+
# {#fetch_resource_metadata} retains for scope resolution.
|
|
110
|
+
REQUIRED_RESOURCE_METADATA_FIELDS = %w[resource].freeze
|
|
111
|
+
|
|
112
|
+
# The protected resource metadata fields this client reads, and their
|
|
113
|
+
# RFC 9728 Section 2 types. `resource` is compared with the server
|
|
114
|
+
# URL, `authorization_servers` supplies the issuer discovery is driven
|
|
115
|
+
# from, and `scopes_supported` is joined into the `scope` parameter of
|
|
116
|
+
# the authorization request.
|
|
117
|
+
RESOURCE_METADATA_FIELDS = {
|
|
118
|
+
'resource' => :string,
|
|
119
|
+
'authorization_servers' => :string_array,
|
|
120
|
+
'scopes_supported' => :string_array
|
|
121
|
+
}.freeze
|
|
122
|
+
|
|
123
|
+
# The authorization server metadata fields this client reads, and
|
|
124
|
+
# their RFC 8414 Section 2 types. The two boolean advertisements
|
|
125
|
+
# (`client_id_metadata_document_supported` and
|
|
126
|
+
# `authorization_response_iss_parameter_supported`) are deliberately
|
|
127
|
+
# absent: a value that is not a boolean says nothing this client can
|
|
128
|
+
# act on, and both are already read fail-closed — "not supported" for
|
|
129
|
+
# the first, "advertised, so a response without `iss` is refused" for
|
|
130
|
+
# the second (see {MCPClient::Auth::ServerMetadata}) — which is a
|
|
131
|
+
# safer reading than refusing the document outright.
|
|
132
|
+
SERVER_METADATA_FIELDS = {
|
|
133
|
+
'issuer' => :string,
|
|
134
|
+
'authorization_endpoint' => :string,
|
|
135
|
+
'token_endpoint' => :string,
|
|
136
|
+
'registration_endpoint' => :string,
|
|
137
|
+
'scopes_supported' => :string_array,
|
|
138
|
+
'response_types_supported' => :string_array,
|
|
139
|
+
'grant_types_supported' => :string_array,
|
|
140
|
+
'code_challenge_methods_supported' => :string_array
|
|
141
|
+
}.freeze
|
|
142
|
+
|
|
143
|
+
# RFC 8414 Section 2 makes `issuer`, `authorization_endpoint` and
|
|
144
|
+
# `token_endpoint` REQUIRED, and every one of them is a URL this
|
|
145
|
+
# client parses: the issuer identifies the authorization server a
|
|
146
|
+
# token and a client are bound to, the authorization endpoint is what
|
|
147
|
+
# the browser is sent to, and the token endpoint is where the code is
|
|
148
|
+
# redeemed. A type check alone accepts a document that simply omits
|
|
149
|
+
# them — there is no field of the wrong type — so the document is
|
|
150
|
+
# cached as metadata and the flow crashes with a
|
|
151
|
+
# `URI::InvalidURIError` out of `start_authorization_flow` or
|
|
152
|
+
# `complete_authorization_flow`, by which time dynamic client
|
|
153
|
+
# registration has already created a client at the authorization
|
|
154
|
+
# server. What the RFC requires is required here, at discovery.
|
|
155
|
+
REQUIRED_SERVER_METADATA_FIELDS = %w[issuer authorization_endpoint token_endpoint].freeze
|
|
156
|
+
|
|
157
|
+
# The one access token type this client can present. RFC 6749 Section
|
|
158
|
+
# 7.1: "the client MUST NOT use an access token if it does not
|
|
159
|
+
# understand the token type". A bearer token is presented as it
|
|
160
|
+
# stands (RFC 6750 Section 2.1) and is what MCP 2026-07-28 requires;
|
|
161
|
+
# every other type is a credential this client cannot form a request
|
|
162
|
+
# with — a DPoP token needs a proof JWT of its own, a MAC token a
|
|
163
|
+
# signature — so putting its bytes behind `Authorization: DPoP` would
|
|
164
|
+
# present a credential in a way its authorization server never
|
|
165
|
+
# authorized. (It would not even be spelled right: the header is
|
|
166
|
+
# built with `String#capitalize`, which makes "DPoP" "Dpop".) The
|
|
167
|
+
# comparison is case-insensitive: RFC 6749 Section 5.1 makes the
|
|
168
|
+
# value case-insensitive, and servers do answer "bearer".
|
|
169
|
+
SUPPORTED_TOKEN_TYPE = 'bearer'
|
|
170
|
+
|
|
171
|
+
# How each type reads in a failure message.
|
|
172
|
+
TYPE_DESCRIPTIONS = {
|
|
173
|
+
string: 'a string',
|
|
174
|
+
non_empty_string: 'a non-empty string',
|
|
175
|
+
header_value: 'a non-empty string of bytes an HTTP header can carry',
|
|
176
|
+
token_type: 'a token type this client can present ("Bearer")',
|
|
177
|
+
integer: 'an integer',
|
|
178
|
+
string_array: 'an array of strings',
|
|
179
|
+
redirect_uri_array: 'an array of usable redirect URIs'
|
|
180
|
+
}.freeze
|
|
181
|
+
|
|
182
|
+
# Bytes no HTTP header field value may carry: the C0 controls (CR and
|
|
183
|
+
# LF above all, which would end the header line and start one of the
|
|
184
|
+
# peer's choosing) and DEL. RFC 6749 Appendix A is stricter still —
|
|
185
|
+
# an access token is 1*VSCHAR — but obs-text is at least transported,
|
|
186
|
+
# while a control byte is either refused by the HTTP stack or splits
|
|
187
|
+
# the request.
|
|
188
|
+
HEADER_UNSAFE_BYTE = ->(byte) { byte < 0x20 || byte == 0x7F }
|
|
189
|
+
|
|
190
|
+
# Schemes a callback can actually arrive on this client.
|
|
191
|
+
CALLBACK_SCHEMES = %w[http https].freeze
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Why a parsed token endpoint response is not a credential, if it is
|
|
196
|
+
# not one. The body is peer-controlled JSON of any shape: `200 []` and
|
|
197
|
+
# `200 null` parse to an Array and to nil, which cannot be asked for a
|
|
198
|
+
# member at all, so the shape is established before any value is read.
|
|
199
|
+
# @param data [Object, nil] the parsed JSON body
|
|
200
|
+
# @return [String, nil] the reason, or nil when the response is usable
|
|
201
|
+
def token_response_error(data)
|
|
202
|
+
unless issued_access_token?(data)
|
|
203
|
+
return "the token response carries no access_token (#{TOKEN_RESPONSE_REFERENCE})"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
missing_field_error(data, REQUIRED_TOKEN_RESPONSE_FIELDS, 'token response',
|
|
207
|
+
TOKEN_RESPONSE_REFERENCE) ||
|
|
208
|
+
mistyped_field_error(data, TOKEN_RESPONSE_FIELDS, 'token response', TOKEN_RESPONSE_REFERENCE)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Why a parsed client registration response registers no client, if it
|
|
212
|
+
# registers none.
|
|
213
|
+
# @param data [Object, nil] the parsed JSON body
|
|
214
|
+
# @return [String, nil] the reason, or nil when the response is usable
|
|
215
|
+
def registration_response_error(data)
|
|
216
|
+
unless registered_client?(data)
|
|
217
|
+
return "the registration response carries no client_id (#{REGISTRATION_RESPONSE_REFERENCE})"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
mistyped_field_error(data, REGISTRATION_RESPONSE_FIELDS, 'registration response',
|
|
221
|
+
REGISTRATION_RESPONSE_REFERENCE)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Why a parsed protected resource document is not usable metadata, if
|
|
225
|
+
# it is not. The document drives discovery and supplies the scopes of
|
|
226
|
+
# the authorization request, so a field of the wrong JSON type is
|
|
227
|
+
# refused here rather than asked for `first` or `join` later.
|
|
228
|
+
# @param data [Hash] the parsed JSON body (its Hash-ness is checked by the caller)
|
|
229
|
+
# @return [String, nil] the reason, or nil when the document is usable
|
|
230
|
+
def resource_metadata_error(data)
|
|
231
|
+
missing_field_error(data, REQUIRED_RESOURCE_METADATA_FIELDS, 'protected resource metadata',
|
|
232
|
+
RESOURCE_METADATA_REFERENCE) ||
|
|
233
|
+
mistyped_field_error(data, RESOURCE_METADATA_FIELDS, 'protected resource metadata',
|
|
234
|
+
RESOURCE_METADATA_REFERENCE)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Why a parsed authorization server document is not usable metadata,
|
|
238
|
+
# if it is not.
|
|
239
|
+
# @param data [Hash] the parsed JSON body (its Hash-ness is checked by the caller)
|
|
240
|
+
# @return [String, nil] the reason, or nil when the document is usable
|
|
241
|
+
def server_metadata_error(data)
|
|
242
|
+
missing_field_error(data, REQUIRED_SERVER_METADATA_FIELDS, 'authorization server metadata',
|
|
243
|
+
SERVER_METADATA_REFERENCE) ||
|
|
244
|
+
mistyped_field_error(data, SERVER_METADATA_FIELDS, 'authorization server metadata',
|
|
245
|
+
SERVER_METADATA_REFERENCE)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The first field a document's RFC makes REQUIRED and the document
|
|
249
|
+
# does not carry. A JSON null is an omission: it is no more a URL than
|
|
250
|
+
# an absent key is.
|
|
251
|
+
# @param data [Hash] the parsed JSON body
|
|
252
|
+
# @param fields [Array<String>] the field names the RFC requires
|
|
253
|
+
# @param label [String] what the document is, for the message
|
|
254
|
+
# @param reference [String] the RFC section the requirement comes from
|
|
255
|
+
# @return [String, nil]
|
|
256
|
+
def missing_field_error(data, fields, label, reference)
|
|
257
|
+
missing = fields.find { |field| data[field].nil? }
|
|
258
|
+
return nil unless missing
|
|
259
|
+
|
|
260
|
+
"the #{label} omits the required #{missing} (#{reference})"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The first field of a response body that is present and not of the
|
|
264
|
+
# type its RFC gives it. A field that is absent (or JSON null) is not
|
|
265
|
+
# mistyped: the optional ones may be omitted, and the required ones
|
|
266
|
+
# are checked by name before this runs.
|
|
267
|
+
# @param data [Hash] the parsed JSON body
|
|
268
|
+
# @param fields [Hash{String => Symbol}] field name to expected type
|
|
269
|
+
# @param label [String] what the document is, for the message
|
|
270
|
+
# @param reference [String] the RFC section the types come from
|
|
271
|
+
# @return [String, nil]
|
|
272
|
+
def mistyped_field_error(data, fields, label, reference)
|
|
273
|
+
fields.each do |field, type|
|
|
274
|
+
value = data[field]
|
|
275
|
+
next if value.nil? || value_of_type?(value, type)
|
|
276
|
+
|
|
277
|
+
return "the #{label}'s #{field} is not #{TYPE_DESCRIPTIONS[type]} (#{reference})"
|
|
278
|
+
end
|
|
279
|
+
nil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @param value [Object] a value read from a response body
|
|
283
|
+
# @param type [Symbol] one of the keys of TYPE_DESCRIPTIONS
|
|
284
|
+
# @return [Boolean]
|
|
285
|
+
def value_of_type?(value, type)
|
|
286
|
+
case type
|
|
287
|
+
when :string then value.is_a?(String)
|
|
288
|
+
when :non_empty_string then non_empty_string?(value)
|
|
289
|
+
when :header_value then header_value_bytes?(value)
|
|
290
|
+
when :token_type then presentable_token_type?(value)
|
|
291
|
+
# `true` and `false` are not Integers, so booleans are rejected here.
|
|
292
|
+
when :integer then value.is_a?(Integer)
|
|
293
|
+
when :string_array then value.is_a?(Array) && value.all?(String)
|
|
294
|
+
when :redirect_uri_array then value.is_a?(Array) && value.all? { |uri| redirect_uri_bytes?(uri) }
|
|
295
|
+
else false
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Whether a token record can be presented at all. Both fields
|
|
300
|
+
# {MCPClient::Auth::Token#to_header} builds the `Authorization` header
|
|
301
|
+
# out of are checked, not just the access token: a record whose
|
|
302
|
+
# access_token is absent, empty or of any other type is never a
|
|
303
|
+
# credential — its header would be a bare "Bearer " or, worse,
|
|
304
|
+
# `Bearer ["x"]`, a to_s of whatever JSON arrived, attributed to
|
|
305
|
+
# whatever authorization server is current — and a token_type that is
|
|
306
|
+
# not a string crashes `capitalize`, while one carrying CR or LF makes
|
|
307
|
+
# the header value two header lines. Storage answers with whatever it
|
|
308
|
+
# was given, so this is asked wherever a token is read, issued or
|
|
309
|
+
# applied, not only of what came off the wire.
|
|
310
|
+
# @param token [Object, nil] a token record
|
|
311
|
+
# @return [Boolean] whether it carries bytes an Authorization header can present
|
|
312
|
+
def token_bytes?(token)
|
|
313
|
+
return false unless token.respond_to?(:access_token) && access_token_bytes?(token.access_token)
|
|
314
|
+
|
|
315
|
+
token.respond_to?(:token_type) && presentable_token_type?(token.token_type)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Whether an access token of this type can be presented at all: bytes
|
|
319
|
+
# an HTTP header can carry, and a type this client understands
|
|
320
|
+
# (RFC 6749 Section 7.1). A record read back from storage is asked the
|
|
321
|
+
# same question as a token response is, so a type this client cannot
|
|
322
|
+
# honour is never presented, whichever side it came from.
|
|
323
|
+
# @param value [Object, nil] a candidate token type
|
|
324
|
+
# @return [Boolean]
|
|
325
|
+
def presentable_token_type?(value)
|
|
326
|
+
header_value_bytes?(value) && value.casecmp(SUPPORTED_TOKEN_TYPE).zero?
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The same question about a parsed token endpoint response body.
|
|
330
|
+
# @param data [Object, nil] the parsed JSON body
|
|
331
|
+
# @return [Boolean]
|
|
332
|
+
def issued_access_token?(data)
|
|
333
|
+
data.is_a?(Hash) && access_token_bytes?(data['access_token'])
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# RFC 7591 Section 3.2.1 makes client_id REQUIRED in a registration
|
|
337
|
+
# response, and it is a string: it goes into the authorization URL and
|
|
338
|
+
# into every token request. A response without usable bytes has
|
|
339
|
+
# registered nothing — accepting it sends the user to the
|
|
340
|
+
# authorization endpoint with an empty (or a `to_s`-mangled)
|
|
341
|
+
# client_id, and the flow only fails on the way back, after the
|
|
342
|
+
# browser has already been opened.
|
|
343
|
+
# @param data [Object, nil] the parsed JSON registration response
|
|
344
|
+
# @return [Boolean]
|
|
345
|
+
def registered_client?(data)
|
|
346
|
+
data.is_a?(Hash) && client_id_bytes?(data['client_id'])
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @param value [Object, nil] a candidate access token
|
|
350
|
+
# @return [Boolean] whether it is token bytes an Authorization header can carry
|
|
351
|
+
def access_token_bytes?(value)
|
|
352
|
+
header_value_bytes?(value)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# @param value [Object, nil] a candidate header field value
|
|
356
|
+
# @return [Boolean] whether it is a non-empty string an HTTP header can carry
|
|
357
|
+
def header_value_bytes?(value)
|
|
358
|
+
non_empty_string?(value) && value.each_byte.none?(&HEADER_UNSAFE_BYTE)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# A registered redirect URI is asked for its `first` and put into the
|
|
362
|
+
# authorization URL the browser is sent to. An empty string is an
|
|
363
|
+
# array element of the right JSON type and no redirect URI at all: it
|
|
364
|
+
# opens the browser with `redirect_uri=`, and the authorization server
|
|
365
|
+
# rejects the request the user was just sent into. Having a scheme is
|
|
366
|
+
# not enough either — `javascript:alert(1)` and `data:text/html,...`
|
|
367
|
+
# have one, and a browser that follows them runs the peer's script in
|
|
368
|
+
# the page instead of delivering a code anywhere; a bare `http:` has
|
|
369
|
+
# one and no host to deliver to. So an array of strings registers
|
|
370
|
+
# redirect URIs only when every element is one a callback could
|
|
371
|
+
# actually arrive on AND one MCP 2026-07-28 allows: an HTTPS URL with
|
|
372
|
+
# a host, a plain-HTTP URL on the loopback interface (the callback
|
|
373
|
+
# server {MCPClient::Auth::BrowserOAuth} runs), or an RFC 8252
|
|
374
|
+
# Section 7.1 private-use scheme the host application registered with
|
|
375
|
+
# the operating system — and, either way, without the fragment RFC
|
|
376
|
+
# 6749 Section 3.1.2 forbids.
|
|
377
|
+
# @param value [Object, nil] a candidate redirect URI
|
|
378
|
+
# @return [Boolean]
|
|
379
|
+
def redirect_uri_bytes?(value)
|
|
380
|
+
return false unless non_empty_string?(value)
|
|
381
|
+
|
|
382
|
+
uri = URI.parse(value)
|
|
383
|
+
scheme = uri.scheme.to_s.downcase
|
|
384
|
+
return false unless uri.fragment.nil?
|
|
385
|
+
return allowed_callback_url?(uri, scheme) if CALLBACK_SCHEMES.include?(scheme)
|
|
386
|
+
|
|
387
|
+
private_use_redirect_uri?(uri, scheme)
|
|
388
|
+
rescue URI::InvalidURIError
|
|
389
|
+
false
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# MCP 2026-07-28 "Communication Security": "All redirect URIs MUST be
|
|
393
|
+
# either `localhost` or use HTTPS." Plain HTTP is the exception the
|
|
394
|
+
# loopback interface gets — the code never leaves the machine — and
|
|
395
|
+
# `http://app.example.com/callback` is not that exception: it carries
|
|
396
|
+
# the authorization code, and the authorization server's own error
|
|
397
|
+
# text, across the network in the clear.
|
|
398
|
+
# @param uri [URI::Generic] the parsed redirect URI
|
|
399
|
+
# @param scheme [String] its downcased scheme
|
|
400
|
+
# @return [Boolean]
|
|
401
|
+
def allowed_callback_url?(uri, scheme)
|
|
402
|
+
return false if uri.host.to_s.empty?
|
|
403
|
+
return true if scheme == 'https'
|
|
404
|
+
|
|
405
|
+
loopback_address?(uri.hostname.to_s)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# RFC 8252 Section 7.1: a native application may receive its callback
|
|
409
|
+
# on a private-use URI scheme, "a scheme based on a domain name under
|
|
410
|
+
# their control, expressed in reverse order"
|
|
411
|
+
# (`com.example.app:/oauth2redirect`, and the `com.example.app://oauth`
|
|
412
|
+
# authority spelling operating systems and SDKs also register). Such a
|
|
413
|
+
# URI is hierarchical — it has a path or an authority, not an opaque
|
|
414
|
+
# body — which is what separates it from the `javascript:` and `data:`
|
|
415
|
+
# URIs that are not redirect targets at all. The callback never leaves
|
|
416
|
+
# the device, so the localhost-or-HTTPS requirement that governs the
|
|
417
|
+
# network schemes does not reach it.
|
|
418
|
+
# @param uri [URI::Generic] the parsed redirect URI
|
|
419
|
+
# @param scheme [String] its downcased scheme
|
|
420
|
+
# @return [Boolean]
|
|
421
|
+
def private_use_redirect_uri?(uri, scheme)
|
|
422
|
+
return false unless scheme.include?('.') && uri.opaque.nil?
|
|
423
|
+
|
|
424
|
+
uri.path.to_s.start_with?('/') || !uri.host.to_s.empty?
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# @param value [Object, nil] a candidate client id
|
|
428
|
+
# @return [Boolean] whether it is a non-empty string of client id bytes
|
|
429
|
+
def client_id_bytes?(value)
|
|
430
|
+
non_empty_string?(value)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# @param value [Object, nil]
|
|
434
|
+
# @return [Boolean] whether it is a String with at least one character
|
|
435
|
+
def non_empty_string?(value)
|
|
436
|
+
value.is_a?(String) && !value.empty?
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module Auth
|
|
5
|
+
class OAuthProvider
|
|
6
|
+
# How an authorization or registration request decides what scope to ask
|
|
7
|
+
# for: the MCP scope SELECTION strategy picks what the request needs, and
|
|
8
|
+
# the 2026-07-28 step-up rule adds back what this client has already
|
|
9
|
+
# asked for or been granted. Mixed into {MCPClient::Auth::OAuthProvider};
|
|
10
|
+
# every method is private there.
|
|
11
|
+
module ScopeSelection
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Resolve the scope for authorization/registration requests: the MCP
|
|
15
|
+
# scope SELECTION strategy picks what this request needs (see
|
|
16
|
+
# {#selected_scope}), and the 2026-07-28 step-up rule adds back what has
|
|
17
|
+
# already been asked for (see {#accumulated_scope}).
|
|
18
|
+
# @return [String, nil]
|
|
19
|
+
def resolved_scope
|
|
20
|
+
accumulated_scope(selected_scope)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# What this request needs, by the MCP 2025-11-25 scope selection
|
|
24
|
+
# strategy: the challenge's scope parameter is authoritative; then an
|
|
25
|
+
# explicitly configured scope (:all resolves to the AS-advertised scope
|
|
26
|
+
# list); then the Protected Resource Metadata's scopes_supported;
|
|
27
|
+
# otherwise no scope at all.
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
def selected_scope
|
|
30
|
+
return @challenge_scope if @challenge_scope && !@challenge_scope.empty?
|
|
31
|
+
|
|
32
|
+
if scope == :all
|
|
33
|
+
all_scopes = supported_scopes
|
|
34
|
+
return all_scopes.join(' ') unless all_scopes.empty?
|
|
35
|
+
elsif scope
|
|
36
|
+
return scope
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
prm = @challenge_resource_metadata || @resource_metadata
|
|
40
|
+
prm_scopes = advertised_scopes(prm&.scopes_supported)
|
|
41
|
+
return prm_scopes.join(' ') unless prm_scopes.empty?
|
|
42
|
+
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# MCP 2026-07-28 "Step-Up Authorization Flow", step 2: "Determine
|
|
47
|
+
# required scopes by computing the union of the client's previously
|
|
48
|
+
# requested scope set and the scopes from the current challenge. This
|
|
49
|
+
# ensures previously granted permissions are preserved when servers
|
|
50
|
+
# emit per-operation scope challenges." A challenge is authoritative for
|
|
51
|
+
# what the CURRENT operation needs, not for what the client already had:
|
|
52
|
+
# re-authorizing with the challenge's scope alone trades the permissions
|
|
53
|
+
# every other operation depends on for the one being retried, and the
|
|
54
|
+
# next operation challenges again.
|
|
55
|
+
# @param selected [String, nil] the scope this request selects on its own
|
|
56
|
+
# @return [String, nil] the union, or nil when no scope is to be sent
|
|
57
|
+
def accumulated_scope(selected)
|
|
58
|
+
scopes = (previously_requested_scopes + selected.to_s.split).uniq
|
|
59
|
+
scopes.empty? ? nil : scopes.join(' ')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# "The client's previously requested scope set". Three things say what
|
|
63
|
+
# this client already asked for, and a step-up that consulted only the
|
|
64
|
+
# first would trade away permissions:
|
|
65
|
+
#
|
|
66
|
+
# * the last authorization request this provider made — in-process, and
|
|
67
|
+
# gone the moment the process restarts or the host builds another
|
|
68
|
+
# provider;
|
|
69
|
+
# * the scope the host configured, which is what this client asks for
|
|
70
|
+
# whenever a challenge is not overriding it;
|
|
71
|
+
# * the scope of the token in hand, which is what the authorization
|
|
72
|
+
# server actually granted (RFC 6749 Section 5.1) and is the only one
|
|
73
|
+
# of the three that survives a restart.
|
|
74
|
+
#
|
|
75
|
+
# The granted set counts only while the token belongs to the
|
|
76
|
+
# authorization server in use: what one server granted is not a
|
|
77
|
+
# permission another one ever gave, and asking B for A's scopes is at
|
|
78
|
+
# best a rejected request. The in-process set is dropped for the same
|
|
79
|
+
# reason when the authorization server changes, and with the rest of
|
|
80
|
+
# the per-server state when the provider is retargeted.
|
|
81
|
+
#
|
|
82
|
+
# The configured scope counts only once this client has actually asked
|
|
83
|
+
# for something or holds a grant. The rule preserves PREVIOUSLY
|
|
84
|
+
# REQUESTED permissions; on a first authorization there are none, and
|
|
85
|
+
# the challenge is authoritative for what the operation needs (MCP
|
|
86
|
+
# 2026-07-28 scope selection). Adding the configured set there would
|
|
87
|
+
# widen the very first request beyond what was challenged for — with
|
|
88
|
+
# `scope: :all`, to everything the authorization server advertises.
|
|
89
|
+
# @return [Array<String>]
|
|
90
|
+
def previously_requested_scopes
|
|
91
|
+
asked = @requested_scope.to_s.split
|
|
92
|
+
granted = granted_scopes
|
|
93
|
+
return (asked + granted).uniq if asked.empty? && granted.empty?
|
|
94
|
+
|
|
95
|
+
(asked + configured_scopes + granted).uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The scope the host configured, as a list.
|
|
99
|
+
# @return [Array<String>]
|
|
100
|
+
def configured_scopes
|
|
101
|
+
return supported_scopes if scope == :all
|
|
102
|
+
return [] unless scope.is_a?(String)
|
|
103
|
+
|
|
104
|
+
scope.split
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The scope the authorization server in use granted the token in hand.
|
|
108
|
+
# A token of another authorization server — or one this client
|
|
109
|
+
# retired — grants nothing here.
|
|
110
|
+
# @return [Array<String>]
|
|
111
|
+
def granted_scopes
|
|
112
|
+
token = stored_token_or_nil
|
|
113
|
+
return [] unless token.respond_to?(:scope) && token.scope.is_a?(String)
|
|
114
|
+
return [] unless token_for_current_issuer?(token) || bindable_to_current_issuer?(token)
|
|
115
|
+
|
|
116
|
+
token.scope.split
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A scopes_supported value as a scope list: RFC 8414 Section 2 and RFC
|
|
120
|
+
# 9728 Section 2 both make it an array of strings, and a document that
|
|
121
|
+
# breaks that is refused on the wire — but a record read back from a
|
|
122
|
+
# storage backend that persists plain hashes is not, and `"a b".join`
|
|
123
|
+
# is a NoMethodError out of the flow.
|
|
124
|
+
# @param scopes [Object, nil] the advertised value
|
|
125
|
+
# @return [Array<String>] the scopes, or none
|
|
126
|
+
def advertised_scopes(scopes)
|
|
127
|
+
return [] unless scopes.is_a?(Array)
|
|
128
|
+
|
|
129
|
+
scopes.grep(String)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|