mcp 1.0.0 → 1.2.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.
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ # The custom-header half of SEP-2243 (MCP 2026-07-28): scanning a tool's `inputSchema` for
6
+ # `x-mcp-header` declarations and encoding `tools/call` argument values into `Mcp-Param-{Name}` HTTP headers,
7
+ # with the `=?base64?...?=` sentinel for values that cannot ride as plain ASCII field values.
8
+ # Mirrors the TypeScript SDK's `mcpParamHeaders` codec; the standard-header half (`Mcp-Method`, `Mcp-Name`)
9
+ # lives with the transport.
10
+ #
11
+ # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#custom-headers-from-tool-parameters
12
+ module McpParamHeaders
13
+ # The fixed prefix every custom-parameter header carries.
14
+ HEADER_PREFIX = "Mcp-Param-"
15
+
16
+ # The schema-extension property name a tool's `inputSchema` carries.
17
+ X_MCP_HEADER_KEY = "x-mcp-header"
18
+
19
+ # RFC 9110 Section 5.1 `token` syntax (`1*tchar`): rejects empty names, spaces,
20
+ # control characters (including CR/LF), and the HTTP delimiters.
21
+ RFC9110_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.freeze
22
+
23
+ # The spec text admits `string`, `integer`, and `boolean`. `number` is also accepted because
24
+ # the published conformance referee annotates `type: "number"` parameters and expects them
25
+ # mirrored; the TypeScript SDK makes the same accommodation.
26
+ PERMITTED_TYPES = ["string", "integer", "boolean", "number"].freeze
27
+
28
+ # JSON Schema keywords the SEP-2243 static-reachability constraint excludes from
29
+ # the `properties`-only chain. An `x-mcp-header` under any of these invalidates
30
+ # the tool definition rather than being silently ignored.
31
+ NON_REACHABLE_SUBSCHEMA_KEYWORDS = [
32
+ "items",
33
+ "prefixItems",
34
+ "contains",
35
+ "additionalProperties",
36
+ "unevaluatedProperties",
37
+ "unevaluatedItems",
38
+ "propertyNames",
39
+ "patternProperties",
40
+ "dependentSchemas",
41
+ "oneOf",
42
+ "anyOf",
43
+ "allOf",
44
+ "not",
45
+ "if",
46
+ "then",
47
+ "else",
48
+ "$defs",
49
+ "definitions",
50
+ ].freeze
51
+
52
+ # Keywords whose value maps names to subschemas rather than being one subschema or a list of them.
53
+ OBJECT_VALUED_SUBSCHEMA_KEYWORDS = ["patternProperties", "dependentSchemas", "$defs", "definitions"].freeze
54
+
55
+ # Integers beyond 2**53 - 1 lose precision in JSON number interchange, so they are not mirrored;
56
+ # the TypeScript SDK refuses unsafe integers the same way.
57
+ MAX_SAFE_INTEGER = (2**53) - 1
58
+
59
+ BASE64_SENTINEL_PREFIX = "=?base64?"
60
+ BASE64_SENTINEL_SUFFIX = "?="
61
+
62
+ class << self
63
+ # Scans a tool's `inputSchema` for `x-mcp-header` declarations and validates every constraint
64
+ # the spec places on them: RFC 9110 token names, case-insensitive uniqueness, primitive-typed
65
+ # declaring properties, and static reachability through a chain of `properties` keys only.
66
+ # Returns `{ valid: true, declarations: [...] }` with each declaration `{ path:, header_name:, type: }`,
67
+ # or `{ valid: false, reason: "..." }` on the first violation.
68
+ def scan(input_schema)
69
+ declarations = []
70
+ fault = visit(input_schema, [], true, declarations, {})
71
+
72
+ fault ? { valid: false, reason: fault } : { valid: true, declarations: declarations }
73
+ end
74
+
75
+ # Builds the `Mcp-Param-{Name}` headers for one `tools/call` from the scanned declarations and
76
+ # the call's `arguments`. A `null` or absent value omits its header (the spec's MUST-omit rows);
77
+ # a non-primitive or non-representable value is omitted rather than emitted malformed.
78
+ def build(declarations, arguments)
79
+ declarations.each_with_object({}) do |declaration, headers|
80
+ value = value_at_path(arguments, declaration[:path])
81
+ next if value.nil?
82
+
83
+ string_value = primitive_to_string(value)
84
+ next unless string_value
85
+
86
+ encoded = begin
87
+ encode_value(string_value)
88
+ rescue EncodingError
89
+ # A string that cannot be represented as UTF-8 (e.g. binary data) has no header
90
+ # representation; omit it like the other non-representable values.
91
+ next
92
+ end
93
+
94
+ headers["#{HEADER_PREFIX}#{declaration[:header_name]}"] = encoded
95
+ end
96
+ end
97
+
98
+ # Converts a primitive argument to its header string per the spec's type-conversion rules:
99
+ # strings pass through, booleans become lowercase `"true"` / `"false"`, and numbers become
100
+ # their decimal string. `nil` means "not representable: do not emit a header".
101
+ def primitive_to_string(value)
102
+ case value
103
+ when String
104
+ value
105
+ when true
106
+ "true"
107
+ when false
108
+ "false"
109
+ when Integer
110
+ value.abs <= MAX_SAFE_INTEGER ? value.to_s : nil
111
+ when Float
112
+ return unless value.finite?
113
+
114
+ # JSON has one number type: an integral float serializes without the fractional part,
115
+ # matching the `String(42.0)` the JavaScript reference emits.
116
+ value == value.truncate ? value.truncate.to_s : value.to_s
117
+ end
118
+ end
119
+
120
+ # Encodes a header value per the spec's value-encoding rules: a safe plain-ASCII field value
121
+ # passes through unchanged, everything else is wrapped as `=?base64?{base64-of-UTF-8}?=`.
122
+ def encode_value(value)
123
+ return value unless needs_base64?(value)
124
+
125
+ "#{BASE64_SENTINEL_PREFIX}#{[value.encode(Encoding::UTF_8)].pack("m0")}#{BASE64_SENTINEL_SUFFIX}"
126
+ end
127
+
128
+ private
129
+
130
+ def visit(node, path, reachable, declarations, seen_lower)
131
+ return unless node.is_a?(Hash)
132
+
133
+ if key?(node, X_MCP_HEADER_KEY)
134
+ fault = validate_declaration(node, path, reachable, declarations, seen_lower)
135
+
136
+ return fault if fault
137
+ end
138
+
139
+ properties = read(node, "properties")
140
+ if properties.is_a?(Hash)
141
+ properties.each do |key, child|
142
+ fault = visit(child, path + [key.to_s], reachable, declarations, seen_lower)
143
+
144
+ return fault if fault
145
+ end
146
+ end
147
+
148
+ # Static-reachability sweep: descend the keywords the `properties` chain MUST NOT pass
149
+ # through with `reachable: false`, so an annotation under any of them is reported.
150
+ # `$defs` covers `$ref`-within-`$defs`; chasing arbitrary `$ref` URIs is out of scope.
151
+ NON_REACHABLE_SUBSCHEMA_KEYWORDS.each do |keyword|
152
+ next unless (sub = read(node, keyword))
153
+
154
+ branches = if sub.is_a?(Array)
155
+ sub
156
+ elsif sub.is_a?(Hash) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.include?(keyword)
157
+ sub.values
158
+ else
159
+ [sub]
160
+ end
161
+
162
+ branches.each do |branch|
163
+ fault = visit(branch, path + ["<#{keyword}>"], false, declarations, seen_lower)
164
+
165
+ return fault if fault
166
+ end
167
+ end
168
+
169
+ nil
170
+ end
171
+
172
+ def validate_declaration(node, path, reachable, declarations, seen_lower)
173
+ if !reachable || path.empty?
174
+ return "#{path_name(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of `properties` keys"
175
+
176
+ end
177
+
178
+ annotation = read(node, X_MCP_HEADER_KEY)
179
+
180
+ unless annotation.is_a?(String) && !annotation.empty?
181
+ return "#{path_name(path)}: x-mcp-header MUST be a non-empty string"
182
+ end
183
+
184
+ unless RFC9110_TOKEN.match?(annotation)
185
+ return "#{path_name(path)}: x-mcp-header `#{annotation}` is not a valid RFC 9110 token"
186
+ end
187
+
188
+ type = read(node, "type")
189
+ unless type.is_a?(String) && PERMITTED_TYPES.include?(type)
190
+ return "#{path_name(path)}: x-mcp-header is only permitted on primitive-typed properties " \
191
+ "(got `#{type.inspect}`)"
192
+ end
193
+
194
+ lower = annotation.downcase
195
+ prior = seen_lower[lower]
196
+ if prior
197
+ return "x-mcp-header `#{annotation}` is not case-insensitively unique (also declared as `#{prior}`)"
198
+ end
199
+
200
+ seen_lower[lower] = annotation
201
+ declarations << { path: path, header_name: annotation, type: type }
202
+ nil
203
+ end
204
+
205
+ # A value cannot ride as a plain ASCII field value when it is empty, already shaped like
206
+ # the Base64 sentinel (the spec's ambiguity rule), carries edge whitespace that field parsing
207
+ # would strip, or contains a byte outside visible ASCII plus interior tab.
208
+ def needs_base64?(value)
209
+ return true if value.empty?
210
+ return true if value.start_with?(BASE64_SENTINEL_PREFIX) && value.end_with?(BASE64_SENTINEL_SUFFIX)
211
+ return true if value != value.strip
212
+
213
+ value.each_byte.any? { |byte| byte != 0x09 && !byte.between?(0x20, 0x7e) }
214
+ end
215
+
216
+ def value_at_path(root, path)
217
+ path.reduce(root) do |node, key|
218
+ break unless node.is_a?(Hash)
219
+
220
+ read(node, key)
221
+ end
222
+ end
223
+
224
+ # Schemas and arguments arrive with string keys off the wire but may carry symbol keys
225
+ # when constructed in Ruby; read both forms like the SDK's other readers.
226
+ def read(hash, key)
227
+ value = hash[key]
228
+
229
+ value.nil? ? hash[key.to_sym] : value
230
+ end
231
+
232
+ def key?(hash, key)
233
+ hash.key?(key) || hash.key?(key.to_sym)
234
+ end
235
+
236
+ def path_name(path)
237
+ path.empty? ? "<root>" : path.join(".")
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../request_envelope"
4
+
5
+ module MCP
6
+ class Client
7
+ # Stamps the SEP-2575 per-request `_meta` envelope onto outgoing modern requests.
8
+ # The reserved key names are shared with the server-side `MCP::RequestEnvelope`.
9
+ # Reserved keys always win over caller-supplied `_meta` entries because they are
10
+ # wire vocabulary the SDK is responsible for; every other entry is preserved.
11
+ module ModernEnvelope
12
+ extend self
13
+
14
+ # Returns a copy of `request` with `params._meta` carrying the modern triple.
15
+ # Neither `request` nor its nested hashes are mutated.
16
+ def stamp(request, protocol_version:, client_info:, capabilities:)
17
+ params_key = request.key?("params") ? "params" : :params
18
+ params = request[params_key] || {}
19
+ meta_key = params.key?("_meta") ? "_meta" : :_meta
20
+ meta = params[meta_key] || {}
21
+
22
+ stamped_meta = meta.merge(
23
+ RequestEnvelope::PROTOCOL_VERSION_META_KEY.to_sym => protocol_version,
24
+ RequestEnvelope::CLIENT_INFO_META_KEY.to_sym => client_info,
25
+ RequestEnvelope::CLIENT_CAPABILITIES_META_KEY.to_sym => capabilities,
26
+ )
27
+
28
+ request.merge(params_key => params.merge(meta_key => stamped_meta))
29
+ end
30
+ end
31
+ end
32
+ end
@@ -8,7 +8,7 @@ module MCP
8
8
  module OAuth
