ruby-mcp-client 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- metadata +68 -2
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
# MCP 2026-07-28 Streamable HTTP "Custom Headers from Tool Parameters"
|
|
5
|
+
# (SEP-2243). A tool's inputSchema may annotate a property with
|
|
6
|
+
# `x-mcp-header`; on the Streamable HTTP transport the client mirrors the
|
|
7
|
+
# argument value into an `Mcp-Param-{name}` request header so that
|
|
8
|
+
# intermediaries can route on it without parsing the body.
|
|
9
|
+
#
|
|
10
|
+
# This module validates the annotations (clients MUST reject tool
|
|
11
|
+
# definitions that violate the constraints) and extracts the header values
|
|
12
|
+
# for a call (clients MUST mirror the designated values, omitting a header
|
|
13
|
+
# whose argument is absent or null).
|
|
14
|
+
module HeaderParams
|
|
15
|
+
ANNOTATION = 'x-mcp-header'
|
|
16
|
+
HEADER_PREFIX = 'Mcp-Param-'
|
|
17
|
+
# HTTP field names are case-insensitive, so membership of the mirrored
|
|
18
|
+
# namespace is decided on the lower-cased name.
|
|
19
|
+
HEADER_PREFIX_DOWNCASE = HEADER_PREFIX.downcase.freeze
|
|
20
|
+
|
|
21
|
+
# HTTP field-name token: 1*tchar (RFC 9110 Section 5.6.2)
|
|
22
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
23
|
+
|
|
24
|
+
# The only JSON Schema types an annotated property may have.
|
|
25
|
+
PRIMITIVE_TYPES = %w[string integer boolean].freeze
|
|
26
|
+
|
|
27
|
+
# Integer values must fit IEEE754 double precision exactly.
|
|
28
|
+
SAFE_INTEGER_MAX = (2**53) - 1
|
|
29
|
+
SAFE_INTEGER_MIN = -SAFE_INTEGER_MAX
|
|
30
|
+
|
|
31
|
+
# Header value that may travel as-is: visible ASCII (0x21-0x7E), spaces
|
|
32
|
+
# and tabs only in the interior (RFC 9110 field values; MCP 2026-07-28
|
|
33
|
+
# Streamable HTTP "Value Encoding"). RFC 9110 field values may also be
|
|
34
|
+
# empty, so an empty string travels as an empty field value rather than
|
|
35
|
+
# as an encoding of nothing.
|
|
36
|
+
HEADER_SAFE_VALUE = /\A(?:[\x21-\x7E](?:[\x20-\x7E\t]*[\x21-\x7E])?)?\z/
|
|
37
|
+
|
|
38
|
+
# The Base64 sentinel markers; a plain value that starts with the one and
|
|
39
|
+
# ends with the other must itself be encoded to avoid ambiguity. The rule
|
|
40
|
+
# is on the start and the end alone: "=?base64?=" is sentinel-shaped even
|
|
41
|
+
# though its two markers overlap.
|
|
42
|
+
BASE64_SENTINEL_START = '=?base64?'
|
|
43
|
+
BASE64_SENTINEL_END = '?='
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Check every `x-mcp-header` annotation in an inputSchema against the
|
|
48
|
+
# transport's constraints.
|
|
49
|
+
# @param schema [Hash, nil] the tool's inputSchema
|
|
50
|
+
# @return [Array<String>] violations (empty when the schema is acceptable)
|
|
51
|
+
def validate_schema(schema)
|
|
52
|
+
return [] unless schema.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
errors = []
|
|
55
|
+
walk(schema, [], root: true, reachable: false, document: schema, errors: errors, seen: {}, found: [])
|
|
56
|
+
errors
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The statically reachable annotated properties of an inputSchema.
|
|
60
|
+
# @param schema [Hash, nil] the tool's inputSchema
|
|
61
|
+
# @return [Array<Array(Array<String>, String)>] [property path, header name] pairs
|
|
62
|
+
def annotations(schema)
|
|
63
|
+
return [] unless schema.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
found = []
|
|
66
|
+
walk(schema, [], root: true, reachable: false, document: schema, errors: [], seen: {}, found: found)
|
|
67
|
+
found
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Whether an HTTP header name belongs to the mirrored namespace, which
|
|
71
|
+
# the client owns on a modern session: its members are derived from the
|
|
72
|
+
# call's arguments and from nothing else.
|
|
73
|
+
# @param name [String, Symbol] an HTTP header name
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def mirrored_header?(name)
|
|
76
|
+
name.to_s.downcase.start_with?(HEADER_PREFIX_DOWNCASE)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The `Mcp-Param-*` headers for one tools/call.
|
|
80
|
+
# @param schema [Hash, nil] the tool's inputSchema
|
|
81
|
+
# @param arguments [Hash, nil] the call arguments (String or Symbol keys)
|
|
82
|
+
# @return [Hash{String => String}] header name => encoded value
|
|
83
|
+
# @raise [MCPClient::Errors::ValidationError] when an annotated argument cannot be mirrored
|
|
84
|
+
def headers_for(schema, arguments)
|
|
85
|
+
annotations(schema).each_with_object({}) do |(path, name), headers|
|
|
86
|
+
value = dig_argument(arguments, path)
|
|
87
|
+
next if value.nil?
|
|
88
|
+
|
|
89
|
+
headers["#{HEADER_PREFIX}#{name}"] = encode_value(value, path)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Encode a parameter value for an MCP request header (Mcp-Name,
|
|
94
|
+
# Mcp-Param-*): strings as-is when header-safe, integers in decimal,
|
|
95
|
+
# booleans lowercase; anything not safely representable — non-ASCII,
|
|
96
|
+
# control characters, leading/trailing whitespace, or a value that looks
|
|
97
|
+
# like the sentinel — as `=?base64?<b64 of UTF-8>?=`.
|
|
98
|
+
#
|
|
99
|
+
# A Ruby String carries an encoding of its own, and the value being
|
|
100
|
+
# mirrored is the one the JSON body carries: UTF-8. The conversion
|
|
101
|
+
# therefore comes first — deciding header safety on, say, UTF-16 bytes
|
|
102
|
+
# would be deciding it on a different string (and an ASCII pattern cannot
|
|
103
|
+
# even be matched against one).
|
|
104
|
+
# @param value [String, Integer, true, false] the parameter value
|
|
105
|
+
# @return [String] the header value
|
|
106
|
+
def encode_header_value(value)
|
|
107
|
+
text = value.to_s.encode('UTF-8')
|
|
108
|
+
return text if text.match?(HEADER_SAFE_VALUE) && !sentinel_shaped?(text)
|
|
109
|
+
|
|
110
|
+
"=?base64?#{[text].pack('m0')}?="
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Encode one mirrored argument, enforcing the primitive-type and safe
|
|
114
|
+
# integer constraints.
|
|
115
|
+
# @param value [Object] the argument value
|
|
116
|
+
# @param path [Array<String>] the property path (for messages)
|
|
117
|
+
# @return [String]
|
|
118
|
+
# @raise [MCPClient::Errors::ValidationError]
|
|
119
|
+
def encode_value(value, path)
|
|
120
|
+
# JSON has no integer type: a schema integer may be parsed as 42.0.
|
|
121
|
+
value = value.to_i if value.is_a?(Float) && value.finite? && value == value.floor
|
|
122
|
+
case value
|
|
123
|
+
when Integer
|
|
124
|
+
unless value.between?(SAFE_INTEGER_MIN, SAFE_INTEGER_MAX)
|
|
125
|
+
raise MCPClient::Errors::ValidationError,
|
|
126
|
+
"Argument #{path.join('.')} is mirrored into an HTTP header and must be within the safe " \
|
|
127
|
+
"integer range (#{SAFE_INTEGER_MIN}..#{SAFE_INTEGER_MAX})"
|
|
128
|
+
end
|
|
129
|
+
value.to_s
|
|
130
|
+
when String, true, false
|
|
131
|
+
encode_header_value(value)
|
|
132
|
+
else
|
|
133
|
+
raise MCPClient::Errors::ValidationError,
|
|
134
|
+
"Argument #{path.join('.')} is mirrored into an HTTP header and must be a primitive " \
|
|
135
|
+
"(string, integer or boolean), got #{value.class}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @param text [String] a UTF-8 header value
|
|
140
|
+
# @return [Boolean] whether the value would read as a Base64 sentinel
|
|
141
|
+
def sentinel_shaped?(text)
|
|
142
|
+
text.start_with?(BASE64_SENTINEL_START) && text.end_with?(BASE64_SENTINEL_END)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Read the argument at an exact property path, accepting String or
|
|
146
|
+
# Symbol keys at each step. A step given under both kinds of key with
|
|
147
|
+
# different values is rejected: the JSON body serializes both, which one
|
|
148
|
+
# the server reads is its business, and no header can agree with an
|
|
149
|
+
# argument that is two values.
|
|
150
|
+
# @return [Object, nil] the value, nil when absent (or explicitly null)
|
|
151
|
+
# @raise [MCPClient::Errors::ValidationError] on conflicting String/Symbol keys
|
|
152
|
+
def dig_argument(arguments, path)
|
|
153
|
+
path.reduce(arguments) do |node, key|
|
|
154
|
+
return nil unless node.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
if node.key?(key) && node.key?(key.to_sym) && node[key] != node[key.to_sym]
|
|
157
|
+
raise MCPClient::Errors::ValidationError,
|
|
158
|
+
"Argument #{path.join('.')} is given under both a String and a Symbol key with different values"
|
|
159
|
+
end
|
|
160
|
+
node.key?(key) ? node[key] : node[key.to_sym]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# JSON Schema 2020-12 keywords whose value is one subschema.
|
|
165
|
+
SCHEMA_KEYWORDS = %w[additionalProperties items contains not if then else propertyNames
|
|
166
|
+
unevaluatedProperties unevaluatedItems additionalItems contentSchema].freeze
|
|
167
|
+
# Keywords whose value is a map of subschemas (draft-07 `dependencies`
|
|
168
|
+
# may hold schemas too).
|
|
169
|
+
SCHEMA_MAP_KEYWORDS = %w[properties patternProperties $defs definitions dependentSchemas dependencies].freeze
|
|
170
|
+
# Keywords whose value is an array of subschemas.
|
|
171
|
+
SCHEMA_ARRAY_KEYWORDS = %w[allOf anyOf oneOf prefixItems].freeze
|
|
172
|
+
|
|
173
|
+
# Recursive schema walk over schema-bearing keywords only (instance data
|
|
174
|
+
# such as `default`, `examples`, `enum` or `const` is never a schema). A
|
|
175
|
+
# node is a *reachable property* when the chain from the root to it
|
|
176
|
+
# consists solely of `properties` keys; annotations anywhere else (items,
|
|
177
|
+
# composition and conditional keywords, $defs, $ref targets, the root
|
|
178
|
+
# itself) invalidate the tool.
|
|
179
|
+
# @api private
|
|
180
|
+
def walk(node, path, root:, reachable:, document:, errors:, seen:, found:)
|
|
181
|
+
return unless node.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
ctx = { document: document, errors: errors, seen: seen, found: found }
|
|
184
|
+
check_annotation(node, path, reachable, document, errors, seen, found) if annotated?(node)
|
|
185
|
+
node.each do |key, value|
|
|
186
|
+
key_name = key.to_s
|
|
187
|
+
if key_name == 'properties' && value.is_a?(Hash)
|
|
188
|
+
value.each { |name, prop| walk(prop, path + [name.to_s], root: false, reachable: root || reachable, **ctx) }
|
|
189
|
+
else
|
|
190
|
+
subschemas(key_name, value).each { |sub| walk(sub, path + [key_name], root: false, reachable: false, **ctx) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The subschemas held by a keyword's value (none for instance data such
|
|
196
|
+
# as default, examples, enum or const).
|
|
197
|
+
# @api private
|
|
198
|
+
def subschemas(key_name, value)
|
|
199
|
+
if SCHEMA_MAP_KEYWORDS.include?(key_name)
|
|
200
|
+
value.is_a?(Hash) ? value.values : []
|
|
201
|
+
elsif SCHEMA_ARRAY_KEYWORDS.include?(key_name) || (key_name == 'items' && value.is_a?(Array))
|
|
202
|
+
Array(value)
|
|
203
|
+
elsif SCHEMA_KEYWORDS.include?(key_name)
|
|
204
|
+
[value]
|
|
205
|
+
else
|
|
206
|
+
[]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Whether a property schema declares exactly one of the primitive types.
|
|
211
|
+
#
|
|
212
|
+
# A type array naming the one type is the same declaration as the bare
|
|
213
|
+
# string, and JSON Schema has no other way to spell a nullable primitive
|
|
214
|
+
# than to union it with "null": the constraint is on the property's type,
|
|
215
|
+
# while a null *value* has its own rule -- the header is omitted -- so
|
|
216
|
+
# dropping the whole tool over `["string", "null"]` would reject a schema
|
|
217
|
+
# the transport can mirror perfectly well.
|
|
218
|
+
#
|
|
219
|
+
# A property that states its type through a reference states it all the
|
|
220
|
+
# same: JSON Schema 2020-12 evaluates `$ref` beside its siblings (Core
|
|
221
|
+
# 8.2.3.1), so `{"$ref": "#/$defs/r", "x-mcp-header": "Region"}` is a
|
|
222
|
+
# primitive property whenever the target is one. That is separate from
|
|
223
|
+
# the reachability rule, which is about where the ANNOTATION sits and
|
|
224
|
+
# still never passes through a reference.
|
|
225
|
+
# @api private
|
|
226
|
+
def primitive_type?(node, document = nil)
|
|
227
|
+
node = typed_node(node, document)
|
|
228
|
+
return false unless node.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
type = node.key?('type') ? node['type'] : node[:type]
|
|
231
|
+
declared = Array(type) - ['null']
|
|
232
|
+
declared.size == 1 && declared.first.is_a?(String) && PRIMITIVE_TYPES.include?(declared.first)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# How many references the type lookup will follow before giving up.
|
|
236
|
+
MAX_REF_HOPS = 8
|
|
237
|
+
|
|
238
|
+
# The schema object that states this property's type: the node itself, or
|
|
239
|
+
# what its local `$ref` chain leads to. A reference this client cannot
|
|
240
|
+
# resolve on its own -- an external URI, a pointer into nothing, a cycle,
|
|
241
|
+
# or a name whose pointer escapes are percent-encoded -- resolves to
|
|
242
|
+
# nothing, and the property is treated as one whose type is unstated: the
|
|
243
|
+
# tool is excluded rather than mirrored on a guess.
|
|
244
|
+
# @api private
|
|
245
|
+
def typed_node(node, document)
|
|
246
|
+
hops = 0
|
|
247
|
+
seen = []
|
|
248
|
+
while node.is_a?(Hash) && !node.key?('type') && !node.key?(:type)
|
|
249
|
+
ref = node.key?('$ref') ? node['$ref'] : node[:$ref]
|
|
250
|
+
return nil unless ref.is_a?(String) && !seen.include?(ref) && (hops += 1) <= MAX_REF_HOPS
|
|
251
|
+
|
|
252
|
+
seen << ref
|
|
253
|
+
node = resolve_local_pointer(document, ref)
|
|
254
|
+
end
|
|
255
|
+
node
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Resolve a same-document JSON pointer (RFC 6901) given as a URI fragment.
|
|
259
|
+
# @api private
|
|
260
|
+
def resolve_local_pointer(document, ref)
|
|
261
|
+
return nil unless document.is_a?(Hash) && ref.start_with?('#')
|
|
262
|
+
|
|
263
|
+
pointer = ref[1..]
|
|
264
|
+
return document if pointer.empty?
|
|
265
|
+
return nil unless pointer.start_with?('/')
|
|
266
|
+
|
|
267
|
+
pointer.split('/', -1).drop(1).reduce(document) do |node, token|
|
|
268
|
+
return nil if token.include?('%')
|
|
269
|
+
|
|
270
|
+
pointer_step(node, token.gsub('~1', '/').gsub('~0', '~'))
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# @api private
|
|
275
|
+
def pointer_step(node, token)
|
|
276
|
+
case node
|
|
277
|
+
when Hash then node.key?(token) ? node[token] : node[token.to_sym]
|
|
278
|
+
when Array then token.match?(/\A(?:0|[1-9][0-9]*)\z/) ? node[token.to_i] : nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @api private
|
|
283
|
+
def annotated?(node)
|
|
284
|
+
node.key?(ANNOTATION) || node.key?(ANNOTATION.to_sym)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# @api private
|
|
288
|
+
def check_annotation(node, path, reachable, document, errors, seen, found)
|
|
289
|
+
value = node.key?(ANNOTATION) ? node[ANNOTATION] : node[ANNOTATION.to_sym]
|
|
290
|
+
# Property names are peer-controlled: inspect escapes control characters.
|
|
291
|
+
where = path.empty? ? 'the schema root' : path.join('.').inspect
|
|
292
|
+
|
|
293
|
+
unless value.is_a?(String)
|
|
294
|
+
errors << "#{ANNOTATION} at #{where} must be a string"
|
|
295
|
+
return
|
|
296
|
+
end
|
|
297
|
+
errors << "#{ANNOTATION} at #{where} must not be empty" if value.empty?
|
|
298
|
+
if !value.empty? && !value.match?(TOKEN)
|
|
299
|
+
errors << "#{ANNOTATION} at #{where} must be an HTTP field-name token (#{value.inspect})"
|
|
300
|
+
end
|
|
301
|
+
unless reachable
|
|
302
|
+
errors << "#{ANNOTATION} at #{where} is not statically reachable via properties keys from the schema root"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
unless primitive_type?(node, document)
|
|
306
|
+
errors << "#{ANNOTATION} at #{where} must be on a primitive property (integer, string or boolean)"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
key = value.downcase
|
|
310
|
+
if seen.key?(key)
|
|
311
|
+
errors << "#{ANNOTATION} values must be case-insensitively unique: #{value.inspect} at #{where} " \
|
|
312
|
+
"duplicates #{seen[key]}"
|
|
313
|
+
else
|
|
314
|
+
seen[key] = where
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
found << [path, value] if reachable && errors.empty?
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module MCPClient
|
|
6
|
+
module HttpTransportBase
|
|
7
|
+
# Inflation of peer-supplied gzip bytes under an expansion bound. The peer
|
|
8
|
+
# controls the compression ratio, so the bound is applied to the pieces
|
|
9
|
+
# zlib produces as it produces them: a body that expands far past the
|
|
10
|
+
# ceiling is stopped at the first piece that crosses it, never allocated
|
|
11
|
+
# in full and then measured.
|
|
12
|
+
module BoundedInflate
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param inflater [Zlib::Inflate] the stream the bytes belong to
|
|
16
|
+
# @param bytes [String] gzip bytes as they arrived
|
|
17
|
+
# @param limit [Integer, nil] ceiling on the inflated size, nil for none
|
|
18
|
+
# @param inflated_so_far [Integer] bytes this stream already produced
|
|
19
|
+
# @return [String, nil] the text these bytes expand to; nil once the
|
|
20
|
+
# expansion would cross the limit
|
|
21
|
+
def inflate(inflater, bytes, limit, inflated_so_far = 0)
|
|
22
|
+
text = +''.b
|
|
23
|
+
over = ->(piece) { limit && inflated_so_far + text.bytesize + piece.bytesize > limit }
|
|
24
|
+
catch(:over_the_bound) do
|
|
25
|
+
inflater.inflate(bytes) do |piece|
|
|
26
|
+
throw :over_the_bound if over.call(piece)
|
|
27
|
+
|
|
28
|
+
text << piece
|
|
29
|
+
end
|
|
30
|
+
# zlib hands over full pieces as it fills them; what is left of a
|
|
31
|
+
# stream that has not ended stays in its buffer until asked for.
|
|
32
|
+
tail = inflater.flush_next_out
|
|
33
|
+
throw :over_the_bound if over.call(tail)
|
|
34
|
+
|
|
35
|
+
return text << tail
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|