ruby-mcp-client 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,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