9
9
  # Stateless helpers that map MCP-authorization spec URLs and headers into something
10
10
  # the `Flow` orchestrator and `MCP::Client::HTTP` transport can act on.
11
- # The module bundles five concerns that share no state but are closely related to
11
+ # The module bundles six concerns that share no state but are closely related to
12
12
  # the spec's "Discovery" and "Communication Security" sections:
13
13
  #
14
14
  # - **`WWW-Authenticate` parsing** (`parse_www_authenticate`): pulls
@@ -22,6 +22,10 @@ module MCP
22
22
  # - **Communication Security check** (`secure_url?`): enforces "HTTPS only"
23
23
  # for every OAuth-facing URL, with the loopback carve-out described in
24
24
  # `secure_url?`'s comment.
25
+ # - **Destination checks** (`same_origin?`, `private_network_host?`):
26
+ # answer *where* a URL points, which the scheme alone does not. `Flow` uses
27
+ # them to refuse server-supplied discovery URLs aimed at hosts the MCP server
28
+ # has no business steering the client toward.
25
29
  # - **URL canonicalization** (`canonicalize_url`): normalizes scheme,
26
30
  # host, port, path, percent-encoded dot segments, and fragments
27
31
  # so two URLs that *refer to the same resource* compare as equal,
@@ -193,6 +197,51 @@ module MCP
193
197
  false
