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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. 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