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.
- checksums.yaml +4 -4
- data/README.md +428 -9
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +338 -63
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +95 -7
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- data/lib/mcp/instrumentation.rb +6 -0
- data/lib/mcp/methods.rb +21 -0
- data/lib/mcp/prompt.rb +8 -1
- data/lib/mcp/protocol_deprecations.rb +61 -0
- data/lib/mcp/request_envelope.rb +117 -0
- data/lib/mcp/server/input_required_result.rb +163 -0
- data/lib/mcp/server/pending_response.rb +62 -0
- data/lib/mcp/server/request_state_security.rb +131 -0
- data/lib/mcp/server/transports/stdio_transport.rb +39 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +710 -20
- data/lib/mcp/server.rb +541 -43
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +77 -15
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +11 -2
|
@@ -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
|
|
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
|
-
#
|
|
292
|
-
# a
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
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
|
-
#
|
|
302
|
-
#
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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|
|