194
198
  end
195
199
 
200
+ # Returns true when `url` and `other` share an origin: same scheme, same host,
201
+ # and same port. Scheme and host compare case-insensitively, and an implicit default port compares
202
+ # equal to the same port written out, because `URI::HTTP#port` already resolves to the default.
203
+ #
204
+ # Anything that fails to parse or carries no host returns false, so a caller using this as
205
+ # a security gate refuses rather than admits on malformed input.
206
+ def same_origin?(url, other)
207
+ uri = URI.parse(url.to_s)
208
+ other_uri = URI.parse(other.to_s)
209
+ return false if uri.host.nil? || uri.host.empty?
210
+ return false if other_uri.host.nil? || other_uri.host.empty?
211
+
212
+ uri.scheme&.downcase == other_uri.scheme&.downcase &&
213
+ uri.host.downcase == other_uri.host.downcase &&
214
+ uri.port == other_uri.port
215
+ rescue URI::InvalidURIError
216
+ false
217
+ end
218
+
219
+ # Returns true when `host` is an IP literal that is not reachable from the public internet,
220
+ # or the `localhost` name. The ranges are the ones RFC 9728 Section 7.7 and the MCP security best practices name
221
+ # as SSRF targets, so `169.254.0.0/16` covers the `169.254.169.254` cloud metadata address that motivates the check.
222
+ #
223
+ # Hostnames are deliberately NOT resolved. A lookup here would be a second network request driven by
224
+ # the same untrusted input, and the address it returned could differ from the one the HTTP client connects to
225
+ # a moment later, so the check would read as a guarantee it cannot make. That leaves names like `vault.corp.internal`
226
+ # out of reach of this predicate; it is one layer of an SSRF defense, not the whole of one.
227
+ def private_network_host?(host)
228
+ return false if host.nil? || host.empty?
229
+
230
+ normalized = host.downcase
231
+ return true if normalized == "localhost"
232
+
233
+ literal = normalized.delete_prefix("[").delete_suffix("]")
234
+ address = parse_ip_address(literal)
235
+ return numeric_ipv4_spelling?(literal) unless address
236
+
237
+ # `::ffff:169.254.169.254` addresses the same interface as `169.254.169.254`,
238
+ # so compare the IPv4 form rather than letting the mapped spelling through.
239
+ address = address.native if address.ipv6? && address.ipv4_mapped?
240
+
241
+ ranges = address.ipv4? ? PRIVATE_IPV4_RANGES : PRIVATE_IPV6_RANGES
242
+ ranges.any? { |range| range.include?(address) }
243
+ end
244
+
196
245
  # Returns true when `url` satisfies the structural requirements for
