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
data/lib/hitch/doctor.rb
ADDED
|
@@ -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
|