mcp 0.25.0 → 1.6.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -2548
  3. data/lib/json_rpc_handler.rb +3 -2
  4. data/lib/mcp/client/http.rb +355 -66
  5. data/lib/mcp/client/mcp_param_headers.rb +242 -0
  6. data/lib/mcp/client/modern_envelope.rb +32 -0
  7. data/lib/mcp/client/oauth/authorization_request.rb +27 -0
  8. data/lib/mcp/client/oauth/bounded_body.rb +67 -0
  9. data/lib/mcp/client/oauth/client_credentials_provider.rb +20 -6
  10. data/lib/mcp/client/oauth/cross_app_access_provider.rb +21 -1
  11. data/lib/mcp/client/oauth/discovery.rb +131 -14
  12. data/lib/mcp/client/oauth/flow.rb +586 -108
  13. data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
  14. data/lib/mcp/client/oauth/provider.rb +16 -1
  15. data/lib/mcp/client/oauth/storage_backed_provider.rb +49 -5
  16. data/lib/mcp/client/oauth.rb +2 -0
  17. data/lib/mcp/client/stdio.rb +271 -66
  18. data/lib/mcp/client/tool.rb +3 -2
  19. data/lib/mcp/client.rb +364 -41
  20. data/lib/mcp/configuration.rb +84 -7
  21. data/lib/mcp/elicitation/enum_schema.rb +121 -0
  22. data/lib/mcp/elicitation.rb +10 -0
  23. data/lib/mcp/error_codes.rb +14 -8
  24. data/lib/mcp/icon.rb +35 -2
  25. data/lib/mcp/instrumentation.rb +6 -0
  26. data/lib/mcp/methods.rb +21 -0
  27. data/lib/mcp/prompt.rb +8 -1
  28. data/lib/mcp/protocol_deprecations.rb +61 -0
  29. data/lib/mcp/request_envelope.rb +117 -0
  30. data/lib/mcp/server/input_required_result.rb +163 -0
  31. data/lib/mcp/server/pending_response.rb +62 -0
  32. data/lib/mcp/server/request_state_security.rb +131 -0
  33. data/lib/mcp/server/transports/stdio_transport.rb +39 -2
  34. data/lib/mcp/server/transports/streamable_http_transport.rb +820 -24
  35. data/lib/mcp/server.rb +623 -54
  36. data/lib/mcp/server_context.rb +92 -5
  37. data/lib/mcp/server_session.rb +104 -20
  38. data/lib/mcp/tool/response.rb +8 -2
  39. data/lib/mcp/tool.rb +1 -1
  40. data/lib/mcp/transport.rb +7 -0
  41. data/lib/mcp/version.rb +1 -1
  42. data/lib/mcp.rb +3 -0
  43. metadata +14 -3
@@ -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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to,
7
+ # and with which scopes.
8
+ #
9
+ # Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it
10
+ # without changing the shape every implementer wrote.
11
+ #
12
+ # Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not,
13
+ # so the representation can change later.
14
+ AuthorizationRequest = Struct.new(
15
+ # `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata.
16
+ :authorization_server,
17
+ # The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`.
18
+ :scopes,
19
+ # The MCP server URL this client was configured with.
20
+ :server_url,
21
+ # The RFC 8707 `resource` the token is being requested for.
22
+ :resource,
23
+ keyword_init: true,
24
+ )
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # Bounds an OAuth response body while it arrives, rather than after it has been buffered.
7
+ # Discovery documents, registration responses, and token responses are all small by definition,
8
+ # so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of
9
+ # `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`.
10
+ class BoundedBody
11
+ MAX_RESPONSE_BYTES = 4 * 1024 * 1024
12
+
13
+ # Raised while the body is read. Each caller translates it into its own error type,
14
+ # so this never reaches an embedder.
15
+ class TooLargeError < StandardError; end
16
+
17
+ # What the OAuth code reads from a response. The Faraday response itself is not passed on,
18
+ # so a later caller cannot reach the unbounded `response.body` by accident.
19
+ Response = Struct.new(:status, :body)
20
+
21
+ def initialize(max_bytes: MAX_RESPONSE_BYTES)
22
+ @max_bytes = max_bytes
23
+ @buffer = +""
24
+ end
25
+
26
+ # Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
27
+ # `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
28
+ # that expands past the cap is refused partway through the expansion rather than after it. That holds only while
29
+ # the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
30
+ def on_data
31
+ proc do |chunk, _received_bytes, _env|
32
+ @buffer << chunk
33
+
34
+ raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes
35
+ end
36
+ end
37
+
38
+ # The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver
39
+ # the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then,
40
+ # but refusing them still keeps an over-cap document out of `JSON.parse`.
41
+ def response_for(response)
42
+ Response.new(response.status, bounded_body(response))
43
+ end
44
+
45
+ private
46
+
47
+ def bounded_body(response)
48
+ return @buffer unless @buffer.empty?
49
+
50
+ body = response.body
51
+ body = body.is_a?(String) ? body : body.to_s
52
+ raise TooLargeError, too_large_message if body.bytesize > @max_bytes
53
+
54
+ body
55
+ end
56
+
57
+ def too_large_message
58
+ # Not "the authorization server": protected resource metadata comes from the MCP server's own origin,
59
+ # so this message covers endpoints on both sides of the flow.
60
+ "Response body from the OAuth endpoint exceeds #{@max_bytes} bytes"
61
+ end
62
+ end
63
+
64
+ private_constant :BoundedBody
65
+ end
66
+ end
67
+ end
@@ -37,11 +37,17 @@ module MCP
37
37
  # also take the algorithm as an explicit option).