197
246
  # a Client ID Metadata Document URL per the MCP 2025-11-25
198
247
  # authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
@@ -287,20 +336,17 @@ module MCP
287
336
  uri.to_s
288
337
  end
289
338
 
290
- # Returns true when `prm` (a PRM `resource` URL) covers `server`
291
- # (the MCP endpoint URL): same scheme/host/port, with PRM's path being
292
- # a prefix of the server's path. When PRM also advertises a query
293
- # string, the server's query MUST be identical to it
294
- # (otherwise a hijacked PRM that advertises `?tenant=evil` would cover
295
- # an MCP server at `?tenant=victim` and let the attacker mint
296
- # a different tenant's token for the same origin + path).
297
- # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier
298
- # over the origin + path prefix and covers any server query.
339
+ # Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
340
+ # same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
341
+ # also advertises a query string, the server's query MUST be identical to it (otherwise
342
+ # a hijacked PRM that advertises `?tenant=evil` would cover an MCP server at `?tenant=victim`
343
+ # and let the attacker mint a different tenant's token for the same origin + path).
344
+ # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier over
345
+ # the origin + path prefix and covers any server query.
299
346
  #
300
- # An empty query (`prm_url?` -- URI#query returns `""`) is NOT
301
- # treated as wildcard: it represents the URI literally `<...>?`,
302
- # which is distinct from "no query at all" and from any non-empty query,
303
- # so it must match exactly.
347
+ # An empty query (`prm_url?` -- URI#query returns `""`) is NOT treated as wildcard:
348
+ # it represents the URI literally `<...>?`, which is distinct from "no query at all"
349
+ # and from any non-empty query, so it must match exactly.
304
350
  #
