mcp 1.1.0 → 1.3.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
@@ -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#default_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
@@ -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.