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,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack/mock"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module Hitch
9
+ class Doctor
10
+ SCHEMA = "hitch.doctor.v1"
11
+ CHECK_IDS = %w[
12
+ versions
13
+ configuration
14
+ resource_discovery
15
+ route_order
16
+ migrations
17
+ registry
18
+ hosts
19
+ origins
20
+ rate_limit_store
21
+ ].freeze
22
+ # What to do about it, keyed by the code that named it. A diagnosis
23
+ # without a next step sends the reader back to the source, which is the
24
+ # thing a doctor exists to save them from. Machine consumers get the
25
+ # same answer from `details` plus this file.
26
+ REMEDIES = {
27
+ "unsupported" => "Match Hitch's supported window, or upgrade Hitch.",
28
+ "invalid" => "Run the failing setting's validation directly: Hitch.configuration.validate!",
29
+ "unresolvable" => "Boot the app to see which tool failed; " \
30
+ "Hitch.configuration.validate! does not build the registry and will report success.",
31
+ "mismatch" => "resource_uri must equal the URI clients send as `resource`, byte for byte.",
32
+ "missing_endpoint" => "Add `match \"/mcp\", to: \"mcp#handle\", via: :all` to config/routes.rb.",
33
+ "invalid_engine_mount" => "Mount the engine exactly once, at root: `mount Hitch::Engine => \"/\"`.",
34
+ "wrong_verbs" => "The MCP route needs `via: :all` — the endpoint answers POST and OPTIONS.",
35
+ "shadowed" => "Move the MCP route above whichever host route matches the same path first.",
36
+ "after_engine" => "Put the MCP route before `mount Hitch::Engine`.",
37
+ "missing" => "Run bin/rails db:migrate.",
38
+ "empty" => "Register a tool: bin/rails generate hitch:tool NAME.",
39
+ "blocked" => "Add the host to config.hosts, or remove it from Hitch's allowed_hosts.",
40
+ "insecure_http" => "Use https origins in production; plain http ones cannot be trusted.",
41
+ "uncountable" => "Point mcp.rate_limit_store at a store whose #increment returns a count.",
42
+ "unshared" => "Configure a shared config.cache_store (Solid Cache, Redis, Memcached), " \
43
+ "or set mcp.rate_limit_store explicitly.",
44
+ "probe_error" => "The check itself could not run; HITCH_DOCTOR_FORMAT=json names the error class."
45
+ }.freeze
46
+ Check = Data.define(:id, :status, :code, :summary, :details) do
47
+ def initialize(id:, status:, code:, summary:, details: {})
48
+ super(
49
+ id: id.to_s.freeze,
50
+ status: status.to_s.freeze,
51
+ code: code.to_s.freeze,
52
+ summary: summary.to_s.freeze,
53
+ details: Doctor.copy_json(details)
54
+ )
55
+ freeze
56
+ end
57
+
58
+ def to_h
59
+ {
60
+ "id" => id,
61
+ "status" => status,
62
+ "code" => code,
63
+ "summary" => summary,
64
+ "details" => details
65
+ }
66
+ end
67
+ end
68
+
69
+ Report = Data.define(:schema, :status, :checks) do
70
+ def initialize(schema:, status:, checks:)
71
+ super(schema: schema.to_s.freeze, status: status.to_s.freeze, checks: checks.dup.freeze)
72
+ freeze
73
+ end
74
+
75
+ def failure?
76
+ checks.any? { |check| check.status == "fail" }
77
+ end
78
+
79
+ def to_h
80
+ {
81
+ "schema" => schema,
82
+ "status" => status,
83
+ "checks" => checks.map(&:to_h)
84
+ }
85
+ end
86
+ end
87
+
88
+ class System
89
+ REQUIRED_TABLES = %w[
90
+ hitch_access_tokens
91
+ hitch_clients
92
+ hitch_client_redirect_uris
93
+ ].freeze
94
+
95
+ def versions
96
+ {
97
+ "hitch" => Hitch::VERSION,
98
+ "rails" => Rails.version,
99
+ "ruby" => RUBY_VERSION,
100
+ "mcp" => Gem.loaded_specs["mcp"]&.version&.to_s
101
+ }
102
+ end
103
+
104
+ def environment_name
105
+ Rails.env.to_s
106
+ end
107
+
108
+ def validate_configuration!
109
+ configuration = Hitch.configuration
110
+ configuration.validate!
111
+ if Rails.env.production? && configuration.dynamic_client_registration_enabled
112
+ store = configuration.dynamic_client_registration_rate_store
113
+ Hitch::RateLimitStore.assert_shared!(
114
+ store, setting: Hitch::DynamicRegistrationRateLimit::SETTING
115
+ )
116
+ probe_registration_store!(store)
117
+ end
118
+ true
119
+ end
120
+
121
+ # A dedicated-but-unreachable registration store passes the class check
122
+ # while every production registration 503s; drive it like the admission
123
+ # probe does. Registration refuses on an uncountable store, so this
124
+ # mirrors the request path rather than adding a stricter one.
125
+ def probe_registration_store!(store)
126
+ key = "hitch:doctor:v1:#{SecureRandom.hex(16)}"
127
+ count = begin
128
+ store.increment(key, 1, expires_in: 5)
129
+ rescue NotImplementedError
130
+ nil
131
+ end
132
+ return true if count.is_a?(Integer)
133
+
134
+ raise Hitch::DynamicRegistrationRateLimit::Unavailable,
135
+ "#{Hitch::DynamicRegistrationRateLimit::SETTING} cannot count registration attempts"
136
+ ensure
137
+ begin
138
+ store&.delete(key)
139
+ # NotImplementedError included: a raise here would replace the
140
+ # Unavailable this method exists to report.
141
+ rescue NotImplementedError, StandardError
142
+ nil
143
+ end
144
+ end
145
+
146
+ def runtime_enabled?
147
+ Hitch.configuration.mcp.enabled
148
+ end
149
+
150
+ def discovery_facts
151
+ resource = URI.parse(Hitch.configuration.resource_uri.to_s)
152
+ issuer = Hitch::ResourceUri.origin(resource)
153
+ resource_metadata_uri = Hitch::ResourceUri.protected_resource_metadata_url(resource)
154
+ authorization = application_get("/.well-known/oauth-authorization-server", resource)
155
+ protected_resource = application_get(URI.parse(resource_metadata_uri).request_uri, resource)
156
+
157
+ {
158
+ "resource_uri" => Hitch.configuration.resource_uri,
159
+ "issuer" => issuer,
160
+ "resource_metadata_uri" => resource_metadata_uri,
161
+ "authorization_status" => authorization.fetch("status"),
162
+ "authorization_document" => authorization.fetch("document"),
163
+ "resource_status" => protected_resource.fetch("status"),
164
+ "resource_document" => protected_resource.fetch("document")
165
+ }
166
+ end
167
+
168
+ def route_facts
169
+ resource = URI.parse(Hitch.configuration.resource_uri.to_s)
170
+ resource_path = resource.path
171
+ resource_path = "/" if resource_path.empty?
172
+ routes = Rails.application.routes.routes.to_a
173
+ endpoint_indexes = routes.each_index.select do |index|
174
+ route = routes.fetch(index)
175
+ normalized_route_path(route) == resource_path && modern_endpoint_route?(route)
176
+ end
177
+ engine_indexes = routes.each_index.select { |index| hitch_engine_route?(routes.fetch(index)) }
178
+ endpoint_index = endpoint_indexes.one? ? endpoint_indexes.first : nil
179
+ endpoint_route = routes.fetch(endpoint_index) if endpoint_index
180
+ expected_target = if endpoint_route
181
+ {
182
+ "controller" => endpoint_route.defaults[:controller].to_s,
183
+ "action" => endpoint_route.defaults[:action].to_s
184
+ }
185
+ end
186
+ recognized_targets = ActionDispatch::Request::HTTP_METHODS.to_h do |http_method|
187
+ method = http_method.downcase
188
+ [ method, recognized_route_target(resource.to_s, method) ]
189
+ end
190
+ predecessors = if endpoint_index
191
+ routes.each_index.select do |index|
192
+ index < endpoint_index && normalized_route_path(routes.fetch(index)) == resource_path
193
+ end
194
+ else
195
+ []
196
+ end
197
+
198
+ {
199
+ "resource_path" => resource_path,
200
+ "endpoint_indexes" => endpoint_indexes,
201
+ "endpoint_all_verbs" => endpoint_index ? routes.fetch(endpoint_index).verb.to_s.empty? : false,
202
+ "endpoint_reachable" => expected_target && recognized_targets.values.all? { |target| target == expected_target },
203
+ "recognized_targets" => recognized_targets,
204
+ "same_path_predecessor_indexes" => predecessors,
205
+ "engine_mount_indexes" => engine_indexes,
206
+ "engine_mount_paths" => engine_indexes.map { |index| normalized_route_path(routes.fetch(index)) }
207
+ }
208
+ end
209
+
210
+ # with_connection, not ActiveRecord::Base.connection: the latter is soft
211
+ # deprecated and raises outright on a host that sets
212
+ # config.active_record.permanent_connection_checkout = :disallowed,
213
+ # which turned a healthy install into a failing diagnostic.
214
+ def migration_facts
215
+ installed = ActiveRecord::Base.connection_pool.migration_context.get_all_versions.map(&:to_s)
216
+ required = Dir[Hitch::Engine.root.join("db/migrate/*.rb")].map do |path|
217
+ File.basename(path).split("_", 2).first
218
+ end.sort
219
+ missing_tables = ActiveRecord::Base.with_connection do |connection|
220
+ REQUIRED_TABLES.reject { |table| connection.data_source_exists?(table) }
221
+ end
222
+ {
223
+ "required_versions" => required,
224
+ "missing_versions" => required - installed,
225
+ "missing_tables" => missing_tables
226
+ }
227
+ end
228
+
229
+ def registry_facts
230
+ configuration = Hitch.configuration
231
+ snapshot = Hitch::MCP::Internal::RegistryRuntime.build_snapshot(
232
+ registry_name: configuration.mcp.registry,
233
+ supported_scopes: configuration.supported_scopes
234
+ )
235
+ {
236
+ "registry" => snapshot.registry_name,
237
+ "tool_count" => snapshot.entries.length,
238
+ "tool_names" => snapshot.entries.map(&:name)
239
+ }
240
+ end
241
+
242
+ def host_facts
243
+ resource_host = URI.parse(Hitch.configuration.resource_uri.to_s).hostname&.downcase
244
+ configured = Hitch.configuration.allowed_hosts
245
+ expected = [ resource_host, *configured ].compact.uniq
246
+ rails_hosts = Rails.application.config.hosts
247
+ blocked = if rails_hosts.empty?
248
+ []
249
+ else
250
+ permissions = ActionDispatch::HostAuthorization::Permissions.new(rails_hosts)
251
+ expected.reject { |host| permissions.allows?(host) }
252
+ end
253
+ {
254
+ "canonical_host" => resource_host,
255
+ "configured_hosts" => configured,
256
+ "rails_host_policy_entries" => rails_hosts.length,
257
+ "blocked_hosts" => blocked
258
+ }
259
+ end
260
+
261
+ def origin_facts
262
+ origins = Hitch.configuration.allowed_origins
263
+ {
264
+ "configured_origins" => origins,
265
+ "deny_default" => origins.empty?,
266
+ "production" => Rails.env.production?,
267
+ "insecure_production_origins" => Rails.env.production? ? origins.grep(/\Ahttp:\/\//) : []
268
+ }
269
+ end
270
+
271
+ # Drives the real store rather than describing it: two increments on an
272
+ # isolated key must return 1 then 2, and the key must expire on its own.
273
+ def rate_limit_store_facts
274
+ configuration = Hitch.configuration.mcp
275
+ store = configuration.rate_limit_store
276
+ key = "hitch:doctor:v1:#{SecureRandom.hex(16)}"
277
+ first = store.increment(key, 1, expires_in: 5)
278
+ second = store.increment(key, 1, expires_in: 5)
279
+
280
+ {
281
+ "store_class" => store.class.name,
282
+ "counts" => [ first, second ] == [ 1, 2 ],
283
+ # Integers and nil verbatim; anything else only by class, so a
284
+ # broken store cannot put message text into the report.
285
+ "returned" => [ first, second ].map do |value|
286
+ value.is_a?(Integer) || value.nil? ? value : value.class.name
287
+ end,
288
+ "unshared" => Hitch::RateLimitStore.unshared?(store),
289
+ "environment" => environment_name
290
+ }
291
+ ensure
292
+ begin
293
+ store&.delete(key)
294
+ # NotImplementedError included so a store without delete cannot
295
+ # replace this probe's own result mid-ensure.
296
+ rescue NotImplementedError, StandardError
297
+ nil
298
+ end
299
+ end
300
+
301
+
302
+ private
303
+
304
+ def application_get(path, resource)
305
+ environment = Rack::MockRequest.env_for(
306
+ path,
307
+ method: "GET",
308
+ "HTTP_HOST" => Hitch::ResourceUri.authority(resource),
309
+ "SERVER_NAME" => resource.host,
310
+ "SERVER_PORT" => resource.port.to_s,
311
+ "rack.url_scheme" => resource.scheme,
312
+ "HTTPS" => ("on" if resource.scheme == "https")
313
+ ).compact
314
+ status, _headers, body = Rails.application.call(environment)
315
+ bytes = +""
316
+ body.each do |part|
317
+ bytes << part.to_s
318
+ raise "discovery response exceeds diagnostic bound" if bytes.bytesize > 1_048_576
319
+ end
320
+ { "status" => status, "document" => JSON.parse(bytes) }
321
+ ensure
322
+ body&.close if body.respond_to?(:close)
323
+ end
324
+
325
+ def normalized_route_path(route)
326
+ route.path.spec.to_s.sub(/\(\.?:format\)\z/, "").sub("(.:format)", "")
327
+ end
328
+
329
+ def recognized_route_target(resource_uri, method)
330
+ parameters = Rails.application.routes.recognize_path(resource_uri, method: method.to_sym)
331
+ {
332
+ "controller" => parameters[:controller].to_s,
333
+ "action" => parameters[:action].to_s
334
+ }
335
+ rescue ActionController::RoutingError, AbstractController::ActionNotFound, NameError
336
+ nil
337
+ end
338
+
339
+ def modern_endpoint_route?(route)
340
+ route.defaults[:action].to_s == "handle" && controller_uses?(route, Hitch::MCP::Endpoint)
341
+ end
342
+
343
+ def controller_uses?(route, concern)
344
+ controller = route.defaults[:controller].to_s
345
+ return false if controller.empty?
346
+
347
+ controller_class = "#{controller}_controller".camelize.constantize
348
+ controller_class.ancestors.include?(concern)
349
+ rescue NameError
350
+ false
351
+ end
352
+
353
+ def hitch_engine_route?(route)
354
+ application = route.app
355
+ seen = {}
356
+ loop do
357
+ return true if application.equal?(Hitch::Engine)
358
+ return false if seen.key?(application.object_id) || !application.respond_to?(:app)
359
+
360
+ seen[application.object_id] = true
361
+ replacement = application.app
362
+ return false if replacement.equal?(application)
363
+
364
+ application = replacement
365
+ end
366
+ end
367
+ end
368
+
369
+ class << self
370
+ def call(system: System.new)
371
+ new(system:).call
372
+ end
373
+
374
+ def render(report, format: "human")
375
+ case format
376
+ when "human" then render_human(report)
377
+ when "json" then "#{JSON.pretty_generate(report.to_h)}\n"
378
+ else raise ArgumentError, "HITCH_DOCTOR_FORMAT must be human or json"
379
+ end
380
+ end
381
+
382
+ # Bounds hostile store output: keys and non-JSON values are coerced to
383
+ # strings, so a broken store cannot put rich objects into the report.
384
+ # Cyclic input raises (and the check reports probe_error) instead of
385
+ # recursing without a floor.
386
+ def copy_json(value)
387
+ Hitch::MCP::Internal::JsonValues.copy(
388
+ value, keys: :to_s, symbols: :to_s, foreign: :to_s, freeze: true
389
+ )
390
+ end
391
+
392
+ private
393
+
394
+ def render_human(report)
395
+ lines = [ "Hitch doctor v1: #{report.status.upcase}" ]
396
+ report.checks.each do |check|
397
+ lines << format("%-4s %-24s %-28s %s", check.status.upcase, check.id, check.code, check.summary)
398
+ lines << " -> #{REMEDIES.fetch(check.code)}" if REMEDIES.key?(check.code)
399
+ end
400
+ counts = %w[pass warn fail skip].to_h do |status|
401
+ [ status, report.checks.count { |check| check.status == status } ]
402
+ end
403
+ lines << "Summary: pass=#{counts.fetch('pass')} warn=#{counts.fetch('warn')} " \
404
+ "fail=#{counts.fetch('fail')} skip=#{counts.fetch('skip')}"
405
+ "#{lines.join("\n")}\n"
406
+ end
407
+ end
408
+
409
+ def initialize(system:)
410
+ @system = system
411
+ end
412
+
413
+ def call
414
+ checks = [
415
+ versions_check,
416
+ configuration_check,
417
+ resource_discovery_check,
418
+ route_order_check,
419
+ migrations_check,
420
+ registry_check,
421
+ hosts_check,
422
+ origins_check,
423
+ rate_limit_store_check
424
+ ]
425
+ raise "Hitch doctor check set drifted" unless checks.map(&:id) == CHECK_IDS
426
+
427
+ overall = if checks.any? { |check| check.status == "fail" }
428
+ "error"
429
+ elsif checks.any? { |check| check.status == "warn" }
430
+ "warning"
431
+ else
432
+ "ok"
433
+ end
434
+ Report.new(schema: SCHEMA, status: overall, checks:)
435
+ end
436
+
437
+ private
438
+
439
+ attr_reader :system
440
+
441
+ def versions_check
442
+ versions = system.versions
443
+ # The one authority on supported versions is the gemspec itself.
444
+ specification = Gem.loaded_specs.fetch("hitch-rails")
445
+ dependencies = specification.dependencies.to_h { |dependency| [ dependency.name, dependency.requirement ] }
446
+ requirements = {
447
+ "ruby" => specification.required_ruby_version,
448
+ "rails" => dependencies.fetch("rails"),
449
+ "mcp" => dependencies.fetch("mcp")
450
+ }
451
+ unsupported = requirements.filter_map do |name, requirement|
452
+ value = versions[name]
453
+ name unless value && requirement.satisfied_by?(Gem::Version.new(value))
454
+ rescue ArgumentError
455
+ name
456
+ end
457
+ return pass("versions", "supported", "Runtime versions are in Hitch's supported window", versions) if
458
+ unsupported.empty?
459
+
460
+ fail_check(
461
+ "versions",
462
+ "unsupported",
463
+ "One or more runtime versions are outside Hitch's supported window",
464
+ versions.merge("unsupported" => unsupported)
465
+ )
466
+ rescue StandardError => error
467
+ probe_failure("versions", error)
468
+ end
469
+
470
+ def configuration_check
471
+ runtime = system.runtime_enabled?
472
+ system.validate_configuration!
473
+ code = runtime ? "valid_full_runtime" : "valid_auth_only"
474
+ summary = runtime ? "OAuth and MCP runtime configuration is valid" : "OAuth configuration is valid; MCP runtime is disabled"
475
+ pass("configuration", code, summary, "environment" => system.environment_name, "runtime_enabled" => runtime)
476
+ rescue StandardError => error
477
+ environment = begin
478
+ system.environment_name
479
+ rescue StandardError
480
+ "unavailable"
481
+ end
482
+ fail_check(
483
+ "configuration",
484
+ "invalid",
485
+ "Hitch configuration is invalid",
486
+ "environment" => environment,
487
+ "error_class" => error.class.name
488
+ )
489
+ end
490
+
491
+ def resource_discovery_check
492
+ facts = system.discovery_facts
493
+ resource = URI.parse(facts.fetch("resource_uri"))
494
+ issuer = facts.fetch("issuer")
495
+ authorization = facts.fetch("authorization_document")
496
+ protected_resource = facts.fetch("resource_document")
497
+ coherent = facts.fetch("authorization_status") == 200 && facts.fetch("resource_status") == 200 &&
498
+ authorization["issuer"] == issuer &&
499
+ authorization["authorization_endpoint"] == "#{issuer}/oauth/authorize" &&
500
+ authorization["token_endpoint"] == "#{issuer}/oauth/token" &&
501
+ protected_resource["resource"] == facts.fetch("resource_uri") &&
502
+ protected_resource["authorization_servers"] == [ issuer ] &&
503
+ URI.parse(facts.fetch("resource_metadata_uri")).query == resource.query
504
+ details = facts.slice("resource_uri", "issuer", "resource_metadata_uri", "authorization_status", "resource_status")
505
+ return pass("resource_discovery", "coherent", "Canonical resource and discovery documents agree", details) if coherent
506
+
507
+ fail_check("resource_discovery", "mismatch", "Canonical resource and discovery documents do not agree", details)
508
+ rescue StandardError => error
509
+ probe_failure("resource_discovery", error)
510
+ end
511
+
512
+ def route_order_check
513
+ return skip(
514
+ "route_order",
515
+ "runtime_disabled",
516
+ "Modern MCP route order is not applicable while the runtime is disabled"
517
+ ) unless system.runtime_enabled?
518
+
519
+ facts = system.route_facts
520
+ endpoints = facts.fetch("endpoint_indexes")
521
+ mounts = facts.fetch("engine_mount_indexes")
522
+ return fail_check("route_order", "missing_endpoint", "Exactly one modern MCP endpoint route is required", facts) unless
523
+ endpoints.one?
524
+ return fail_check("route_order", "invalid_engine_mount", "Hitch::Engine must be mounted exactly once at root", facts) unless
525
+ mounts.one? && facts.fetch("engine_mount_paths") == [ "/" ]
526
+ return fail_check("route_order", "wrong_verbs", "The modern MCP route must admit the endpoint's full method contract", facts) unless
527
+ facts.fetch("endpoint_all_verbs")
528
+ return fail_check("route_order", "shadowed", "A host route shadows the canonical MCP endpoint", facts) if
529
+ facts.fetch("same_path_predecessor_indexes").any? || !facts.fetch("endpoint_reachable")
530
+ return fail_check("route_order", "after_engine", "The modern MCP route must precede the Hitch engine mount", facts) unless
531
+ endpoints.first < mounts.first
532
+
533
+ pass("route_order", "ordered", "The modern MCP endpoint precedes one root engine mount", facts)
534
+ rescue StandardError => error
535
+ probe_failure("route_order", error)
536
+ end
537
+
538
+ def migrations_check
539
+ facts = system.migration_facts
540
+ missing = facts.fetch("missing_versions").any? || facts.fetch("missing_tables").any?
541
+ return fail_check("migrations", "missing", "Required Hitch migrations or tables are missing", facts) if missing
542
+
543
+ pass("migrations", "current", "Hitch migrations are current", facts)
544
+ rescue StandardError => error
545
+ probe_failure("migrations", error)
546
+ end
547
+
548
+ def registry_check
549
+ return skip("registry", "runtime_disabled", "MCP registry is not applicable while the runtime is disabled") unless
550
+ system.runtime_enabled?
551
+
552
+ facts = system.registry_facts
553
+ return warning("registry", "empty", "Registry is valid but exposes no tools", facts) if facts.fetch("tool_count").zero?
554
+
555
+ pass("registry", "valid", "Registry is valid and explicitly populated", facts)
556
+ rescue StandardError => error
557
+ fail_check("registry", "unresolvable", "Registry validation failed", "error_class" => error.class.name)
558
+ end
559
+
560
+ def hosts_check
561
+ facts = system.host_facts
562
+ return fail_check("hosts", "blocked", "Rails host authorization blocks a configured Hitch host", facts) if
563
+ facts.fetch("blocked_hosts").any?
564
+
565
+ pass("hosts", "accepted", "Canonical and configured Hitch hosts pass Rails host authorization", facts)
566
+ rescue StandardError => error
567
+ probe_failure("hosts", error)
568
+ end
569
+
570
+ def origins_check
571
+ facts = system.origin_facts
572
+ return warning(
573
+ "origins",
574
+ "insecure_http",
575
+ "Production browser origins include plain HTTP",
576
+ facts
577
+ ) if facts.fetch("insecure_production_origins").any?
578
+ if facts.fetch("deny_default")
579
+ return pass("origins", "deny_default", "Browser CORS remains deny-default", facts)
580
+ end
581
+
582
+ pass("origins", "exact", "Browser CORS uses exact configured origins", facts)
583
+ rescue StandardError => error
584
+ probe_failure("origins", error)
585
+ end
586
+
587
+ def rate_limit_store_check
588
+ return skip(
589
+ "rate_limit_store",
590
+ "runtime_disabled",
591
+ "Request admission is not applicable while the MCP runtime is disabled"
592
+ ) unless system.runtime_enabled?
593
+
594
+ facts = system.rate_limit_store_facts
595
+ # The code names the defect; the status says how much it matters. Only
596
+ # production refuses, matching the runtime.
597
+ report = system.environment_name == "production" ? method(:fail_check) : method(:warning)
598
+
599
+ return report.call(
600
+ "rate_limit_store",
601
+ "uncountable",
602
+ "The configured store cannot count MCP requests",
603
+ facts
604
+ ) unless facts.fetch("counts")
605
+
606
+ return report.call(
607
+ "rate_limit_store",
608
+ "unshared",
609
+ "The configured store cannot count one principal's requests across processes",
610
+ facts
611
+ ) if facts.fetch("unshared")
612
+
613
+ pass(
614
+ "rate_limit_store",
615
+ "shared",
616
+ "Request admission counts through a store shared across processes",
617
+ facts
618
+ )
619
+ # NotImplementedError (a ScriptError): the base Store#increment raises it
620
+ # when a store never overrode increment; the report must survive that.
621
+ rescue NotImplementedError, StandardError => error
622
+ probe_failure("rate_limit_store", error)
623
+ end
624
+
625
+ def pass(id, code, summary, details = {})
626
+ Check.new(id:, status: "pass", code:, summary:, details:)
627
+ end
628
+
629
+ def warning(id, code, summary, details = {})
630
+ Check.new(id:, status: "warn", code:, summary:, details:)
631
+ end
632
+
633
+ def fail_check(id, code, summary, details = {})
634
+ Check.new(id:, status: "fail", code:, summary:, details:)
635
+ end
636
+
637
+ def skip(id, code, summary, details = {})
638
+ Check.new(id:, status: "skip", code:, summary:, details:)
639
+ end
640
+
641
+ def probe_failure(id, error)
642
+ fail_check(id, "probe_error", "The #{id.tr('_', ' ')} diagnostic could not complete", "error_class" => error.class.name)
643
+ end
644
+
645
+ private_constant :Check, :Report, :System
646
+ end
647
+ end