hitch-rails 0.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 +7 -0
- data/CHANGELOG.md +103 -0
- data/MIT-LICENSE +20 -0
- data/README.md +460 -0
- data/SECURITY.md +118 -0
- data/app/controllers/concerns/hitch/cors_support.rb +97 -0
- data/app/controllers/concerns/hitch/host_validation.rb +51 -0
- data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
- data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
- data/app/controllers/concerns/hitch/request_admission.rb +46 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
- data/app/controllers/hitch/application_controller.rb +59 -0
- data/app/controllers/hitch/authorizations_controller.rb +152 -0
- data/app/controllers/hitch/metadata_controller.rb +114 -0
- data/app/controllers/hitch/preflights_controller.rb +14 -0
- data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
- data/app/controllers/hitch/registrations_controller.rb +135 -0
- data/app/controllers/hitch/revocations_controller.rb +31 -0
- data/app/controllers/hitch/tokens_controller.rb +89 -0
- data/app/models/hitch/access_token.rb +267 -0
- data/app/models/hitch/application_record.rb +7 -0
- data/app/models/hitch/authorization_request.rb +252 -0
- data/app/models/hitch/client/credentials.rb +30 -0
- data/app/models/hitch/client.rb +237 -0
- data/app/models/hitch/client_authentication.rb +80 -0
- data/app/models/hitch/client_id_metadata/cache.rb +69 -0
- data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
- data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
- data/app/models/hitch/client_id_metadata.rb +316 -0
- data/app/models/hitch/client_redirect_uri.rb +14 -0
- data/app/models/hitch/mcp/context.rb +91 -0
- data/app/models/hitch/mcp/forbidden.rb +10 -0
- data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
- data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
- data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
- data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
- data/app/models/hitch/mcp/internal/header_field.rb +31 -0
- data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
- data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
- data/app/models/hitch/mcp/internal/json_values.rb +182 -0
- data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
- data/app/models/hitch/mcp/internal/media_type.rb +61 -0
- data/app/models/hitch/mcp/internal/observation.rb +333 -0
- data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
- data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
- data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
- data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
- data/app/models/hitch/mcp/internal/server_info.rb +49 -0
- data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
- data/app/models/hitch/mcp/internal.rb +11 -0
- data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
- data/app/models/hitch/mcp/registry.rb +70 -0
- data/app/models/hitch/mcp/result.rb +63 -0
- data/app/models/hitch/mcp/tool.rb +148 -0
- data/app/models/hitch/oauth_request_parameters.rb +74 -0
- data/app/views/hitch/authorizations/new.html.erb +57 -0
- data/config/routes.rb +37 -0
- data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
- data/docs/operator/doctor.md +82 -0
- data/docs/operator/rate_limiting.md +98 -0
- data/docs/public_api/0.2.0.md +322 -0
- data/docs/removing.md +43 -0
- data/lib/generators/hitch/generator_guards.rb +36 -0
- data/lib/generators/hitch/install/install_generator.rb +168 -0
- data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
- data/lib/generators/hitch/install/templates/initializer.rb +40 -0
- data/lib/generators/hitch/install/templates/registry.rb +6 -0
- data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
- data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
- data/lib/generators/hitch/tool_generator.rb +153 -0
- data/lib/hitch/configuration.rb +386 -0
- data/lib/hitch/doctor.rb +647 -0
- data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
- data/lib/hitch/engine.rb +154 -0
- data/lib/hitch/mcp/configuration.rb +190 -0
- data/lib/hitch/mcp/protocol.rb +36 -0
- data/lib/hitch/mcp/test_helper.rb +203 -0
- data/lib/hitch/pkce.rb +18 -0
- data/lib/hitch/rack_form_guard.rb +109 -0
- data/lib/hitch/rate_limit_store.rb +47 -0
- data/lib/hitch/resource_uri.rb +71 -0
- data/lib/hitch/version.rb +5 -0
- data/lib/hitch-rails.rb +6 -0
- data/lib/hitch.rb +51 -0
- data/lib/tasks/hitch.rake +197 -0
- metadata +230 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
module Internal
|
|
6
|
+
# Validates Registry declarations into the persistent snapshot at
|
|
7
|
+
# prepare time and resolves listings and calls against it per request.
|
|
8
|
+
# The snapshot retains only class names and frozen data.
|
|
9
|
+
class RegistryRuntime
|
|
10
|
+
CONSTANT_NAME_PATTERN = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
|
|
11
|
+
OAUTH_SCOPE_PATTERN = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/
|
|
12
|
+
MAX_SCOPE_BYTES = 64
|
|
13
|
+
ANNOTATION_KEYS = {
|
|
14
|
+
"title" => :title,
|
|
15
|
+
"readOnlyHint" => :read_only_hint,
|
|
16
|
+
"read_only_hint" => :read_only_hint,
|
|
17
|
+
"destructiveHint" => :destructive_hint,
|
|
18
|
+
"destructive_hint" => :destructive_hint,
|
|
19
|
+
"idempotentHint" => :idempotent_hint,
|
|
20
|
+
"idempotent_hint" => :idempotent_hint,
|
|
21
|
+
"openWorldHint" => :open_world_hint,
|
|
22
|
+
"open_world_hint" => :open_world_hint
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
Entry = Data.define(
|
|
26
|
+
:class_name,
|
|
27
|
+
:name,
|
|
28
|
+
:description,
|
|
29
|
+
:input_schema,
|
|
30
|
+
:output_schema,
|
|
31
|
+
:annotations,
|
|
32
|
+
:scopes,
|
|
33
|
+
# The SDK tool wrapper (anonymous ::MCP::Tool subclass) compiled once
|
|
34
|
+
# here at prepare time instead of per tool per request. It holds only
|
|
35
|
+
# frozen entry data — never the reloadable host tool class.
|
|
36
|
+
:sdk_tool
|
|
37
|
+
)
|
|
38
|
+
Snapshot = Data.define(:registry_name, :entries)
|
|
39
|
+
CallResolution = Data.define(:status, :tool, :required_scopes)
|
|
40
|
+
RuntimeTool = Data.define(:entry, :tool_class) do
|
|
41
|
+
def name = entry.name
|
|
42
|
+
def description = entry.description
|
|
43
|
+
def input_schema = entry.input_schema
|
|
44
|
+
def output_schema = entry.output_schema
|
|
45
|
+
def annotations = entry.annotations
|
|
46
|
+
def sdk_tool = entry.sdk_tool
|
|
47
|
+
|
|
48
|
+
def call(server_context:, **arguments)
|
|
49
|
+
tool_class.call(server_context:, **arguments)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
def build_snapshot(registry_name:, supported_scopes:)
|
|
55
|
+
registry_class = resolve_named_constant(registry_name, "registry")
|
|
56
|
+
unless registry_class.is_a?(Class) && registry_class < Registry
|
|
57
|
+
raise ArgumentError, "mcp.registry must resolve to a Hitch::MCP::Registry subclass"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
supported = validate_supported_scopes(supported_scopes)
|
|
61
|
+
entries = registry_class.declarations.each_with_index.map do |declaration, index|
|
|
62
|
+
build_entry(declaration, index:, supported_scopes: supported)
|
|
63
|
+
end
|
|
64
|
+
duplicate_names = entries.map(&:name).tally.select { |_name, count| count > 1 }.keys
|
|
65
|
+
unless duplicate_names.empty?
|
|
66
|
+
raise ArgumentError, "mcp.registry contains duplicate tool names: #{duplicate_names.sort.join(', ')}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Snapshot.new(
|
|
70
|
+
registry_name: registry_name.dup.freeze,
|
|
71
|
+
entries: entries.sort_by(&:name).freeze
|
|
72
|
+
)
|
|
73
|
+
# Names what failed to resolve. A host reading this at boot has a
|
|
74
|
+
# typo in one line of one file, and the line is the message.
|
|
75
|
+
rescue NameError => error
|
|
76
|
+
raise ArgumentError, "mcp.registry could not be built: #{error.message}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def runtime_listing(snapshot:, context:)
|
|
80
|
+
validate_snapshot!(snapshot)
|
|
81
|
+
|
|
82
|
+
snapshot.entries.filter_map do |entry|
|
|
83
|
+
runtime_tool = available_runtime_tool(entry, context)
|
|
84
|
+
runtime_tool if runtime_tool && scopes_granted?(entry.scopes, context)
|
|
85
|
+
end.freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def runtime_call(snapshot:, name:, context:)
|
|
89
|
+
validate_snapshot!(snapshot)
|
|
90
|
+
entry = snapshot.entries.find { |candidate| candidate.name == name }
|
|
91
|
+
return hidden_call_resolution unless entry
|
|
92
|
+
|
|
93
|
+
runtime_tool = available_runtime_tool(entry, context)
|
|
94
|
+
return hidden_call_resolution unless runtime_tool
|
|
95
|
+
|
|
96
|
+
unless scopes_granted?(entry.scopes, context)
|
|
97
|
+
return CallResolution.new(
|
|
98
|
+
status: :insufficient_scope,
|
|
99
|
+
tool: nil,
|
|
100
|
+
required_scopes: entry.scopes
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
CallResolution.new(
|
|
105
|
+
status: :available,
|
|
106
|
+
tool: runtime_tool,
|
|
107
|
+
required_scopes: [].freeze
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def available_runtime_tool(entry, context)
|
|
114
|
+
tool_class = resolve_named_constant(entry.class_name, "mcp.registry tool")
|
|
115
|
+
unless tool_class.is_a?(Class) && tool_class < Tool &&
|
|
116
|
+
tool_class.name == entry.class_name && tool_class.tool_name == entry.name &&
|
|
117
|
+
tool_class.method(:call).owner == Tool.singleton_class
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"MCP registry is unavailable: #{entry.class_name} no longer matches its registered snapshot"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
available = tool_class.available_to?(context)
|
|
123
|
+
unless available == true || available == false
|
|
124
|
+
raise ArgumentError, "#{entry.class_name}.available_to? must return true or false"
|
|
125
|
+
end
|
|
126
|
+
return unless available
|
|
127
|
+
|
|
128
|
+
RuntimeTool.new(entry:, tool_class:)
|
|
129
|
+
rescue NameError
|
|
130
|
+
raise ArgumentError, "MCP registry is unavailable: #{entry.class_name} could not be resolved"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def scopes_granted?(required_scopes, context)
|
|
134
|
+
granted_scopes = context.granted_scopes
|
|
135
|
+
unless granted_scopes.instance_of?(Array) && granted_scopes.all?(String)
|
|
136
|
+
raise ArgumentError, "MCP context scopes are unavailable"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
required_scopes.all? { |scope| granted_scopes.include?(scope) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def hidden_call_resolution
|
|
143
|
+
CallResolution.new(status: :hidden, tool: nil, required_scopes: [].freeze)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_snapshot!(snapshot)
|
|
147
|
+
unless snapshot.instance_of?(Snapshot)
|
|
148
|
+
raise ArgumentError, "MCP registry is unavailable: no snapshot has been prepared"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def build_entry(declaration, index:, supported_scopes:)
|
|
153
|
+
class_name = declaration.class_name
|
|
154
|
+
unless class_name.is_a?(String) && CONSTANT_NAME_PATTERN.match?(class_name)
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
"mcp.registry entry #{index + 1} got #{declaration.source}; " \
|
|
157
|
+
"register a named Hitch::MCP::Tool subclass, as in `register EchoTool`"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Named from here on. Counting registry entries at someone whose
|
|
161
|
+
# registry is a list of class names makes them count too.
|
|
162
|
+
label = "mcp.registry tool #{class_name}"
|
|
163
|
+
|
|
164
|
+
tool_class = resolve_named_constant(class_name, "#{label} tool")
|
|
165
|
+
unless tool_class.is_a?(Class) && tool_class < Tool
|
|
166
|
+
raise ArgumentError, "#{label} must register a Hitch::MCP::Tool subclass"
|
|
167
|
+
end
|
|
168
|
+
unless tool_class.method(:call).owner == Tool.singleton_class
|
|
169
|
+
raise ArgumentError, "#{label} must not override framework-owned .call"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
name = validate_tool_name(tool_class.tool_name, label)
|
|
173
|
+
description = validate_description(tool_class.description, label)
|
|
174
|
+
input_schema = SchemaContract.new(
|
|
175
|
+
tool_class.input_schema,
|
|
176
|
+
label: "#{label} input_schema"
|
|
177
|
+
).call
|
|
178
|
+
if declares_server_context?(input_schema)
|
|
179
|
+
raise ArgumentError,
|
|
180
|
+
"#{label} input_schema must not name server_context at the top level"
|
|
181
|
+
end
|
|
182
|
+
output_schema = if tool_class.output_schema.nil?
|
|
183
|
+
nil
|
|
184
|
+
else
|
|
185
|
+
SchemaContract.new(
|
|
186
|
+
tool_class.output_schema,
|
|
187
|
+
label: "#{label} output_schema"
|
|
188
|
+
).call
|
|
189
|
+
end
|
|
190
|
+
annotations = validate_annotations(tool_class.annotations, label)
|
|
191
|
+
scopes = validate_scopes(declaration.scopes, label, supported_scopes)
|
|
192
|
+
|
|
193
|
+
Entry.new(
|
|
194
|
+
class_name: class_name.dup.freeze,
|
|
195
|
+
name:,
|
|
196
|
+
description:,
|
|
197
|
+
input_schema:,
|
|
198
|
+
output_schema:,
|
|
199
|
+
annotations:,
|
|
200
|
+
scopes:,
|
|
201
|
+
sdk_tool: SDKAdapter.build_sdk_tool(
|
|
202
|
+
name:, description:, input_schema:, output_schema:, annotations:
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# server_context is Hitch's own channel to the tool, and the wire
|
|
208
|
+
# refuses it as an argument, so a schema naming it can never be
|
|
209
|
+
# satisfied: every call would fail with "Missing required
|
|
210
|
+
# arguments". Caught once at boot instead of on every request.
|
|
211
|
+
#
|
|
212
|
+
# Top level only. A nested server_context is ordinary tool data, and
|
|
213
|
+
# one reached through $ref or allOf is not caught here — chasing
|
|
214
|
+
# those was 28 lines of recursion for a key nobody names by
|
|
215
|
+
# accident.
|
|
216
|
+
def declares_server_context?(schema)
|
|
217
|
+
properties = schema["properties"]
|
|
218
|
+
(properties.is_a?(Hash) && properties.key?("server_context")) ||
|
|
219
|
+
Array(schema["required"]).include?("server_context")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def validate_tool_name(value, label)
|
|
223
|
+
unless Protocol.tool_name?(value)
|
|
224
|
+
raise ArgumentError,
|
|
225
|
+
"#{label} tool_name must be 1-#{Protocol::MAX_TOOL_NAME_LENGTH} ASCII letters, " \
|
|
226
|
+
"digits, underscore, dot, or dash"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
value.dup.freeze
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def validate_description(value, label)
|
|
233
|
+
unless value.is_a?(String) && value.match?(/\S/)
|
|
234
|
+
raise ArgumentError, "#{label} description must be a nonblank String"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
value.dup.freeze
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_annotations(value, label)
|
|
241
|
+
return if value.nil?
|
|
242
|
+
raise ArgumentError, "#{label} annotations must be a Hash" unless value.is_a?(Hash)
|
|
243
|
+
|
|
244
|
+
value.each_with_object({}) do |(key, annotation_value), normalized|
|
|
245
|
+
canonical = ANNOTATION_KEYS[key.to_s]
|
|
246
|
+
raise ArgumentError, "#{label} contains an unsupported annotation" unless canonical
|
|
247
|
+
raise ArgumentError, "#{label} contains a duplicate annotation" if normalized.key?(canonical)
|
|
248
|
+
|
|
249
|
+
normalized[canonical] = if canonical == :title
|
|
250
|
+
unless annotation_value.is_a?(String) && annotation_value.match?(/\S/)
|
|
251
|
+
raise ArgumentError, "#{label} annotation title must be a nonblank String"
|
|
252
|
+
end
|
|
253
|
+
annotation_value.dup.freeze
|
|
254
|
+
else
|
|
255
|
+
unless annotation_value == true || annotation_value == false
|
|
256
|
+
raise ArgumentError, "#{label} tool hint annotations must be Boolean"
|
|
257
|
+
end
|
|
258
|
+
annotation_value
|
|
259
|
+
end
|
|
260
|
+
end.freeze
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def validate_scopes(scopes, label, supported_scopes)
|
|
264
|
+
unless scopes.is_a?(Array) && !scopes.empty?
|
|
265
|
+
raise ArgumentError, "#{label} scopes must be a nonempty Array"
|
|
266
|
+
end
|
|
267
|
+
unless scopes.all? do |scope|
|
|
268
|
+
scope.is_a?(String) && scope.bytesize <= MAX_SCOPE_BYTES && OAUTH_SCOPE_PATTERN.match?(scope)
|
|
269
|
+
end
|
|
270
|
+
raise ArgumentError, "#{label} scopes must contain valid OAuth scope tokens"
|
|
271
|
+
end
|
|
272
|
+
unless scopes.uniq.length == scopes.length
|
|
273
|
+
raise ArgumentError, "#{label} scopes must not contain duplicates"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
unsupported = scopes - supported_scopes
|
|
277
|
+
unless unsupported.empty?
|
|
278
|
+
raise ArgumentError, "#{label} scopes are not present in Hitch.configuration.supported_scopes"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
scopes.map { |scope| scope.dup.freeze }.freeze
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def validate_supported_scopes(scopes)
|
|
285
|
+
unless scopes.is_a?(Array) && scopes.all?(String)
|
|
286
|
+
raise ArgumentError, "supported_scopes must be an Array of Strings"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
scopes
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def resolve_named_constant(name, label)
|
|
293
|
+
unless name.is_a?(String) && CONSTANT_NAME_PATTERN.match?(name)
|
|
294
|
+
raise ArgumentError, "#{label} must be a named constant"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
constant = name.split("::").reduce(Object) do |namespace, part|
|
|
298
|
+
raise NameError, "#{label} #{name} is not a resolvable constant" unless namespace.is_a?(Module)
|
|
299
|
+
|
|
300
|
+
namespace.const_get(part, false)
|
|
301
|
+
end
|
|
302
|
+
unless constant.respond_to?(:name) && constant.name == name
|
|
303
|
+
raise ArgumentError, "#{label} must resolve to its exact named constant"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
constant
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
|
|
6
|
+
module Hitch
|
|
7
|
+
module MCP
|
|
8
|
+
module Internal
|
|
9
|
+
# Converts the closed host Result into the one exact SDK response that was
|
|
10
|
+
# independently schema-validated and measured before SDK validation.
|
|
11
|
+
class ResultNormalizer
|
|
12
|
+
JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"
|
|
13
|
+
ROOT_SCHEMA_KEYWORDS = %w[type $ref oneOf anyOf allOf not if const enum].freeze
|
|
14
|
+
|
|
15
|
+
class Failure < StandardError
|
|
16
|
+
attr_reader :category
|
|
17
|
+
|
|
18
|
+
def initialize(category)
|
|
19
|
+
@category = category
|
|
20
|
+
super("Hitch MCP result normalization failed")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Duck-typed stand-in for ::MCP::Tool::Response — both supported SDK
|
|
25
|
+
# lines read only #to_h and #content_provided?. explicit_error_text is
|
|
26
|
+
# the host-authored Result.error string, carried out of band so the
|
|
27
|
+
# response normalizer can tell it apart from SDK-generated errors.
|
|
28
|
+
class SDKResponse
|
|
29
|
+
attr_reader :explicit_error_text
|
|
30
|
+
|
|
31
|
+
def initialize(result, content_provided:, explicit_error_text: nil)
|
|
32
|
+
@hitch_result = result
|
|
33
|
+
@hitch_content_provided = content_provided
|
|
34
|
+
@explicit_error_text = explicit_error_text
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
JsonValues.copy(@hitch_result)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def content_provided?
|
|
42
|
+
@hitch_content_provided
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
def call(result:, output_schema:, max_bytes:)
|
|
48
|
+
new(result, output_schema, max_bytes).call
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def failure_category(error)
|
|
52
|
+
error.category if error.instance_of?(Failure)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def explicit_error_text(response)
|
|
56
|
+
response.explicit_error_text if response.instance_of?(SDKResponse)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(result, output_schema, max_bytes)
|
|
61
|
+
@result = result
|
|
62
|
+
@output_schema = output_schema
|
|
63
|
+
@max_bytes = max_bytes
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def call
|
|
67
|
+
invalid!(:invalid_result_type) unless result.instance_of?(Result)
|
|
68
|
+
invalid!(:invalid_result_limit) unless max_bytes.instance_of?(Integer) && max_bytes.positive?
|
|
69
|
+
|
|
70
|
+
canonical, content_provided, explicit_error_text = canonical_result
|
|
71
|
+
serialized = generate(canonical)
|
|
72
|
+
invalid!(:result_too_large) if serialized.bytesize > max_bytes
|
|
73
|
+
|
|
74
|
+
SDKResponse.new(canonical, content_provided:, explicit_error_text:)
|
|
75
|
+
rescue SystemStackError
|
|
76
|
+
invalid!(:serialization_failure)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
attr_reader :result, :output_schema, :max_bytes
|
|
82
|
+
|
|
83
|
+
def canonical_result
|
|
84
|
+
case result.kind
|
|
85
|
+
when :text then text_result
|
|
86
|
+
when :structured then structured_result
|
|
87
|
+
when :error then error_result
|
|
88
|
+
else invalid!(:invalid_result_type)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def text_result
|
|
93
|
+
validate_output_schema!(nil) if output_schema
|
|
94
|
+
content = text_content(result.value)
|
|
95
|
+
[ deep_freeze(content: content, isError: false), true, nil ]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def structured_result
|
|
99
|
+
invalid!(:missing_output_schema) unless output_schema
|
|
100
|
+
|
|
101
|
+
value = result.value
|
|
102
|
+
serialized_value = generate(value)
|
|
103
|
+
validate_output_schema!(value)
|
|
104
|
+
text = result.text
|
|
105
|
+
fallback = value.is_a?(Hash) ? nil : serialized_value
|
|
106
|
+
content = text || fallback
|
|
107
|
+
canonical = {
|
|
108
|
+
content: content ? text_content(content) : [],
|
|
109
|
+
isError: false,
|
|
110
|
+
structuredContent: value
|
|
111
|
+
}
|
|
112
|
+
[ deep_freeze(canonical), !content.nil?, nil ]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def error_result
|
|
116
|
+
text = result.value
|
|
117
|
+
[ deep_freeze(content: text_content(text), isError: true), true, text ]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_output_schema!(value)
|
|
121
|
+
schema = normalized_output_schema
|
|
122
|
+
schema = schema.merge("type" => "object") unless ROOT_SCHEMA_KEYWORDS.any? { |key| schema.key?(key) }
|
|
123
|
+
resolver = ->(uri) { raise JSONSchemer::UnknownRef, uri.to_s }
|
|
124
|
+
schemer = JSONSchemer.schema(
|
|
125
|
+
schema,
|
|
126
|
+
meta_schema: JSON_SCHEMA_2020_12,
|
|
127
|
+
format: false,
|
|
128
|
+
ref_resolver: resolver,
|
|
129
|
+
regexp_resolver: "ruby"
|
|
130
|
+
)
|
|
131
|
+
invalid!(:output_schema_mismatch) unless schemer.valid?(value)
|
|
132
|
+
rescue Failure
|
|
133
|
+
raise
|
|
134
|
+
rescue StandardError
|
|
135
|
+
invalid!(:output_schema_validation_failure)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def normalized_output_schema
|
|
139
|
+
parsed = JSON.parse(generate(output_schema))
|
|
140
|
+
invalid!(:output_schema_validation_failure) unless parsed.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
parsed
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def generate(value)
|
|
146
|
+
JSON.generate(value, max_nesting: false)
|
|
147
|
+
rescue JSON::GeneratorError, EncodingError
|
|
148
|
+
invalid!(:serialization_failure)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def text_content(text)
|
|
152
|
+
[ { type: "text", text: text } ]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def deep_freeze(value)
|
|
156
|
+
JsonValues.deep_freeze(value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def invalid!(category)
|
|
160
|
+
raise Failure, category
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private_constant :Failure, :SDKResponse
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module MCP
|
|
5
|
+
module Internal
|
|
6
|
+
# Reports a synthetic failure with caller-fixed context through the host
|
|
7
|
+
# error reporter. The original exception never crosses this boundary, and
|
|
8
|
+
# every reporting failure is swallowed.
|
|
9
|
+
class SanitizedReport
|
|
10
|
+
class ReportedFailure < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def emit(source:, context:, message:)
|
|
14
|
+
return unless Rails.respond_to?(:error)
|
|
15
|
+
|
|
16
|
+
failure = ReportedFailure.new(message)
|
|
17
|
+
failure.set_backtrace(caller(1, 8))
|
|
18
|
+
failure.freeze
|
|
19
|
+
Rails.error.report(
|
|
20
|
+
failure,
|
|
21
|
+
handled: true,
|
|
22
|
+
severity: :error,
|
|
23
|
+
context: context,
|
|
24
|
+
source: source
|
|
25
|
+
)
|
|
26
|
+
nil
|
|
27
|
+
rescue StandardError, SystemStackError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private_constant :ReportedFailure
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Hitch
|
|
8
|
+
module MCP
|
|
9
|
+
module Internal
|
|
10
|
+
# Admission-time contract for a tool's input or output schema: a bounded,
|
|
11
|
+
# copied, frozen JSON Schema 2020-12 document with only same-document
|
|
12
|
+
# references.
|
|
13
|
+
class SchemaContract
|
|
14
|
+
MAX_SCHEMA_DEPTH = 64
|
|
15
|
+
MAX_SCHEMA_OBJECTS = 10_000
|
|
16
|
+
MAX_SCHEMA_BYTES = 1_048_576
|
|
17
|
+
JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"
|
|
18
|
+
REFERENCE_KEYS = %w[$ref $dynamicRef].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(value, label:)
|
|
21
|
+
@label = label
|
|
22
|
+
@schema = copy_schema(value)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
invalid!("must be a JSON object") unless @schema.instance_of?(Hash)
|
|
27
|
+
invalid!("exceeds #{MAX_SCHEMA_BYTES} serialized bytes") if serialized_bytes > MAX_SCHEMA_BYTES
|
|
28
|
+
|
|
29
|
+
validate_dialects_and_references!
|
|
30
|
+
validate_json_schema!
|
|
31
|
+
|
|
32
|
+
@schema
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :label
|
|
38
|
+
|
|
39
|
+
def copy_schema(value)
|
|
40
|
+
JsonValues.copy(
|
|
41
|
+
value,
|
|
42
|
+
keys: :stringify_symbols, symbols: :to_s, foreign: :reject,
|
|
43
|
+
finite: true, duplicates: :reject, freeze: true,
|
|
44
|
+
max_depth: MAX_SCHEMA_DEPTH, max_objects: MAX_SCHEMA_OBJECTS,
|
|
45
|
+
on_invalid: method(:copy_invalid!)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def copy_invalid!(reason, detail)
|
|
50
|
+
invalid!(
|
|
51
|
+
case reason
|
|
52
|
+
when :depth then "nesting exceeds #{MAX_SCHEMA_DEPTH}"
|
|
53
|
+
when :recursive then "contains a recursive Ruby object"
|
|
54
|
+
when :objects then "exceeds #{MAX_SCHEMA_OBJECTS} schema objects"
|
|
55
|
+
when :key then "contains a non-string schema key"
|
|
56
|
+
when :duplicate_key then "contains duplicate key #{detail.inspect}"
|
|
57
|
+
when :non_finite then "contains a non-finite number"
|
|
58
|
+
else "contains a non-JSON value"
|
|
59
|
+
end
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serialized_bytes
|
|
64
|
+
JSON.generate(@schema, max_nesting: false).bytesize
|
|
65
|
+
rescue JSON::GeneratorError
|
|
66
|
+
invalid!("cannot be serialized as JSON")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_dialects_and_references!
|
|
70
|
+
each_schema_value(@schema) do |node|
|
|
71
|
+
next unless node.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
dialect = node["$schema"]
|
|
74
|
+
if dialect && dialect != JSON_SCHEMA_2020_12
|
|
75
|
+
invalid!("must use JSON Schema 2020-12")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
REFERENCE_KEYS.each do |key|
|
|
79
|
+
next unless node.key?(key)
|
|
80
|
+
|
|
81
|
+
reference = node[key]
|
|
82
|
+
unless reference.is_a?(String) && reference.start_with?("#")
|
|
83
|
+
invalid!("supports only same-document #{key} values")
|
|
84
|
+
end
|
|
85
|
+
resolve_local_reference(reference)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_json_schema!
|
|
91
|
+
resolver = lambda do |uri|
|
|
92
|
+
raise JSONSchemer::UnknownRef, uri.to_s
|
|
93
|
+
end
|
|
94
|
+
schemer = JSONSchemer.schema(
|
|
95
|
+
@schema,
|
|
96
|
+
meta_schema: JSON_SCHEMA_2020_12,
|
|
97
|
+
format: false,
|
|
98
|
+
ref_resolver: resolver,
|
|
99
|
+
regexp_resolver: "ruby"
|
|
100
|
+
)
|
|
101
|
+
errors = schemer.validate_schema.to_a
|
|
102
|
+
invalid!("is not a valid JSON Schema 2020-12 document") unless errors.empty?
|
|
103
|
+
rescue JSONSchemer::InvalidRefResolution, JSONSchemer::InvalidRefPointer,
|
|
104
|
+
JSONSchemer::UnknownRef, RegexpError, URI::InvalidURIError
|
|
105
|
+
invalid!("is not a valid JSON Schema 2020-12 document")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_local_reference(reference)
|
|
109
|
+
fragment = reference.delete_prefix("#")
|
|
110
|
+
return @schema if fragment.empty?
|
|
111
|
+
|
|
112
|
+
decoded = URI::DEFAULT_PARSER.unescape(fragment)
|
|
113
|
+
invalid!("contains an invalid local reference") unless decoded.valid_encoding?
|
|
114
|
+
|
|
115
|
+
if decoded.start_with?("/")
|
|
116
|
+
decoded.split("/", -1).drop(1).reduce(@schema) do |node, token|
|
|
117
|
+
key = token.gsub(/~1/, "/").gsub(/~0/, "~")
|
|
118
|
+
case node
|
|
119
|
+
when Hash
|
|
120
|
+
invalid!("contains an unresolved local reference") unless node.key?(key)
|
|
121
|
+
node.fetch(key)
|
|
122
|
+
when Array
|
|
123
|
+
index = Integer(key, exception: false)
|
|
124
|
+
invalid!("contains an unresolved local reference") unless index && index >= 0 && index < node.length
|
|
125
|
+
node.fetch(index)
|
|
126
|
+
else
|
|
127
|
+
invalid!("contains an unresolved local reference")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
anchor = find_anchor(@schema, decoded)
|
|
132
|
+
invalid!("contains an unresolved local reference") unless anchor
|
|
133
|
+
anchor
|
|
134
|
+
end
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
invalid!("contains an invalid local reference")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def find_anchor(value, name)
|
|
140
|
+
case value
|
|
141
|
+
when Hash
|
|
142
|
+
return value if value["$anchor"] == name || value["$dynamicAnchor"] == name
|
|
143
|
+
|
|
144
|
+
value.each_value do |child|
|
|
145
|
+
found = find_anchor(child, name)
|
|
146
|
+
return found if found
|
|
147
|
+
end
|
|
148
|
+
when Array
|
|
149
|
+
value.each do |child|
|
|
150
|
+
found = find_anchor(child, name)
|
|
151
|
+
return found if found
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def each_schema_value(value, &block)
|
|
158
|
+
yield value
|
|
159
|
+
case value
|
|
160
|
+
when Hash
|
|
161
|
+
value.each_value { |child| each_schema_value(child, &block) }
|
|
162
|
+
when Array
|
|
163
|
+
value.each { |child| each_schema_value(child, &block) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def invalid!(reason)
|
|
168
|
+
raise ArgumentError, "#{label} #{reason}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|