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,532 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
module Auth
|
|
7
|
+
class OAuthProvider
|
|
8
|
+
# WWW-Authenticate challenge handling for {OAuthProvider}: parsing the
|
|
9
|
+
# challenge, fetching and validating the resource metadata it names,
|
|
10
|
+
# retiring tokens of another authorization server, and the checks a
|
|
11
|
+
# peer-advertised URL must pass. Mixed into OAuthProvider; every method
|
|
12
|
+
# relies on its state.
|
|
13
|
+
module ChallengeHandling
|
|
14
|
+
# One decimal, octal or hexadecimal component of a numeric IPv4 spec.
|
|
15
|
+
IPV4_COMPONENT = /\A(?:0x[0-9a-f]+|0[0-7]*|[1-9][0-9]*)\z/i
|
|
16
|
+
|
|
17
|
+
# A percent escape in a hostname.
|
|
18
|
+
PERCENT_ESCAPE = /%([0-9a-f]{2})/i
|
|
19
|
+
|
|
20
|
+
# How many times a host is percent-decoded before it is classified.
|
|
21
|
+
HOST_DECODE_PASSES = 4
|
|
22
|
+
|
|
23
|
+
# The characters a hostname or IP literal is spelled with: letters,
|
|
24
|
+
# digits, '-' and '.' for names, ':' for an IPv6 literal, '_' because
|
|
25
|
+
# resolvers and real deployments tolerate it in names.
|
|
26
|
+
HOSTNAME_CHARACTERS = /\A[a-z0-9\-._:]+\z/i
|
|
27
|
+
|
|
28
|
+
# Handle 401 Unauthorized response (for server discovery)
|
|
29
|
+
# @param response [Faraday::Response] HTTP response
|
|
30
|
+
# @return [ResourceMetadata, nil] Resource metadata if found
|
|
31
|
+
def handle_unauthorized_response(response)
|
|
32
|
+
www_authenticate = response.headers['WWW-Authenticate'] || response.headers['www-authenticate']
|
|
33
|
+
return nil unless www_authenticate
|
|
34
|
+
|
|
35
|
+
# Challenge parameters are read from the Bearer challenge's own
|
|
36
|
+
# segment only — never from the whole (possibly multi-challenge)
|
|
37
|
+
# header — so parameters belonging to Basic or another scheme cannot
|
|
38
|
+
# drive Bearer scope selection or resource metadata discovery. A
|
|
39
|
+
# header without a Bearer challenge carries no usable Bearer params.
|
|
40
|
+
bearer_params = bearer_challenge_segment(www_authenticate)
|
|
41
|
+
# A header naming only schemes this client cannot answer — Basic,
|
|
42
|
+
# Negotiate — is not a Bearer challenge, so it says nothing about
|
|
43
|
+
# the scopes this resource wants and nothing about where its
|
|
44
|
+
# metadata lives. The reset below belongs to a Bearer challenge
|
|
45
|
+
# that carried no scope; letting a Basic 401 make it would drop the
|
|
46
|
+
# scope an insufficient_scope challenge required, and the step-up
|
|
47
|
+
# that follows would ask for less than the server demanded.
|
|
48
|
+
return nil unless bearer_params
|
|
49
|
+
|
|
50
|
+
# MCP 2025-11-25: "Clients MUST treat the scopes provided in the
|
|
51
|
+
# challenge as authoritative for satisfying the current request" —
|
|
52
|
+
# including resetting a previously challenged scope when the current
|
|
53
|
+
# challenge carries none.
|
|
54
|
+
url = extract_resource_metadata_url(www_authenticate)
|
|
55
|
+
scope = bearer_params && extract_challenge_param(bearer_params, 'scope')
|
|
56
|
+
# A step-up challenge (403 insufficient_scope) says this operation
|
|
57
|
+
# needs more scopes, not that the authorization server moved: the
|
|
58
|
+
# token in hand stays valid (MCP 2026-07-28 step-up authorization).
|
|
59
|
+
# So whatever its resource metadata is worth — unfetchable, fetched
|
|
60
|
+
# and refused, or named by an unacceptable URL — the known server
|
|
61
|
+
# stays in place rather than becoming unknown (which would withhold
|
|
62
|
+
# that token from every other operation), and the challenged scope
|
|
63
|
+
# is recorded as the authoritative one. A challenge reporting an
|
|
64
|
+
# invalid token is judged in full, and a refused document stands.
|
|
65
|
+
step_up = step_up_challenge?(bearer_params)
|
|
66
|
+
previous = [@challenge_metadata_url, @challenge_resource_metadata, @challenge_error]
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
# The challenge header is peer-controlled input: validate the
|
|
70
|
+
# advertised URL BEFORE storing, fetching, or recording any challenge
|
|
71
|
+
# state, so a malicious challenge cannot pivot this host into requests
|
|
72
|
+
# against internal services (SSRF) and cannot leave the provider
|
|
73
|
+
# holding half of a rejected challenge.
|
|
74
|
+
validate_peer_advertised_url!(url, 'resource metadata URL (from WWW-Authenticate challenge)') if url
|
|
75
|
+
|
|
76
|
+
@challenge_scope = scope
|
|
77
|
+
return nil unless url
|
|
78
|
+
|
|
79
|
+
# Remember the advertised URL even if the fetch below fails, so a
|
|
80
|
+
# later discovery retries it instead of probing well-known URIs the
|
|
81
|
+
# challenge already superseded. The current header is the one to
|
|
82
|
+
# honour: an earlier document, and an earlier refusal, are forgotten
|
|
83
|
+
# before the fetch so a failed fetch leaves the flow waiting on this
|
|
84
|
+
# URL rather than completing against stale state.
|
|
85
|
+
@challenge_metadata_url = url
|
|
86
|
+
@challenge_resource_metadata = nil
|
|
87
|
+
@challenge_error = nil
|
|
88
|
+
adopt_challenge_metadata(url)
|
|
89
|
+
rescue MCPClient::Errors::ConnectionError
|
|
90
|
+
raise unless step_up
|
|
91
|
+
|
|
92
|
+
@challenge_metadata_url, @challenge_resource_metadata, @challenge_error = previous
|
|
93
|
+
@challenge_scope = scope
|
|
94
|
+
raise
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Whether a Bearer challenge asks for more scopes (SEP-835 / MCP
|
|
99
|
+
# 2026-07-28 step-up), as opposed to reporting an invalid token.
|
|
100
|
+
# @param bearer_params [String, nil] the Bearer challenge's parameters
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def step_up_challenge?(bearer_params)
|
|
103
|
+
bearer_params && extract_challenge_param(bearer_params, 'error') == 'insufficient_scope'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Fetch, validate and adopt the resource metadata a challenge named.
|
|
107
|
+
# @param url [String] the advertised metadata URL
|
|
108
|
+
# @return [ResourceMetadata]
|
|
109
|
+
# @raise [MCPClient::Errors::ConnectionError] when the fetch fails or the document is refused
|
|
110
|
+
def adopt_challenge_metadata(url)
|
|
111
|
+
# This URL was explicitly advertised by the 401 challenge, so a 404 is a
|
|
112
|
+
# misconfiguration to surface (strict), not a speculative miss to skip.
|
|
113
|
+
metadata = fetch_resource_metadata(url, strict: true)
|
|
114
|
+
# Metadata discovery would reject is refused now, whole: it neither
|
|
115
|
+
# retires the token nor lingers as an authoritative challenge.
|
|
116
|
+
reject_unacceptable_challenge!(metadata)
|
|
117
|
+
# A validated challenge supersedes an earlier refused one.
|
|
118
|
+
@challenge_error = nil
|
|
119
|
+
# Reuse this challenge-advertised metadata during the subsequent OAuth
|
|
120
|
+
# flow instead of re-deriving (and possibly missing) the well-known URL.
|
|
121
|
+
@challenge_resource_metadata = metadata
|
|
122
|
+
revoke_token_on_authorization_server_change(metadata)
|
|
123
|
+
metadata
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A challenge whose metadata could not be fetched yet is retried before
|
|
127
|
+
# any token is judged: until it resolves, the current authorization
|
|
128
|
+
# server is unknown and nothing is presented.
|
|
129
|
+
# @return [void]
|
|
130
|
+
def resolve_pending_challenge
|
|
131
|
+
return unless @challenge_metadata_url && @challenge_resource_metadata.nil?
|
|
132
|
+
|
|
133
|
+
adopt_challenge_metadata(@challenge_metadata_url)
|
|
134
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
135
|
+
logger.debug("Challenge metadata still unresolved: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A challenge naming another authorization server than the one the
|
|
139
|
+
# stored token came from retires that token at once: it is never
|
|
140
|
+
# presented again, whatever the storage backend can do.
|
|
141
|
+
# @param resource_metadata [ResourceMetadata]
|
|
142
|
+
# @return [void]
|
|
143
|
+
def revoke_token_on_authorization_server_change(resource_metadata)
|
|
144
|
+
advertised = Array(resource_metadata&.authorization_servers).first
|
|
145
|
+
known = stored_server_metadata&.issuer
|
|
146
|
+
return unless advertised && known && advertised != known
|
|
147
|
+
|
|
148
|
+
@authorization_server_switched = true
|
|
149
|
+
# Ending the pending requests and retiring the token are one step
|
|
150
|
+
# against the responses being accepted meanwhile: a code exchange or
|
|
151
|
+
# refresh that has passed its checks holds this lock until its token
|
|
152
|
+
# is written, so it is never this transition that lands in between
|
|
153
|
+
# (see {MCPClient::Auth::OAuthProvider#with_authorization_state_lock}).
|
|
154
|
+
with_authorization_state_lock do
|
|
155
|
+
# The requests still pending with the server this resource left can
|
|
156
|
+
# never complete as this resource's: ended now, before anything is
|
|
157
|
+
# fetched from the advertised server, so a late answer to them is
|
|
158
|
+
# refused by every provider sharing the storage.
|
|
159
|
+
end_pending_requests_of(known)
|
|
160
|
+
# A token another provider sharing the storage already bound to the
|
|
161
|
+
# advertised server is exactly the token to keep.
|
|
162
|
+
next if record_bound_to?(stored_token_or_nil, advertised)
|
|
163
|
+
|
|
164
|
+
logger.debug('The challenge names another authorization server; retiring the stored token')
|
|
165
|
+
delete_token(bind_to: Token::RETIRED_ISSUER)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Apply the checks discovery applies to challenge-advertised resource
|
|
170
|
+
# metadata (resource identity, an acceptable authorization server URL)
|
|
171
|
+
# before anything acts on it; a failing document refuses the whole
|
|
172
|
+
# challenge (see {#reject_challenge!}).
|
|
173
|
+
# @param resource_metadata [ResourceMetadata]
|
|
174
|
+
# @return [void]
|
|
175
|
+
# @raise [MCPClient::Errors::ConnectionError] when the challenge is refused
|
|
176
|
+
def reject_unacceptable_challenge!(resource_metadata)
|
|
177
|
+
begin
|
|
178
|
+
validate_resource_matches!(resource_metadata)
|
|
179
|
+
rescue MCPClient::Errors::ConnectionError => e
|
|
180
|
+
reject_challenge!(e.message)
|
|
181
|
+
end
|
|
182
|
+
advertised = Array(resource_metadata.authorization_servers).first
|
|
183
|
+
unless advertised
|
|
184
|
+
reject_challenge!('Protected resource metadata does not advertise any authorization_servers')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
validate_peer_advertised_url!(advertised, 'authorization server URL (from resource metadata)')
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Extract the protected-resource-metadata URL from a WWW-Authenticate header.
|
|
191
|
+
# Per RFC 9728 the parameter is `resource_metadata`; a legacy `resource`
|
|
192
|
+
# parameter is accepted as a fallback for older servers. Only the Bearer
|
|
193
|
+
# challenge's own segment is consulted, so a parameter belonging to
|
|
194
|
+
# another scheme's challenge can never drive discovery.
|
|
195
|
+
# @param header [String] the WWW-Authenticate header value
|
|
196
|
+
# @return [String, nil] the metadata URL if present
|
|
197
|
+
def extract_resource_metadata_url(header)
|
|
198
|
+
# A URL is not free text. RFC 3986 spells a URI in ASCII, so a
|
|
199
|
+
# header this client had to scrub to read did not advertise one:
|
|
200
|
+
# every undecodable byte in it became a '?', and fetching that
|
|
201
|
+
# rewriting would send a request to a URL nobody wrote. The
|
|
202
|
+
# challenge simply names no metadata URL, and discovery falls back
|
|
203
|
+
# to the well-known URIs.
|
|
204
|
+
return nil unless PeerText.decodable?(header)
|
|
205
|
+
|
|
206
|
+
params = bearer_challenge_segment(header)
|
|
207
|
+
return nil unless params
|
|
208
|
+
|
|
209
|
+
# Auth-params may include optional whitespace around '=' (RFC 7235).
|
|
210
|
+
# Quoted form: resource_metadata = "https://..."
|
|
211
|
+
if (m = params.match(/resource_metadata\s*=\s*"([^"]+)"/))
|
|
212
|
+
return m[1]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Unquoted token form: resource_metadata = https://...
|
|
216
|
+
if (m = params.match(/resource_metadata\s*=\s*([^,\s]+)/))
|
|
217
|
+
return m[1]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Legacy fallback: resource="https://.../.well-known/oauth-protected-resource"
|
|
221
|
+
legacy = params.match(/resource\s*=\s*"([^"]+)"/)&.captures&.first
|
|
222
|
+
legacy if legacy && protected_resource_metadata_url?(legacy)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# RFC 9728 names the challenge parameter `resource_metadata`; a
|
|
226
|
+
# `resource` parameter is a resource identifier (RFC 8707), not a
|
|
227
|
+
# document. It is read as a metadata URL only when it points at a
|
|
228
|
+
# protected resource metadata well-known location — read as one
|
|
229
|
+
# otherwise, the MCP endpoint itself would be fetched as metadata,
|
|
230
|
+
# fail, and stand in the way of the well-known fallback MCP
|
|
231
|
+
# 2026-07-28 requires when the challenge names no document.
|
|
232
|
+
# @param url [String] the parameter value
|
|
233
|
+
# @return [Boolean]
|
|
234
|
+
def protected_resource_metadata_url?(url)
|
|
235
|
+
URI.parse(url).path.to_s.include?('/.well-known/oauth-protected-resource')
|
|
236
|
+
rescue URI::InvalidURIError
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Extract the Bearer challenge's own parameter segment from a (possibly
|
|
241
|
+
# multi-challenge) WWW-Authenticate header, so params belonging to other
|
|
242
|
+
# schemes (e.g. `Basic resource_metadata="...", Bearer realm="x"`) are
|
|
243
|
+
# never attributed to the Bearer challenge. Mirrors
|
|
244
|
+
# HttpTransportBase#bearer_challenge_segment.
|
|
245
|
+
# @param header [String, nil] the WWW-Authenticate header value
|
|
246
|
+
# @return [String, nil] the Bearer challenge's parameters (possibly
|
|
247
|
+
# empty), or nil when the header has no Bearer challenge
|
|
248
|
+
def bearer_challenge_segment(header)
|
|
249
|
+
# A header value is peer bytes, and `gsub`, `match` and `[]` all
|
|
250
|
+
# raise `ArgumentError` on bytes that are not valid UTF-8 — out of
|
|
251
|
+
# the 401 handler, which would then report the client's own
|
|
252
|
+
# ArgumentError instead of the challenge. It is made decodable
|
|
253
|
+
# before it is read; nothing else about it is changed, because the
|
|
254
|
+
# URL and scope this parser returns have to be what the peer wrote.
|
|
255
|
+
header = matchable_peer_text(header)
|
|
256
|
+
return nil unless header
|
|
257
|
+
|
|
258
|
+
# Locate the Bearer scheme token only OUTSIDE quoted strings: a
|
|
259
|
+
# quoted value such as realm="prefix Bearer x" must not anchor the
|
|
260
|
+
# segment.
|
|
261
|
+
masked = header.gsub(/"(?:\\.|[^"\\])*"/) { |q| "\"#{' ' * (q.length - 2)}\"" }
|
|
262
|
+
match = masked.match(/(?:\A|[\s,])Bearer(?=[\s,]|\z)/i)
|
|
263
|
+
return nil unless match
|
|
264
|
+
|
|
265
|
+
header[match.end(0)..][AUTH_PARAMS_RUN]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Extract an auth-param value from a WWW-Authenticate header
|
|
269
|
+
# (quoted or unquoted form, optional whitespace around '=').
|
|
270
|
+
# @param header [String] the WWW-Authenticate header value
|
|
271
|
+
# @param name [String] the auth-param name
|
|
272
|
+
# @return [String, nil] the parameter value if present
|
|
273
|
+
def extract_challenge_param(header, name)
|
|
274
|
+
header = matchable_peer_text(header)
|
|
275
|
+
return nil unless header
|
|
276
|
+
|
|
277
|
+
if (m = header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*"([^"]*)"/i))
|
|
278
|
+
return m[1]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
header.match(/(?:^|[\s,])#{Regexp.escape(name)}\s*=\s*([^,\s]+)/i)&.captures&.first
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
# Validate a URL that a peer advertised to us (a 401 challenge's
|
|
287
|
+
# resource_metadata, or PRM authorization_servers).
|
|
288
|
+
#
|
|
289
|
+
# Stricter than enforce_https!, which exists for URLs the OPERATOR
|
|
290
|
+
# configured and therefore tolerates plain-HTTP loopback for local
|
|
291
|
+
# development. Applying that exception to peer-supplied input would
|
|
292
|
+
# leave the reported SSRF intact against the most sensitive targets of
|
|
293
|
+
# all — services listening only on localhost. It is honored here only
|
|
294
|
+
# for a loopback MCP server naming a loopback target: the developer is
|
|
295
|
+
# already pointed at a local stack, and that stack may advertise
|
|
296
|
+
# another port of itself over plain HTTP. Nothing wider qualifies. A
|
|
297
|
+
# server on a private network ('10.0.0.5', 'app.internal') is remote
|
|
298
|
+
# as far as this check is concerned, and even a loopback server may not
|
|
299
|
+
# send us to a link-local or private address — '169.254.169.254' is our
|
|
300
|
+
# cloud metadata endpoint, not the MCP server's.
|
|
301
|
+
#
|
|
302
|
+
# A refusal of a CHALLENGE-advertised URL is recorded (latch: true) so a
|
|
303
|
+
# later discovery fails closed instead of silently reusing cached
|
|
304
|
+
# authorization-server metadata. A refusal of a document found by
|
|
305
|
+
# speculative well-known discovery records nothing: there is no cache to
|
|
306
|
+
# protect, and latching it would leave a server that is later fixed
|
|
307
|
+
# unreachable for the life of this provider.
|
|
308
|
+
#
|
|
309
|
+
# NOTE: hostnames are checked literally. This does not resolve DNS, so a
|
|
310
|
+
# public name that resolves to a private address is not caught here;
|
|
311
|
+
# that needs resolution-time checking in the HTTP layer.
|
|
312
|
+
# @param url [String] the peer-advertised URL
|
|
313
|
+
# @param label [String] human-readable name for errors
|
|
314
|
+
# @param latch [Boolean] whether a refusal is recorded as an authoritative
|
|
315
|
+
# challenge refusal (true for a 401 challenge, false for speculative
|
|
316
|
+
# well-known discovery, which must stay retryable)
|
|
317
|
+
# @raise [MCPClient::Errors::ConnectionError] if the URL is not acceptable
|
|
318
|
+
def validate_peer_advertised_url!(url, label, latch: true)
|
|
319
|
+
uri = URI.parse(url)
|
|
320
|
+
host = uri.hostname.to_s.downcase
|
|
321
|
+
|
|
322
|
+
# The whole development exception, in one predicate: a loopback
|
|
323
|
+
# target advertised to a client whose configured server is loopback.
|
|
324
|
+
local_stack = loopback_address?(host) && loopback_server?
|
|
325
|
+
|
|
326
|
+
if uri.scheme != 'https' && !(uri.scheme == 'http' && local_stack)
|
|
327
|
+
refuse_peer_url!("OAuth #{label} must use HTTPS: #{safe_error_text(url)}", latch: latch)
|
|
328
|
+
end
|
|
329
|
+
# An authority-less URL ('https:foo', 'https:///foo') has an acceptable
|
|
330
|
+
# scheme and an empty host, so it would otherwise pass every check
|
|
331
|
+
# below and be treated as a validated authorization server — enough to
|
|
332
|
+
# retire the stored token before discovery can only fail.
|
|
333
|
+
refuse_peer_url!("OAuth #{label} must name a host: #{safe_error_text(url)}", latch: latch) if host.empty?
|
|
334
|
+
if local_address?(host) && !local_stack
|
|
335
|
+
refuse_peer_url!("OAuth #{label} must not target a loopback or private address: #{safe_error_text(url)}",
|
|
336
|
+
latch: latch)
|
|
337
|
+
end
|
|
338
|
+
# Anything left that a resolver could not look up as a name — an
|
|
339
|
+
# undecodable escape, a NUL, a slash smuggled in as '%2f' — is not a
|
|
340
|
+
# host we can classify, so it is refused rather than handed to the
|
|
341
|
+
# HTTP client to interpret.
|
|
342
|
+
return if hostname_shaped?(normalize_host(host))
|
|
343
|
+
|
|
344
|
+
refuse_peer_url!("OAuth #{label} must name a valid host: #{safe_error_text(url)}", latch: latch)
|
|
345
|
+
rescue URI::InvalidURIError
|
|
346
|
+
refuse_peer_url!("OAuth #{label} is not a valid URL: #{safe_error_text(url)}", latch: latch)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# @param message [String] why the peer-advertised URL was refused
|
|
350
|
+
# @param latch [Boolean] whether to record the refusal for later discovery
|
|
351
|
+
# @raise [MCPClient::Errors::ConnectionError] always
|
|
352
|
+
def refuse_peer_url!(message, latch:)
|
|
353
|
+
reject_challenge!(message) if latch
|
|
354
|
+
|
|
355
|
+
# A speculative document is not latched, but it is still refused: the
|
|
356
|
+
# copy fetch_resource_metadata kept for scope resolution must go too,
|
|
357
|
+
# or its scopes_supported would be sent in the registration and
|
|
358
|
+
# authorization requests of a flow that discarded the document.
|
|
359
|
+
@resource_metadata = nil
|
|
360
|
+
raise MCPClient::Errors::ConnectionError, message
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# @return [Boolean] whether the resource metadata being acted on was
|
|
364
|
+
# advertised by a 401 challenge (authoritative) rather than found by
|
|
365
|
+
# speculative well-known discovery
|
|
366
|
+
def challenge_advertised_metadata?
|
|
367
|
+
!(@challenge_resource_metadata.nil? && @challenge_metadata_url.nil?)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# @param message [String] why the challenge was refused
|
|
371
|
+
# @raise [MCPClient::Errors::ConnectionError] always
|
|
372
|
+
def reject_challenge!(message)
|
|
373
|
+
# Drop every scrap of the refused challenge so nothing half-applied
|
|
374
|
+
# survives, and remember why for the next discovery attempt.
|
|
375
|
+
@challenge_scope = nil
|
|
376
|
+
@challenge_metadata_url = nil
|
|
377
|
+
@challenge_resource_metadata = nil
|
|
378
|
+
@resource_metadata = nil
|
|
379
|
+
@challenge_error = message
|
|
380
|
+
raise MCPClient::Errors::ConnectionError, message
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# The development exception is for a developer pointed at a local
|
|
384
|
+
# stack, so it asks for loopback and nothing else: a server on the
|
|
385
|
+
# office network ('10.0.0.5', 'app.internal', 'printer.local') is as
|
|
386
|
+
# remote as a public one, and its 401 must not be able to name the
|
|
387
|
+
# client's own localhost or the cloud metadata endpoint.
|
|
388
|
+
# @return [Boolean] whether the configured MCP server is on the
|
|
389
|
+
# loopback interface, in which case loopback discovery targets are
|
|
390
|
+
# expected
|
|
391
|
+
def loopback_server?
|
|
392
|
+
loopback_address?(URI.parse(server_url).hostname.to_s.downcase)
|
|
393
|
+
rescue URI::InvalidURIError
|
|
394
|
+
false
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The one definition of "loopback" in this client: 'localhost' and its
|
|
398
|
+
# subdomains, which RFC 6761 reserves for the loopback interface and
|
|
399
|
+
# every resolver answers as 127.0.0.1 / ::1, plus any loopback address
|
|
400
|
+
# (127.0.0.0/8, ::1) in any spelling the resolver accepts. Shared by
|
|
401
|
+
# the SSRF classifier, the registered application_type and the
|
|
402
|
+
# plain-HTTP exception for configured and discovered endpoints, so all
|
|
403
|
+
# three agree on what a local stack is.
|
|
404
|
+
# @param host [String] a hostname
|
|
405
|
+
# @return [Boolean] whether it names the loopback interface
|
|
406
|
+
def loopback_address?(host)
|
|
407
|
+
host = normalize_host(host)
|
|
408
|
+
return true if LOOPBACK_HOSTS.include?(host) || host.end_with?('.localhost')
|
|
409
|
+
|
|
410
|
+
ip = parse_address(host)
|
|
411
|
+
return false unless ip
|
|
412
|
+
|
|
413
|
+
ip = ip.native if ip.ipv6? && (ip.ipv4_mapped? || ip.ipv4_compat?)
|
|
414
|
+
ip.loopback?
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# @param host [String] a hostname
|
|
418
|
+
# @return [Boolean] whether it names a loopback, private or link-local address
|
|
419
|
+
def local_address?(host)
|
|
420
|
+
return true if loopback_address?(host)
|
|
421
|
+
|
|
422
|
+
host = normalize_host(host)
|
|
423
|
+
return true if host.end_with?('.local', '.internal')
|
|
424
|
+
|
|
425
|
+
local_ip?(host)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# A host is classified by the name a resolver would look up: URI#hostname
|
|
429
|
+
# does not percent-decode, but the HTTP client dials the decoded name,
|
|
430
|
+
# so '169.254.169.254%2e', '127%2e0%2e0%2e1' and '%31%32%37.0.0.1' all
|
|
431
|
+
# reach 127.0.0.1 / the metadata endpoint and must classify as those.
|
|
432
|
+
# Decoding comes first; then the brackets of an IPv6 literal, which are
|
|
433
|
+
# punctuation, and a single trailing dot, which only marks the name as
|
|
434
|
+
# fully qualified. Case folding comes last: decoding can put letters
|
|
435
|
+
# back that the caller's downcase already passed over ('%4Cocalhost'
|
|
436
|
+
# decodes to 'Localhost'), and hostnames are case-insensitive.
|
|
437
|
+
# @param host [String] a hostname as it was written in the URL
|
|
438
|
+
# @return [String] the decoded, downcased host, brackets and one
|
|
439
|
+
# trailing dot removed
|
|
440
|
+
def normalize_host(host)
|
|
441
|
+
percent_decode_host(host.to_s).delete_prefix('[').delete_suffix(']').delete_suffix('.').downcase
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Decoding is repeated until the host stops changing so a doubly encoded
|
|
445
|
+
# spelling ('%252e') cannot hide behind one pass, and capped so a host
|
|
446
|
+
# of nothing but escapes cannot spin. A malformed or non-ASCII escape is
|
|
447
|
+
# left alone: it survives as a literal '%', which hostname_shaped?
|
|
448
|
+
# refuses.
|
|
449
|
+
# @param host [String] a hostname as it was written in the URL
|
|
450
|
+
# @return [String] the host with its ASCII percent escapes resolved
|
|
451
|
+
def percent_decode_host(host)
|
|
452
|
+
HOST_DECODE_PASSES.times do
|
|
453
|
+
break unless host.include?('%')
|
|
454
|
+
|
|
455
|
+
decoded = host.gsub(PERCENT_ESCAPE) do |escape|
|
|
456
|
+
byte = ::Regexp.last_match(1).hex
|
|
457
|
+
byte < 0x80 ? byte.chr : escape
|
|
458
|
+
end
|
|
459
|
+
break if decoded == host
|
|
460
|
+
|
|
461
|
+
host = decoded
|
|
462
|
+
end
|
|
463
|
+
host
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# @param host [String] a normalized (decoded, unbracketed) hostname
|
|
467
|
+
# @return [Boolean] whether it is spelled with the characters a
|
|
468
|
+
# hostname or IP literal may contain
|
|
469
|
+
def hostname_shaped?(host)
|
|
470
|
+
!host.empty? && host.match?(HOSTNAME_CHARACTERS)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Classify a literal address semantically rather than by spelling: a
|
|
474
|
+
# prefix list misses the forms IPv6 permits for the very ranges it
|
|
475
|
+
# means to reject ('::ffff:169.254.169.254' for the link-local metadata
|
|
476
|
+
# endpoint, '0:0:0:0:0:0:0:1' for loopback), so parse the host and ask
|
|
477
|
+
# IPAddr. IPv4-mapped and IPv4-compatible addresses are folded to their
|
|
478
|
+
# IPv4 form first, so one set of range checks covers both families.
|
|
479
|
+
# @param host [String] a hostname with any brackets already stripped
|
|
480
|
+
# @return [Boolean] whether it is a literal address in a local range
|
|
481
|
+
def local_ip?(host)
|
|
482
|
+
ip = parse_address(host)
|
|
483
|
+
return false unless ip
|
|
484
|
+
|
|
485
|
+
ip = ip.native if ip.ipv6? && (ip.ipv4_mapped? || ip.ipv4_compat?)
|
|
486
|
+
# 0.0.0.0 / :: name "this host" and are neither loopback nor private
|
|
487
|
+
# to IPAddr, but reach local services just the same.
|
|
488
|
+
return true if ip.to_i.zero?
|
|
489
|
+
|
|
490
|
+
ip.loopback? || ip.link_local? || ip.private?
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# @param host [String] a hostname
|
|
494
|
+
# @return [IPAddr, nil] the address it names, or nil when it is a name
|
|
495
|
+
def parse_address(host)
|
|
496
|
+
IPAddr.new(host)
|
|
497
|
+
rescue ArgumentError # IPAddr::Error included
|
|
498
|
+
# IPAddr only accepts dotted quads, but the resolver (inet_aton) also
|
|
499
|
+
# accepts shorthand and alternate radixes — '127.1', '0177.0.0.1',
|
|
500
|
+
# '0x7f.0.0.1' and '2130706433' all reach 127.0.0.1 — so a check that
|
|
501
|
+
# stopped at IPAddr would wave those straight through.
|
|
502
|
+
shorthand_ipv4(host)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# @param host [String] a hostname
|
|
506
|
+
# @return [IPAddr, nil] the address inet_aton would read, when the host
|
|
507
|
+
# is a numeric IPv4 spec IPAddr itself rejects
|
|
508
|
+
def shorthand_ipv4(host)
|
|
509
|
+
parts = host.split('.', -1)
|
|
510
|
+
return nil unless (1..4).cover?(parts.size) && parts.all? { |part| part.match?(IPV4_COMPONENT) }
|
|
511
|
+
|
|
512
|
+
values = parts.map { |part| Integer(part, ipv4_component_base(part)) }
|
|
513
|
+
# inet_aton: the last component fills every byte the earlier ones left.
|
|
514
|
+
last = values.pop
|
|
515
|
+
return nil if last >= (1 << (8 * (4 - values.size))) || values.any? { |value| value > 255 }
|
|
516
|
+
|
|
517
|
+
IPAddr.new(values.each_with_index.sum { |value, i| value << (8 * (3 - i)) } + last, Socket::AF_INET)
|
|
518
|
+
rescue ArgumentError
|
|
519
|
+
nil
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @param part [String] one component of a numeric IPv4 spec
|
|
523
|
+
# @return [Integer] its radix (leading '0x' hex, leading '0' octal, else decimal)
|
|
524
|
+
def ipv4_component_base(part)
|
|
525
|
+
return 16 if part.downcase.start_with?('0x')
|
|
526
|
+
|
|
527
|
+
part.start_with?('0') ? 8 : 10
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module MCPClient
|
|
7
|
+
module Auth
|
|
8
|
+
class OAuthProvider
|
|
9
|
+
# How this client authenticates at the token endpoint.
|
|
10
|
+
#
|
|
11
|
+
# A confidential client — one the authorization server issued a
|
|
12
|
+
# `client_secret` to — must present that secret with every token
|
|
13
|
+
# request, and RFC 7591 Section 2 says how: "if unspecified or omitted,
|
|
14
|
+
# the default is `client_secret_basic`". This client used to send the
|
|
15
|
+
# secret only for `client_secret_post` and to record an omitted method
|
|
16
|
+
# as `none`, which is the one combination that never authenticates: a
|
|
17
|
+
# registration that issued a secret and named no method produced a
|
|
18
|
+
# record whose secret was never sent, and every token request went out
|
|
19
|
+
# as an unauthenticated client for an authorization server that expects
|
|
20
|
+
# HTTP Basic.
|
|
21
|
+
#
|
|
22
|
+
# So the method the authorization server registered decides, defaulting
|
|
23
|
+
# as the RFC does:
|
|
24
|
+
#
|
|
25
|
+
# * `client_secret_basic` — the credentials go in an `Authorization:
|
|
26
|
+
# Basic` header, form-urlencoded before they are base64'd (RFC 6749
|
|
27
|
+
# Section 2.3.1), never in the body;
|
|
28
|
+
# * `client_secret_post` — in the request body, as before;
|
|
29
|
+
# * `none` (a public client, or no secret at all) — nothing is sent;
|
|
30
|
+
# * anything else (`private_key_jwt`, `client_secret_jwt`, a method a
|
|
31
|
+
# future RFC adds) — this client cannot produce that assertion, so it
|
|
32
|
+
# sends no credentials and says so, rather than guessing with the
|
|
33
|
+
# secret in a header the server did not ask for.
|
|
34
|
+
#
|
|
35
|
+
# Mixed into OAuthProvider.
|
|
36
|
+
module ClientAuthentication
|
|
37
|
+
# RFC 7591 Section 2: the `token_endpoint_auth_method` a registration
|
|
38
|
+
# response that names none is read as.
|
|
39
|
+
DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = 'client_secret_basic'
|
|
40
|
+
|
|
41
|
+
# The method a public client declares: no credentials are sent.
|
|
42
|
+
NO_CLIENT_AUTHENTICATION = 'none'
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The `token_endpoint_auth_method` to record for a registration
|
|
47
|
+
# response. RFC 7591 Section 2's default applies to a client the
|
|
48
|
+
# server issued a secret to; a registration without one is the public
|
|
49
|
+
# client this library asks to be.
|
|
50
|
+
# @param data [Hash] the parsed registration response
|
|
51
|
+
# @return [String]
|
|
52
|
+
def registered_auth_method(data)
|
|
53
|
+
method = data['token_endpoint_auth_method']
|
|
54
|
+
return method if method.is_a?(String) && !method.empty?
|
|
55
|
+
|
|
56
|
+
client_secret_bytes?(data['client_secret']) ? DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD : NO_CLIENT_AUTHENTICATION
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Add this client's credentials to a token request the way the
|
|
60
|
+
# authorization server registered them.
|
|
61
|
+
# @param params [Hash] the form parameters, extended in place for client_secret_post
|
|
62
|
+
# @param client_info [ClientInfo] the credentials to present
|
|
63
|
+
# @return [String, nil] the `Authorization` header value to send, if any
|
|
64
|
+
def apply_client_credentials!(params, client_info)
|
|
65
|
+
method = token_endpoint_auth_method_for(client_info)
|
|
66
|
+
case method
|
|
67
|
+
when nil, NO_CLIENT_AUTHENTICATION
|
|
68
|
+
nil
|
|
69
|
+
when 'client_secret_post'
|
|
70
|
+
params[:client_secret] = client_info.client_secret
|
|
71
|
+
nil
|
|
72
|
+
when DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD
|
|
73
|
+
basic_authorization_header(client_info.client_id, client_info.client_secret)
|
|
74
|
+
else
|
|
75
|
+
logger.warn('The authorization server registered token_endpoint_auth_method ' \
|
|
76
|
+
"#{safe_error_text(method.to_s).inspect}, which this client cannot present; " \
|
|
77
|
+
'the token request is made without client authentication')
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The method to authenticate these credentials with. A client without
|
|
83
|
+
# a secret authenticates with nothing whatever its metadata says; a
|
|
84
|
+
# client WITH one falls back to RFC 7591's default, which also covers
|
|
85
|
+
# a record persisted before this client read the field (stored as
|
|
86
|
+
# `none` alongside a secret, a combination that authenticates
|
|
87
|
+
# nowhere).
|
|
88
|
+
# @param client_info [ClientInfo]
|
|
89
|
+
# @return [String, nil] the method, or nil when nothing is to be sent
|
|
90
|
+
def token_endpoint_auth_method_for(client_info)
|
|
91
|
+
return nil unless client_secret_bytes?(client_info.client_secret)
|
|
92
|
+
|
|
93
|
+
method = client_info.metadata.token_endpoint_auth_method
|
|
94
|
+
return DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD unless method.is_a?(String) && !method.empty?
|
|
95
|
+
return DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD if method == NO_CLIENT_AUTHENTICATION
|
|
96
|
+
|
|
97
|
+
method
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# RFC 6749 Section 2.3.1: the client identifier and secret are encoded
|
|
101
|
+
# with `application/x-www-form-urlencoded` and then, joined by a
|
|
102
|
+
# single colon, base64-encoded — so a `:` or a space in either does
|
|
103
|
+
# not shift the boundary the server splits on.
|
|
104
|
+
# @param client_id [String]
|
|
105
|
+
# @param client_secret [String]
|
|
106
|
+
# @return [String] the `Authorization` header value
|
|
107
|
+
def basic_authorization_header(client_id, client_secret)
|
|
108
|
+
credentials = "#{URI.encode_www_form_component(client_id.to_s)}:" \
|
|
109
|
+
"#{URI.encode_www_form_component(client_secret.to_s)}"
|
|
110
|
+
"Basic #{Base64.strict_encode64(credentials)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param value [Object, nil] a candidate client secret
|
|
114
|
+
# @return [Boolean] whether it is a secret this client could present
|
|
115
|
+
def client_secret_bytes?(value)
|
|
116
|
+
value.is_a?(String) && !value.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|