305
351
  # Both arguments must already be canonicalized.
306
352
  def resource_covers?(prm:, server:)
@@ -340,6 +386,29 @@ module MCP
340
386
  IPV6_LOOPBACK = IPAddr.new("::1")
341
387
  private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK
342
388
 
389
+ # Backing ranges for `private_network_host?`.
390
+ #
391
+ # `0.0.0.0/8` is "this network" and `100.64.0.0/10` is the carrier-grade NAT
392
+ # space; neither is a destination a legitimate authorization server publishes,
393
+ # and both are reachable enough from a client's network position to be worth
394
+ # refusing. `::/96` covers the unspecified address, IPv6 loopback, and
395
+ # the deprecated IPv4-compatible form (`::10.0.0.1`) in one range.
396
+ PRIVATE_IPV4_RANGES = [
397
+ IPAddr.new("0.0.0.0/8"),
398
+ IPAddr.new("10.0.0.0/8"),
399
+ IPAddr.new("100.64.0.0/10"),
400
+ IPAddr.new("127.0.0.0/8"),
401
+ IPAddr.new("169.254.0.0/16"),
402
+ IPAddr.new("172.16.0.0/12"),
403
+ IPAddr.new("192.168.0.0/16"),
404
+ ].freeze
405
+ PRIVATE_IPV6_RANGES = [
406
+ IPAddr.new("::/96"),
407
+ IPAddr.new("fc00::/7"),
408
+ IPAddr.new("fe80::/10"),
409
+ ].freeze
410
+ private_constant :PRIVATE_IPV4_RANGES, :PRIVATE_IPV6_RANGES
411
+
343
412
  def loopback_host?(host)
344
413
  return false if host.nil? || host.empty?
345
414
 
@@ -362,6 +431,31 @@ module MCP
362
431
  nil
363
432
  end
364
433
 
434
+ # Matches one part of the `inet_aton` numeric grammar: hexadecimal (`0x7f`),
435
+ # octal (`0177`), or decimal (`127`).
436
+ INET_ATON_PART = /\A(?:0x\h+|0[0-7]*|[1-9]\d*)\z/.freeze
437
+ private_constant :INET_ATON_PART
438
+
439
+ # Returns true when `host` is written in the numeric IPv4 grammar `inet_aton` accepts
440
+ # and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or
441
+ # decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and
442
+ # `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address,
443
+ # so a check that understood only the canonical form would refuse `169.254.169.254`
444
+ # while waving through every other spelling of it.
445
+ #
446
+ # Such a host is refused outright rather than decoded and range-checked, because its
447
+ # value is resolver-dependent: a leading zero reads as octal on one platform and decimal
448
+ # on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`.
449
+ # Deciding here which one it meant would leave whichever reading was not taken as a way
450
+ # through. No authorization server is legitimately addressed in this notation, so
451
+ # refusing the grammar entirely costs nothing and leaves no spelling to miss.
452
+ def numeric_ipv4_spelling?(host)
453
+ parts = host.split(".", -1)
454
+ return false if parts.empty? || parts.length > 4
455
+
456
+ parts.all? { |part| INET_ATON_PART.match?(part) }
457
+ end
458
+
365
459
  # A redirect URI counts as native when it uses a custom non-http(s) scheme