38
38
  # - `scope` - String of space-separated scopes to request when the server's
39
39
  # `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
40
- # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
41
- # `client_information`, and `save_client_information(info)`. Defaults to
42
- # an `InMemoryStorage`. The `client_id` / `client_secret` are written
43
- # into it so the token exchange reads them through the same path as
44
- # a pre-registered authorization-code client.
40
+ # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
41
+ # and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
42
+ # The `client_id` / `client_secret` are written into it so the token exchange reads
43
+ # them through the same path as a pre-registered authorization-code client.
44
+ # - `token_request_params` - Hash of String keys and values added to every
45
+ # token request this provider makes, for parameters the authorization
46
+ # server requires beyond the grant itself (Auth0's `audience`, for example).
47
+ # A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
48
+ # The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
49
+ # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
50
+ # its own requests; see `StorageBackedProvider#http_client_customizer`.
45
51
  class ClientCredentialsProvider
46
52
  include StorageBackedProvider
47
53
 
@@ -60,7 +66,10 @@ module MCP
60
66
  private_key: nil,
61
67
  signing_algorithm: nil,
62
68
  scope: nil,
63
- storage: nil
69
+ storage: nil,
70
+ authorization_request_validator: nil,
71
+ token_request_params: nil,
72
+ http_client_customizer: nil
64
73
  )
65
74
  if blank?(client_id)
66
75
  raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
@@ -98,11 +107,16 @@ module MCP
98
107
  client_information["client_secret"] = client_secret
99
108
  end
100
109
 
110
+ http_client_customizer = validated_http_client_customizer(http_client_customizer)
111
+
101
112
  @client_id = client_id
102
113
  @private_key = private_key
103
114
  @signing_algorithm = signing_algorithm
104
115
  @scope = scope
105
116
  @storage = storage || InMemoryStorage.new
117
+ @authorization_request_validator = authorization_request_validator
118
+ @token_request_params = frozen_token_request_params(token_request_params)
119
+ @http_client_customizer = http_client_customizer
106
120
  @storage.save_client_information(client_information)
107
121
  end
108
122
 
@@ -25,6 +25,12 @@ module MCP
25
25
  # the Protected Resource Metadata do not specify one.
26
26
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
27
27
  # Defaults to an `InMemoryStorage`.
28
+ # - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
29
+ # the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
30
+ # `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
31
+ # See `StorageBackedProvider#token_request_params`.
32
+ # - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests;
33
+ # see `StorageBackedProvider#http_client_customizer`.
28
34
  #
29
35
  # https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
30
36
  class CrossAppAccessProvider
@@ -35,7 +41,16 @@ module MCP
35
41
 
36
42
  attr_reader :scope, :storage
37
43
 
38
- def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
44
+ def initialize(
45
+ client_id:,
46
+ client_secret:,
47
+ assertion_provider:,
48
+ scope: nil,
49
+ storage: nil,
50
+ authorization_request_validator: nil,
51
+ token_request_params: nil,
52
+ http_client_customizer: nil
53
+ )
39
54
  if blank?(client_id)
40
55
  raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
41
56
  end
@@ -48,9 +63,14 @@ module MCP
48
63
  raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
49
64
  end
50
65
 
66
+ http_client_customizer = validated_http_client_customizer(http_client_customizer)
67
+
51
68
  @assertion_provider = assertion_provider
52
69
  @scope = scope
53
70
  @storage = storage || InMemoryStorage.new
71
+ @authorization_request_validator = authorization_request_validator
72
+ @token_request_params = frozen_token_request_params(token_request_params)
73
+ @http_client_customizer = http_client_customizer
54
74
  @storage.save_client_information(
55
75
  "client_id" => client_id,
56
76
  "client_secret" => client_secret,
@@ -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,40 @@ 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
+ # Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting
340
+ # a URL next to the request that failed, where it must still match the server's access log; `URI#to_s`
341
+ # still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path`
342
+ # it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters
343
+ # for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry
344
+ # the very credentials being dropped.
345
+ def redact_url(url)
346
+ uri = URI.parse(url.to_s)
347
+
348
+ uri.fragment = nil
349
+ uri.query = nil
350
+ # `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
351
+ # so clear the components individually.
352
+ if uri.respond_to?(:user) && (uri.user || uri.password)
353
+ uri.user = nil
354
+ uri.password = nil
355
+ end
356
+
357
+ uri.to_s
358
+ rescue URI::Error
359
+ "[unparseable URL]"
360
+ end
361
+
362
+ # Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
363
+ # same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
364
+ # also advertises a query string, the server's query MUST be identical to it (otherwise
365
+ # a hijacked PRM that advertises `?tenant=evil` would cover an MCP server at `?tenant=victim`
366
+ # and let the attacker mint a different tenant's token for the same origin + path).
367
+ # PRM with *no* query (URI#query returns `nil`) acts as a generic identifier over
368
+ # the origin + path prefix and covers any server query.
299
369
  #
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.
370
+ # An empty query (`prm_url?` -- URI#query returns `""`) is NOT treated as wildcard:
371
+ # it represents the URI literally `<...>?`, which is distinct from "no query at all"
372
+ # and from any non-empty query, so it must match exactly.
304
373
  #
305
374
  # Both arguments must already be canonicalized.
306
375
  def resource_covers?(prm:, server:)
@@ -340,6 +409,29 @@ module MCP
340
409
  IPV6_LOOPBACK = IPAddr.new("::1")
341
410
  private_constant :IPV4_LOOPBACK_RANGE, :IPV6_LOOPBACK
342
411
 
412
+ # Backing ranges for `private_network_host?`.
413
+ #
414
+ # `0.0.0.0/8` is "this network" and `100.64.0.0/10` is the carrier-grade NAT
415
+ # space; neither is a destination a legitimate authorization server publishes,
416
+ # and both are reachable enough from a client's network position to be worth
417
+ # refusing. `::/96` covers the unspecified address, IPv6 loopback, and
418
+ # the deprecated IPv4-compatible form (`::10.0.0.1`) in one range.
419
+ PRIVATE_IPV4_RANGES = [
420
+ IPAddr.new("0.0.0.0/8"),
421
+ IPAddr.new("10.0.0.0/8"),
422
+ IPAddr.new("100.64.0.0/10"),
423
+ IPAddr.new("127.0.0.0/8"),
424
+ IPAddr.new("169.254.0.0/16"),
425
+ IPAddr.new("172.16.0.0/12"),
426
+ IPAddr.new("192.168.0.0/16"),
427
+ ].freeze
428
+ PRIVATE_IPV6_RANGES = [
429
+ IPAddr.new("::/96"),
430
+ IPAddr.new("fc00::/7"),
431
+ IPAddr.new("fe80::/10"),
432
+ ].freeze
433
+ private_constant :PRIVATE_IPV4_RANGES, :PRIVATE_IPV6_RANGES
434
+
343
435
  def loopback_host?(host)
344
436
  return false if host.nil? || host.empty?
345
437
 
@@ -362,6 +454,31 @@ module MCP
362
454
  nil
363
455
  end
364
456
 
457
+ # Matches one part of the `inet_aton` numeric grammar: hexadecimal (`0x7f`),
458
+ # octal (`0177`), or decimal (`127`).
459
+ INET_ATON_PART = /\A(?:0x\h+|0[0-7]*|[1-9]\d*)\z/.freeze
460
+ private_constant :INET_ATON_PART
461
+
462
+ # Returns true when `host` is written in the numeric IPv4 grammar `inet_aton` accepts
463
+ # and `IPAddr` rejects: one to four dot-separated parts, each hexadecimal, octal, or
464
+ # decimal, with the last part filling the remaining bytes. `2130706433`, `127.1`, and
465
+ # `0x7f000001` all reach 127.0.0.1, and `0xa9fea9fe` reaches the cloud metadata address,
466
+ # so a check that understood only the canonical form would refuse `169.254.169.254`
467
+ # while waving through every other spelling of it.
468
+ #
469
+ # Such a host is refused outright rather than decoded and range-checked, because its
470
+ # value is resolver-dependent: a leading zero reads as octal on one platform and decimal
471
+ # on another, which is the difference between 8.0.0.1 and 10.0.0.1 for `010.0.0.1`.
472
+ # Deciding here which one it meant would leave whichever reading was not taken as a way
473
+ # through. No authorization server is legitimately addressed in this notation, so
474
+ # refusing the grammar entirely costs nothing and leaves no spelling to miss.
475
+ def numeric_ipv4_spelling?(host)
476
+ parts = host.split(".", -1)
477
+ return false if parts.empty? || parts.length > 4
478
+
479
+ parts.all? { |part| INET_ATON_PART.match?(part) }
480
+ end
481
+
365
482
  # A redirect URI counts as native when it uses a custom non-http(s) scheme
366
483
  # (e.g. `com.example.app:/callback`) or when it is an http(s) URI whose host is
367
484
  # a loopback address. A URI without a scheme or one that fails to parse is not native.