366
460
  # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
367
461
  # a loopback address. A URI without a scheme or one that fails to parse is not native.
@@ -33,9 +33,15 @@ module MCP
33
33
  def run!(server_url:, resource_metadata_url: nil, scope: nil)
34
34
  # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
35
35
  # first thing we contact in the OAuth flow, so it has to clear the same
36
- # Communication Security bar as the OAuth endpoints downstream.
36
+ # Communication Security bar as the OAuth endpoints downstream, and it has to
37
+ # point back at the server that issued the challenge.
37
38
  if resource_metadata_url
38
39
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
40
+ ensure_same_origin!(
41
+ resource_metadata_url,
42
+ label: "WWW-Authenticate resource_metadata URL",
43
+ server_url: server_url,
44
+ )
39
45
  end
40
46
 
41
47
  prm, authorization_server = locate_authorization_server(
@@ -49,7 +55,11 @@ module MCP
49
55
  # being redirected to credentials minted for a different audience.
50
56
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
51
57
 
52
- as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
58
+ as_metadata = authorization_server_metadata(
59
+ authorization_server: authorization_server,
60
+ legacy: prm.nil?,
61
+ server_url: server_url,
62
+ )
53
63
 
54
64
  case provider_authorization_flow
55
65
  when :client_credentials
@@ -212,6 +222,11 @@ module MCP
212
222
 
213
223
  if resource_metadata_url
214
224
  ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
225
+ ensure_same_origin!(
226
+ resource_metadata_url,
227
+ label: "WWW-Authenticate resource_metadata URL",
228
+ server_url: server_url,
229
+ )
215
230
  end
216
231
 
217
232
  prm, authorization_server = locate_authorization_server(
@@ -221,7 +236,11 @@ module MCP
221
236
 
222
237
  resource = canonical_resource(server_url: server_url, prm_resource: prm&.dig("resource"))
223
238
 
224
- as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
239
+ as_metadata = authorization_server_metadata(
240
+ authorization_server: authorization_server,
241
+ legacy: prm.nil?,
242
+ server_url: server_url,
243
+ )
225
244
 
226
245
  client_info = if have_stored_client_info
227
246
  # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
@@ -296,6 +315,11 @@ module MCP
296
315
  if prm
297
316
  authorization_server = first_authorization_server(prm)
298
317
  ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")
318
+ ensure_routable_destination!(
319
+ authorization_server,
320
+ label: "PRM `authorization_servers` entry",
321
+ server_url: server_url,
322
+ )
299
323
  [prm, authorization_server]
300
324
  else
301
325
  authorization_base = server_origin!(server_url)
@@ -311,7 +335,7 @@ module MCP
311
335
  # and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from
312
336
  # the origin the metadata was discovered at (neither the TypeScript nor the Python SDK validates the issuer on this path).
313
337
  # When even the metadata document is absent, the legacy spec's default endpoints are used.
314
- def authorization_server_metadata(authorization_server:, legacy:)
338
+ def authorization_server_metadata(authorization_server:, legacy:, server_url:)
315
339
  metadata = if legacy
316
340
  begin
317
341
  fetch_authorization_server_metadata(issuer_url: authorization_server)
@@ -324,7 +348,7 @@ module MCP
324
348
  end
325
349
  end
326
350
 
327
- ensure_secure_endpoints!(metadata)
351
+ ensure_secure_endpoints!(metadata, server_url: server_url)
328
352
  metadata
329
353
  end
330
354
 
@@ -445,13 +469,72 @@ module MCP
445
469
  "#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
446
470
  end
447
471
 
448
- def ensure_secure_endpoints!(as_metadata)
472
+ # Requires a URL the *server* chose to sit on the origin the *caller* chose.
473
+ #
474
+ # Protected Resource Metadata describes the MCP server itself, so on a real deployment it is
475
+ # published on that server's own origin. Without this check a `WWW-Authenticate` challenge
476
+ # can aim the first request of the flow at any host the client can route to: the URL arrives
477
+ # from the network, it is fetched before the user approves anything, and the `resource` check that
478
+ # runs afterwards cannot un-send the request.
479
+ #
480
+ # RFC 9728 does not itself require the metadata URL to be same-origin, so this is stricter than
481
+ # the specification. It is enforced unconditionally because no known deployment publishes its PRM
482
+ # anywhere else, and because the alternative (`Discovery.private_network_host?`) cannot see
483
+ # internal hosts that are named rather than addressed.
484
+ # https://www.rfc-editor.org/rfc/rfc9728#section-7.7
485
+ def ensure_same_origin!(url, label:, server_url:)
486
+ return if Discovery.same_origin?(url, server_url)
487
+
488
+ raise AuthorizationError,
489
+ "#{label} #{sanitized_url(url).inspect} is not on the MCP server origin " \
490
+ "#{sanitized_url(server_url).inspect}; refusing to fetch it."
491
+ end
492
+
493
+ # Refuses an OAuth URL that points into a private, loopback, link-local, or unique-local address,
494
+ # which is the SSRF precaution RFC 9728 Section 7.7 and the MCP security best practices ask clients to take.
495
+ #
496
+ # The carve-out matters as much as the rule: when the MCP server the caller configured is itself on such an address,
497
+ # the whole flow is already inside that network and the authorization server legitimately lives there too.
498
+ # That covers `http://localhost` development, the conformance harness (which runs the MCP server and
499
+ # the authorization server on two loopback ports), and deployments that never leave a corporate network.
500
+ # Only a server reachable on the public internet is barred from steering the client inward.
501
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
502
+ def ensure_routable_destination!(url, label:, server_url:)
503
+ return unless private_network_url?(url)
504
+ return if private_network_url?(server_url)
505
+
506
+ raise AuthorizationError,
507
+ "#{label} #{sanitized_url(url).inspect} points into a private network range, " \
508
+ "which the MCP server at #{sanitized_url(server_url).inspect} is not on; refusing to contact it."
509
+ end
510
+
511
+ def ensure_secure_endpoints!(as_metadata, server_url:)
449
512
  ["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
450
513
  endpoint = as_metadata[key]
451
- ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
514
+ next unless endpoint
515
+
516
+ ensure_secure_url!(endpoint, label: "Authorization server #{key}")
517
+ ensure_routable_destination!(endpoint, label: "Authorization server #{key}", server_url: server_url)
452
518
  end
453
519
  end
454
520
 
521
+ # `ensure_secure_url!` runs first at every call site and already rejects a URL that fails to parse,
522
+ # so the rescue here is a backstop rather than a leniency.
523
+ def private_network_url?(url)
524
+ Discovery.private_network_host?(URI.parse(url.to_s).host)
525
+ rescue URI::InvalidURIError
526
+ false
527
+ end
528
+
529
+ # Strips userinfo and query before a URL reaches an exception message, the same precaution `MCP::Client::HTTP` takes
530
+ # when it reports a URL: these values come off the network and can carry credentials that would otherwise land in
531
+ # every log destination the error passes through.
532
+ def sanitized_url(url)
533
+ Discovery.canonicalize_origin_and_path(url)
534
+ rescue URI::Error
535
+ url.to_s
536
+ end
537
+
455
538
  # Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
456
539
  # identical (literal byte-for-byte equality, no normalization) to
457
540
  # the issuer URL the client used to discover that document. This guards
@@ -962,6 +1045,11 @@ module MCP
962
1045
  @http_client ||= @http_client_factory.call
963
1046
  end
964
1047
 
1048
+ # Deliberately built without redirect-following middleware. Every destination check in
1049
+ # this class runs against the URL as written, before the request goes out, so a connection
1050
+ # that transparently followed a `3xx` would let a server reach a host the checks just refused.
1051
+ # A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
1052
+ # and the guards above only cover the first hop.
965
1053
  def default_http_client
966
1054
  require "faraday"
967
1055
  Faraday.new do |faraday|