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,98 @@
1
+ # Request admission for the Hitch MCP endpoint
2
+
3
+ The MCP tools specification requires invocation rate limiting. Hitch provides
4
+ one authenticated, endpoint-wide fixed-window limit shared across
5
+ `server/discover`, `tools/list`, and `tools/call` for the validated principal
6
+ and client.
7
+
8
+ Hitch counts through your application's own cache store, the same way
9
+ `ActionController::RateLimiting` does. It adds no service to your deployment.
10
+
11
+ ## Configuration
12
+
13
+ Nothing is required. An application that already sets `config.cache_store` is
14
+ already configured:
15
+
16
+ ```ruby
17
+ # config/environments/production.rb
18
+ config.cache_store = :solid_cache_store
19
+ ```
20
+
21
+ The limit itself lives in the generated MCP initializer:
22
+
23
+ ```ruby
24
+ config.mcp.request_limit = { to: 120, within: 1.minute }
25
+ ```
26
+
27
+ To keep MCP admission out of your general cache, point it at a dedicated store:
28
+
29
+ ```ruby
30
+ config.mcp.rate_limit_store = ActiveSupport::Cache::RedisCacheStore.new(
31
+ url: ENV["HITCH_MCP_REDIS_URL"]
32
+ )
33
+ ```
34
+
35
+ Any `ActiveSupport::Cache` store responding to `increment` is accepted.
36
+
37
+ ## What production requires
38
+
39
+ Production boot fails closed when the resolved store cannot count one
40
+ principal's requests across the processes serving them. Three stores are
41
+ refused:
42
+
43
+ | Store | Why it is refused |
44
+ | --- | --- |
45
+ | `:memory_store` | Per process; each worker keeps its own count |
46
+ | `:null_store` | Retains nothing, so no window ever accumulates |
47
+ | `:file_store` | Reads and writes without a lock, so counts are lost under concurrency |
48
+
49
+ Development and test may use any of them. Where the store cannot count at all,
50
+ admission is not enforced rather than failing every request — the same posture
51
+ Rails takes, and safe because production refuses those stores at boot.
52
+
53
+ ## Accuracy
54
+
55
+ Counting is approximate at window boundaries under concurrency, which is what a
56
+ rate limit needs to be. Solid Cache on PostgreSQL is the loosest case: it can
57
+ lose a few increments when a window's key is first created
58
+ ([rails/solid_cache#297](https://github.com/rails/solid_cache/pull/297)), so a
59
+ burst at a boundary may admit a handful of extra calls before the limit engages.
60
+ Redis and Memcached count exactly if you would rather they did.
61
+
62
+ ## Operational notes
63
+
64
+ - Keys are HMAC digests of the validated principal type/id and client ID. Raw
65
+ principal IDs, client IDs, and bearer tokens never reach the store. Token
66
+ rotation cannot reset a caller's quota.
67
+ - The quota deliberately spans every host scope and tool for that
68
+ principal/client. Per-tool quotas and distributed concurrency leases are
69
+ later work.
70
+ - A rejection is `429` with a conservative `Retry-After` equal to the
71
+ configured window.
72
+ - An error the store raises is `503` before request body parsing, Registry
73
+ work, SDK dispatch, or host behavior. Note that `RedisCacheStore` and Solid
74
+ Cache do not raise on a backend outage — they swallow the error and return
75
+ nil, so during an outage admission is not enforced. That is the same posture
76
+ `ActionController::RateLimiting` has on the same stores; if the limit must
77
+ hold through outages, use a store that raises.
78
+ - Avoid an eviction policy that silently removes live quota keys. Eviction can
79
+ reset a caller's active window without producing a store error. If your
80
+ general cache is under memory pressure, give MCP admission a dedicated store.
81
+ - One short-lived key exists per active principal/client window.
82
+
83
+ ## Deployment check
84
+
85
+ Run migrations and the doctor in the same environment and with the same secret
86
+ injection as the application processes:
87
+
88
+ ```sh
89
+ bin/rails db:migrate
90
+ bin/rails hitch:doctor
91
+ ```
92
+
93
+ The `rate_limit_store` check drives the real configured store rather than
94
+ describing it: it increments an isolated random `hitch:doctor:v1:*` key twice,
95
+ asserts the counts are `1` then `2`, and removes the key. It never touches the
96
+ `hitch:mcp:rate-limit:v1:*` application quota namespace. The check reports the
97
+ store class, whether it is shared across processes, and the two probe counts;
98
+ it emits no credentials or store message text.
@@ -0,0 +1,322 @@
1
+ # hitch-rails 0.2 public API
2
+
3
+ This file documents the `0.2.0` compatibility surface — the gem's first
4
+ public release. The authenticated `Hitch::MCP::Endpoint` is the only MCP
5
+ endpoint: it is stateless POST/OPTIONS, and it performs no notification/202
6
+ response shaping.
7
+
8
+ For a confidential token exchange, `client_secret_basic` remains the only
9
+ supported secret-bearing method. The form may repeat the same scalar
10
+ `client_id` carried by the Basic username because the official Python MCP 2.0
11
+ client emits that interoperable shape. Hitch rejects a body `client_secret`, a
12
+ mismatched body ID, and duplicate or structured IDs before authorization-code
13
+ consumption.
14
+
15
+ The first configured `supported_scopes` entry is the base/default scope named
16
+ by the initial MCP bearer challenge. Protected-resource metadata continues to
17
+ advertise the complete supported set, and a known tool's 403 challenge names
18
+ that tool's complete static scope requirement. This keeps the first grant least
19
+ privileged while preserving explicit step-up.
20
+
21
+ ## Implemented surface
22
+
23
+ `Hitch::Configuration#mcp` returns the one persistent
24
+ `Hitch::MCP::Configuration` object owned by the top-level Hitch configuration
25
+ with these runtime settings:
26
+
27
+ - `enabled=` accepts `true` or `false` (default `false`) and is the one
28
+ explicit switch for the authenticated endpoint runtime. While it is false,
29
+ no other MCP setting is required or validated at boot.
30
+ - `server_info=` accepts a static Hash with nonempty String `name` and
31
+ `version`, plus optional String `title` and `instructions`. It is validated
32
+ once at boot/prepare, alongside registry validation, and a malformed value
33
+ fails the boot rather than the first request; the request path reads the
34
+ validated frozen value. Unset, it defaults to the application's dasherized
35
+ name and version "1.0.0".
36
+ - `max_request_bytes=` accepts a positive Integer and defaults to 1 MiB. The
37
+ current value is read for every request; overflow returns 413 before JSON
38
+ parsing.
39
+ - `max_result_bytes=` accepts a positive Integer and defaults to 1 MiB. The
40
+ current value is read for every invocation; a Result whose exact serialized
41
+ JSON exceeds it becomes the generic tool error.
42
+ - `scope_resolver=` accepts a callable with `principal:`, `access_token:`, and
43
+ `request:` keywords, or `nil` (the default), which resolves a `nil` scope.
44
+ A configured resolver runs exactly once per admitted request and returns
45
+ one opaque host scope object or `nil`. Raising resolvers fail closed with a
46
+ generic protocol internal error before registry or SDK work.
47
+ - `request_limit=` accepts a mapping with positive Integer `to` and positive
48
+ whole-second Integer or `ActiveSupport::Duration` `within`. It defaults to
49
+ 120 requests per 60 seconds and applies one fixed window across discovery,
50
+ listing, and calls for the validated principal/client.
51
+ - `rate_limit_store=` accepts any `ActiveSupport::Cache` store responding to
52
+ `increment`, or `nil` to count through the application's own
53
+ `config.action_controller.cache_store`. Hitch adds no service to a deployment
54
+ that has none. `rate_limit_store` reads back the resolved store. Production
55
+ refuses a store that cannot count one principal's requests across the
56
+ processes serving them — `:memory_store`, `:null_store`, and `:file_store` —
57
+ and a raised runtime store error fails closed with HTTP 503. A store that
58
+ returns nil instead of raising (Redis and Solid Cache stores swallow backend
59
+ outages) admits without a limit, as Rails' own limiter does. Counting is
60
+ approximate at window boundaries under concurrency.
61
+
62
+ Host MCP policy and behavior receive a fresh public `Hitch::MCP::Context` for
63
+ every admitted request. It is a frozen envelope with readers for `principal`, `access_token`,
64
+ `scope`, `granted_scopes`, `client_id`, `resource`, `request_id`, `remote_ip`,
65
+ `user_agent`, `protocol_version`, and `meta`. Principal, token, and scope are
66
+ opaque request-local references rather than objects Hitch claims to freeze.
67
+ All scalar request values, granted scopes, and untrusted metadata are copied;
68
+ metadata is recursively frozen. Granted scopes are captured from the validated
69
+ token before the scope resolver runs, so scope resolution cannot rewrite the
70
+ current request's static authorization. Client metadata never supplies
71
+ authority.
72
+
73
+ `config.mcp.registry=` accepts one nonempty String constant name. The named
74
+ constant must resolve to a `Hitch::MCP::Registry` subclass, whose only admission
75
+ path is `register ToolClass, scopes: [...]`. Registration stores the tool class
76
+ name and copied static scopes, never the reloadable class object. A Rails
77
+ `to_prepare` callback clears the prior snapshot, reconstantizes and validates
78
+ the entire registry, and publishes one immutable MCP-name-sorted replacement.
79
+ Failure raises and leaves the registry unavailable rather than serving a stale
80
+ or partial snapshot.
81
+
82
+ An auth-only adopter that leaves `config.mcp.enabled` false continues to boot
83
+ unchanged; other MCP settings are inert without the switch. Setting
84
+ `config.mcp.enabled = true` activates the endpoint runtime and requires a
85
+ named `registry` at boot. Production additionally requires that the resolved
86
+ admission store can count across processes.
87
+
88
+ Registered classes subclass `Hitch::MCP::Tool` and declare `tool_name`,
89
+ `description`, `input_schema`, optional `output_schema`, and optional
90
+ `annotations`. Names use 1–64 ASCII letters, digits, underscores, dots, or
91
+ dashes. Schemas are JSON Schema 2020-12 objects with same-document references
92
+ only and fixed limits of 64 levels, 10,000 schema objects, and 1 MiB serialized
93
+ bytes. A literal top-level `server_context` in `properties` or `required` is
94
+ refused at boot; nested `server_context` data remains valid, and an actual
95
+ top-level value is rejected at the verified call boundary even when a pattern
96
+ or open schema would admit it. Tool annotations accept only `title` and the four MCP Boolean hint
97
+ fields. Registry validation also rejects unsupported or duplicate scopes,
98
+ duplicate MCP names, unnamed/missing/non-tool classes, and any subclass
99
+ replacement of framework-owned `.call`.
100
+
101
+ `.available_to?(context)` is the request-local, argument-free visibility and
102
+ call-admission gate. Its framework default is `false`; hosts must explicitly
103
+ return a Boolean from the current reloadable tool class. Availability runs
104
+ before static scope filtering for list and call. A raise or non-Boolean result
105
+ fails the whole request with generic `-32603` and no registry or scope detail.
106
+
107
+ For `tools/list` and `server/discover`, Hitch constructs the SDK server from
108
+ only registered tools that are available to the current context and whose
109
+ static OAuth scopes are all granted. The order is MCP-name ascending, with
110
+ `cacheScope: "private"`, `ttlMs: 0`, and `resultType: "complete"`. An unknown
111
+ or unavailable call receives the same `-32602` shape. Only a registered,
112
+ available tool may receive a 403 `insufficient_scope` challenge naming its
113
+ complete required static scope set.
114
+
115
+ After the SDK validates a call against the registered input schema, the final
116
+ framework-owned `.call` copies the arguments into one recursively frozen Hash
117
+ whose keys are Strings at every depth. `.authorize!(context, arguments:)` runs
118
+ first and defaults to raising `Hitch::MCP::Forbidden`; its return value is
119
+ ignored when it returns normally. `.perform(context, arguments:)` then receives
120
+ the same Context and arguments object. Forbidden, unexpected policy failures,
121
+ invalid non-JSON arguments, and host exceptions all return the same generic
122
+ tool error, with no host work after policy denial and no exception message on
123
+ the wire.
124
+
125
+ `.perform` must return one exact `Hitch::MCP::Result`; Hashes, models,
126
+ exceptions, SDK responses, and objects with serialization methods are rejected.
127
+ `Result.text(string)` returns text, `Result.structured(value, text: nil)` accepts
128
+ only recursively copied JSON values and requires the tool's registered output
129
+ schema, and `Result.error(public_message)` returns the one explicit
130
+ host-approved error message. Hitch validates structured output itself,
131
+ measures `JSON.generate` of the final Result with an inclusive
132
+ `max_result_bytes` boundary, and runs that as the one output-schema
133
+ validation — the SDK's own result validation stays explicitly off. Invalid constructors or returns, missing/mismatched schemas,
134
+ serialization failures, cap violations, and unexpected host errors all become
135
+ the same generic tool error. Hitch reports a synthetic exception through
136
+ `Rails.error` with only a fixed category, validated tool name, and, when request
137
+ observation is active, Hitch's server-generated correlation ID. A client-supplied
138
+ JSON-RPC ID is never logged. The original exception, arguments, result,
139
+ principal, token, scope, and client metadata are never forwarded.
140
+
141
+ `Hitch::MCP::Endpoint` is a public concern for a dedicated
142
+ `ActionController::API` controller:
143
+
144
+ ```ruby
145
+ class McpController < ActionController::API
146
+ include Hitch::MCP::Endpoint
147
+ end
148
+ ```
149
+
150
+ The host owns a catch-all route placed before the root engine mount:
151
+
152
+ ```ruby
153
+ match "/mcp", to: "mcp#handle", via: :all
154
+ mount Hitch::Engine => "/"
155
+ ```
156
+
157
+ The concern owns stateless final-profile POST and OPTIONS handling. Its fixed
158
+ order is raw Host, exact Origin, method, bearer authentication with active
159
+ canonical audience and client binding, then authenticated fixed-window
160
+ admission. It
161
+ requires the request path/query to equal the canonical resource, validates
162
+ Content-Type and both required Accept media types, reads at most the configured
163
+ byte cap, rejects duplicate JSON members at any depth, validates modern request
164
+ metadata and MCP headers, and dispatches one frozen request through one fresh
165
+ SDK server. Forwarded headers never derive the challenge or server identity.
166
+
167
+ The host-owned Registry is the packaged endpoint's only tool-admission path.
168
+
169
+ `request.hitch_mcp` emits exactly once for every non-OPTIONS endpoint request.
170
+ Its version-1 payload keys are `schema_version`, `request_id`, `method`,
171
+ `tool_name`, `principal_type`, `principal_key`, `client_key`, `http_status`,
172
+ `protocol_code`, `outcome`, `request_bytes`, `response_bytes`, and
173
+ `duration_ms`. The request ID is framework-generated; principal/client keys are
174
+ HMAC digests. Method and tool name are present only after their owning boundary
175
+ validates them. `outcome` is a fixed HTTP/protocol category such as `complete`,
176
+ `unauthorized`, `rate_limited`, `invalid_params`, or `internal_error`.
177
+
178
+ `invocation.hitch_mcp` emits only when SDK schema validation and Hitch argument
179
+ normalization reach a registered available Tool. Its version-1 keys are
180
+ `schema_version`, `request_id`, `tool_name`, `availability`, `argument_policy`,
181
+ `executed`, `result_category`, and `duration_ms`. Availability is `available`;
182
+ argument policy is `allowed`, `denied`, or `failed`; result category is
183
+ `success`, `explicit_error`, or `generic_error`. The request IDs correlate the
184
+ two events. Neither event contains credentials, bodies, arguments, results,
185
+ metadata, exception messages, or backtraces. Subscriber exceptions produce
186
+ only a sanitized `Rails.error` report and do not alter the MCP response.
187
+
188
+ `hitch:install` is public operational API. One run creates
189
+ `config/initializers/hitch.rb`, a host-owned `ActionController::API` endpoint
190
+ controller, the empty `McpToolRegistry` at `app/tools/mcp_tool_registry.rb`,
191
+ and one routes insertion placing the `/mcp` route immediately before the
192
+ `Hitch::Engine` mount. The `--controller-name` option accepts an explicit
193
+ constant path ending in `Controller`. It refuses the entire install before
194
+ writing when the controller name is invalid or an owned file or constant
195
+ collides; an existing engine mount or host-owned `/mcp` route is skipped or
196
+ warned about rather than duplicated. `bin/rails destroy hitch:install`
197
+ reverses the install through the generator's revoke behavior — removing the
198
+ generated files and the `/mcp` route it always writes, while the engine
199
+ mount is deliberately left in place (with a status note) because a generated
200
+ mount and a pre-existing host-owned one are indistinguishable without an
201
+ install record. Beyond that, the host's version control is the rollback
202
+ story.
203
+
204
+ `hitch:tool NAME` is public operational API. NAME accepts valid snake-case,
205
+ kebab-case, constant, and nested `/` or `::` segments. The default root
206
+ namespace is `McpTools`; `--namespace Admin::McpTools` selects another valid
207
+ constant path. Input segments normalize to one deterministic Ruby class, an
208
+ `app/tools/` path, a Minitest integration-test path, and a 1–64 character
209
+ dotted MCP name. A normalized file, class, or test class collision — or a
210
+ missing Registry file — refuses the whole operation before writing.
211
+
212
+ The generated Tool declares a closed empty input schema, all four MCP hint
213
+ annotations, `.available_to? == true`, an `.authorize!` whose empty body
214
+ allows the call and says so, and a `.perform` that returns
215
+ `Hitch::MCP::Result.text`. The generator registers it in
216
+ `McpToolRegistry` with `scopes: [ "mcp" ]`, and the generated integration
217
+ test proves the tool is listed and responds through `post_mcp` over real
218
+ HTTP. `--deny-default` generates the hardened variant instead —
219
+ `.available_to? == false`, a raising `.authorize!`, a raising `.perform` —
220
+ still registered, with a test asserting it stays hidden. `bin/rails destroy
221
+ hitch:tool NAME` removes the generated files and the injected registration
222
+ line; if the registration line was edited or reformatted it refuses the
223
+ whole rollback before deleting anything, so a stranded registration can
224
+ never break the next boot.
225
+
226
+ `Hitch::MCP::TestHelper` is public test-only API loaded with
227
+ `require "hitch/mcp/test_helper"`. `mcp_headers(token:, method:, name: nil,
228
+ protocol_version: "2026-07-28")` returns exact Host, bearer, JSON, Accept,
229
+ protocol, method, and conditional tool-name headers for the current configured
230
+ resource. `post_mcp(method:, token:, params: {}, id: "hitch-test",
231
+ client_info: nil, capabilities: {}, protocol_version: "2026-07-28")` copies and
232
+ validates JSON test inputs, adds the final MCP metadata, posts one JSON-RPC 2.0
233
+ request to the current resource path/query, and returns the ordinary Rails
234
+ integration response. Invalid helper inputs raise `ArgumentError`; endpoint
235
+ HTTP/protocol failures are not reclassified. Neither helper logs or places the
236
+ bearer value in the request body. `mint_mcp_token(principal:, scopes:
237
+ Hitch.configuration.supported_scopes, client_id: "hitch-test-client")` mints
238
+ a real bearer token through the production authorization-code path
239
+ (`create_authorization!` plus PKCE exchange) for any persisted principal
240
+ record and returns the raw token `post_mcp` expects. Scopes outside
241
+ `supported_scopes` raise `ArgumentError` rather than minting a grant the
242
+ real flow could never issue.
243
+
244
+ `bin/rails hitch:doctor` is public operational API. Its default human
245
+ report and `HITCH_DOCTOR_FORMAT=json` machine report contain exactly these
246
+ ordered IDs: `versions`, `configuration`, `resource_discovery`, `route_order`,
247
+ `migrations`, `registry`, `hosts`, `origins`, and `rate_limit_store`.
248
+ Every check names something the host can act on;
249
+ gem-self-diagnosis (package integrity) lives in the repository's CI. The
250
+ `versions` bounds come from the loaded gemspec. JSON uses schema
251
+ `hitch.doctor.v1`; every check has `status`, stable `code`, `summary`, and
252
+ bounded structural `details`.
253
+
254
+ Statuses are `pass`, `warn`, `fail`, or `skip`. The task exits one only when at
255
+ least one check fails, and prints every check before exiting. It performs
256
+ internal read-only discovery requests and schema/route/Registry/package
257
+ inspection. Its admission-store diagnostic increments a random
258
+ `hitch:doctor:v1:*` key twice with a five-second expiry, asserts the counts are
259
+ `1` then `2`, removes the key, and never uses application quota keys. Output
260
+ omits exception messages, credentials, bodies, store credentials, and probe
261
+ keys. There is no fix or repair
262
+ mode. The exact operator contract is in `docs/operator/doctor.md`.
263
+
264
+ `bin/rails hitch:tokens:issue PRINCIPAL=Model:id` issues a long-lived access
265
+ token for a headless agent, and `Hitch::AccessToken.issue!(principal:,
266
+ client_id:, client_name:, scopes:, expires_in:)` is the same call from a
267
+ console. Both run the ordinary authorization-code exchange, so the row is an
268
+ ordinary token: revocable, audience-bound, digest-at-rest. The task defaults
269
+ to 90 days (capped at 3650 days), the first configured scope, and the
270
+ `client_id` `hitch-cli`; `issue!` takes `expires_in` in seconds and otherwise
271
+ applies `access_token_lifetime_seconds`. Disclosure writes once to a new
272
+ `0600` file or an attached terminal, never to stdout.
273
+
274
+ `Hitch::MCP::Protocol` is framework-owned: it names the protocol version and
275
+ method set for Hitch's own use, and may change with the profile Hitch
276
+ implements. Host test suites pin `Hitch::MCP::TestHelper::PROTOCOL_VERSION`
277
+ instead.
278
+
279
+ The operator tasks `hitch:doctor`, `hitch:cimd:check`,
280
+ `hitch:clients:create_confidential`, `hitch:clients:rotate_secret`, and
281
+ `Hitch::AccessToken.cleanup_expired!` are public and keep their names and
282
+ arguments within 0.2.x.
283
+
284
+ The gem directly depends on `mcp >= 1.2, < 2` and `json_schemer >= 2.4, < 3`.
285
+ It declares no Redis dependency. Hitch's SDK adapter, verified
286
+ request, runtime registry wrapper, and version-scoped response normalizer are private
287
+ implementation details, not public constants or extension points. Context is
288
+ the public exception; its construction remains framework-owned.
289
+
290
+ `tools/call.params.arguments` may be omitted, as allowed by the protocol. When
291
+ present it must be an object. Unsupported protocol versions keep Hitch's frozen
292
+ `error.data.supportedVersions` field and additionally expose the official
293
+ runner's `supported` list plus the echoed `requested` value.
294
+
295
+ The conformance harness is test infrastructure, not runtime API. Its five
296
+ fixture tools, separate runner-only missing-capability diagnostic, private
297
+ bearer-file bridge, npm lock, and checked patch are never packaged. The gate
298
+ runs only `server-stateless`, `http-header-validation`,
299
+ `dns-rebinding-protection`, `json-schema-2020-12`, `tools-list`,
300
+ `tools-call-simple-text`, and `tools-call-error`. It does not claim prompts,
301
+ resources, caching, subscriptions, or all-server-suite conformance.
302
+
303
+ The package/client harness is also repository test infrastructure, not
304
+ runtime API. `bin/package-smoke` installs the exact built gem in a disposable
305
+ Rails application and drives the full flow. `bin/package-smoke
306
+ --automated-clients` adds one ephemeral Redis service, exercising
307
+ `RedisCacheStore` as one supported admission backend, and the exhaustive
308
+ official TypeScript/Python SDK 2.0.0 public/confidential matrix. The
309
+ fixtures drive consent, PKCE/state/issuer validation, base-scope discovery,
310
+ listing and calls, confidential-secret rotation, and scope step-up through
311
+ documented host surfaces. Fixture inputs are private mode-0600 files; evidence
312
+ contains no credentials. Product/model clients remain outside that automated
313
+ claim and require separate approval.
314
+
315
+ ## Distribution status
316
+
317
+ All `0.2.0.pre.1` through `0.2.0.pre.4` artifacts are internal checkpoints.
318
+ `0.2.0.pre.4` was the first public-eligible prerelease, and publication was
319
+ deferred so final `0.2.0` can become the first public RubyGems release. No
320
+ pre.4 tag, GitHub release, RubyGems publication, or downloaded-gem evidence
321
+ exists. Every future tag, GitHub release, or RubyGems publication still
322
+ requires an explicit maintainer decision after its milestone gates pass.
data/docs/removing.md ADDED
@@ -0,0 +1,43 @@
1
+ # Removing Hitch from a Rails application
2
+
3
+ Removal is intentionally non-destructive. Unmounting the engine and removing
4
+ the gem must not silently delete OAuth audit data or principal bindings.
5
+
6
+ 1. Disable new authorization and registration traffic at ingress. Set DCR off
7
+ before the final deploy.
8
+ 2. Revoke active Hitch tokens, or wait for their configured expiry if the MCP
9
+ endpoint has already been disabled.
10
+ 3. Stop jobs and schedules that call `Hitch::AccessToken.cleanup_expired!` or
11
+ Hitch rake tasks.
12
+ 4. Back up the `hitch_*` tables and decide their retention period under the
13
+ application's audit and privacy policy.
14
+ 5. Remove generated tools with
15
+ `bin/rails destroy hitch:tool NAME [--namespace Namespace]` while the gem
16
+ is installed — each removes its files and its registration line, refusing
17
+ the rollback if the registration line was edited — then run
18
+ `bin/rails destroy hitch:install` to remove the initializer, endpoint
19
+ controller, registry, and the `/mcp` route. Review anything you customized
20
+ in your version control history first; destroy reverses what the
21
+ generators wrote.
22
+ 6. Remove the `mount Hitch::Engine` line — destroy leaves it in place, since
23
+ it cannot tell a generated mount from one that pre-dates the install —
24
+ and any host controller inclusions of `Hitch::CorsSupport`.
25
+ 7. Remove the Gemfile entry and bundle again.
26
+
27
+ `hitch:doctor` has no repair or uninstall mode. It is safe to use as a final
28
+ read-only inventory before step 5, but its findings do not authorize deletion.
29
+ The isolated diagnostic key is removed by the probe and has a five-second
30
+ expiry; Hitch's ordinary rate-limit keys expire under their configured windows
31
+ and require no uninstall sweep.
32
+
33
+ Hitch does not ship a table-dropping removal migration. Keep the tables while
34
+ old deployments, queued jobs, logs, or audit workflows may still refer to
35
+ their IDs. If the application's owner later chooses deletion, create a host
36
+ migration that names each `hitch_*` table explicitly, take a final backup, and
37
+ review that irreversible operation independently. Do not reuse those table
38
+ names for unrelated data.
39
+
40
+ If the application may reinstall Hitch, retaining the tables and migration
41
+ history is safer than dropping them. Reinstall the same or a forward-compatible
42
+ version and run `bin/rails db:migrate`; never restore only part of the Hitch
43
+ schema.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ module Generators
5
+ # Pre-flight guards shared by Hitch generators: check the destination
6
+ # before writing so a refused run leaves no partial install behind.
7
+ # Including generators define +refusal_subject+.
8
+ module GeneratorGuards
9
+ REGISTRY_PATH = "app/tools/mcp_tool_registry.rb"
10
+
11
+ private
12
+
13
+ def constant_collision?(name)
14
+ return false unless Object.const_defined?(name, false)
15
+ return true if File.expand_path(destination_root) == File.expand_path(Rails.root)
16
+
17
+ source = Object.const_source_location(name, false)&.first
18
+ source && File.expand_path(source).start_with?("#{File.expand_path(destination_root)}/")
19
+ rescue NameError
20
+ false
21
+ end
22
+
23
+ def destination_file?(relative_path)
24
+ File.file?(destination_path(relative_path))
25
+ end
26
+
27
+ def destination_path(relative_path)
28
+ File.expand_path(relative_path, destination_root)
29
+ end
30
+
31
+ def refuse!(operation, errors)
32
+ raise ::Thor::Error, "#{refusal_subject} #{operation} refused:\n- #{errors.join("\n- ")}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "rails/generators"
5
+ require_relative "../generator_guards"
6
+
7
+ module Hitch
8
+ module Generators
9
+ # One install: the initializer, the /mcp endpoint controller, an empty
10
+ # explicit tool registry, and the routes, in order. Migrations are
11
+ # auto-appended by the engine; run db:migrate after install.
12
+ #
13
+ # Usage:
14
+ # bin/rails generate hitch:install
15
+ # bin/rails destroy hitch:install
16
+ class InstallGenerator < ::Rails::Generators::Base
17
+ include Hitch::Generators::GeneratorGuards
18
+
19
+ # Declared explicitly so the command is always `hitch:install`,
20
+ # independent of generator-discovery ordering.
21
+ namespace "hitch:install"
22
+
23
+ source_root File.expand_path("templates", __dir__)
24
+
25
+ class_option :controller_name,
26
+ type: :string,
27
+ default: "McpController",
28
+ desc: "Host controller constant to create (must end in Controller)"
29
+
30
+ INITIALIZER_PATH = "config/initializers/hitch.rb"
31
+ ROUTES_PATH = "config/routes.rb"
32
+ CONTROLLER_PATTERN = /\A[A-Z][A-Za-z0-9]*(?:::[A-Z][A-Za-z0-9]*)*Controller\z/
33
+ MCP_ROUTE_PATTERN = /["']\/?mcp\/?["']/
34
+
35
+ def self.exit_on_failure?
36
+ true
37
+ end
38
+
39
+ def install_or_revoke
40
+ prepare_identity!
41
+
42
+ if behavior == :revoke
43
+ remove_files
44
+ remove_routes
45
+ print_removal_steps
46
+ else
47
+ preflight!
48
+ create_files
49
+ add_routes
50
+ print_next_steps
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def prepare_identity!
57
+ controller_name = options.fetch("controller_name")
58
+ unless CONTROLLER_PATTERN.match?(controller_name)
59
+ refuse!(operation, [ "controller name must be a constant path ending in Controller" ])
60
+ end
61
+
62
+ @controller_name = controller_name
63
+ @controller_modules = controller_name.split("::")[0...-1]
64
+ @controller_leaf = controller_name.split("::").last
65
+ @controller_path = "app/controllers/#{controller_name.underscore}.rb"
66
+ @route_target = controller_name.delete_suffix("Controller").underscore
67
+ end
68
+
69
+ def preflight!
70
+ errors = []
71
+ [ INITIALIZER_PATH, @controller_path, REGISTRY_PATH ].each do |path|
72
+ errors << "file collision: #{path}" if destination_file?(path)
73
+ end
74
+ errors << "constant collision: #{@controller_name}" if constant_collision?(@controller_name)
75
+ errors << "constant collision: McpToolRegistry" if constant_collision?("McpToolRegistry")
76
+ errors << "#{ROUTES_PATH} is missing a Rails.application.routes.draw block" unless
77
+ active_routes.include?(".routes.draw do")
78
+ refuse!("install", errors) if errors.any?
79
+ end
80
+
81
+ # Behavior-aware Thor actions: create in invoke, remove in revoke.
82
+ def create_files
83
+ template "initializer.rb", INITIALIZER_PATH
84
+ template "controller.rb.tt", @controller_path
85
+ template "registry.rb", REGISTRY_PATH
86
+ end
87
+ alias_method :remove_files, :create_files
88
+
89
+ # The /mcp route must precede the engine mount, so both go into one
90
+ # insertion at the top of the draw block.
91
+ def add_routes
92
+ active = active_routes
93
+ match_missing = !MCP_ROUTE_PATTERN.match?(active)
94
+ mount_missing = !active.include?("mount Hitch::Engine")
95
+
96
+ say_status :warn, "#{ROUTES_PATH} already routes /mcp; leaving it as is", :yellow unless match_missing
97
+ say_status :skip, "Hitch::Engine is already mounted", :yellow unless mount_missing
98
+ return unless match_missing || mount_missing
99
+
100
+ route(
101
+ [
102
+ (match_line if match_missing),
103
+ (mount_line if mount_missing)
104
+ ].compact.join("\n")
105
+ )
106
+ end
107
+
108
+ # Removes only the line this generator always writes. With no install
109
+ # record there is no way to tell a generated engine mount from one the
110
+ # host already had, and deleting a host-owned mount would take down its
111
+ # /oauth/* and discovery routes — so the mount is left in place and
112
+ # said so.
113
+ def remove_routes
114
+ active = active_routes
115
+ route(match_line) if active.include?(match_line)
116
+ return unless active.include?("mount Hitch::Engine")
117
+
118
+ say_status :skip,
119
+ "mount Hitch::Engine left in place (it may pre-date this install); remove it manually if unwanted",
120
+ :yellow
121
+ end
122
+
123
+ def match_line
124
+ %(match "/mcp", to: "#{@route_target}#handle", via: :all)
125
+ end
126
+
127
+ def mount_line
128
+ 'mount Hitch::Engine => "/"'
129
+ end
130
+
131
+ def active_routes
132
+ return "" unless destination_file?(ROUTES_PATH)
133
+
134
+ File.binread(destination_path(ROUTES_PATH))
135
+ .each_line.reject { |line| line.lstrip.start_with?("#") }.join
136
+ end
137
+
138
+ def operation
139
+ behavior == :revoke ? "removal" : "install"
140
+ end
141
+
142
+ def refusal_subject = "Hitch"
143
+
144
+ # Removing the initializer leaves the engine's boot validation with no
145
+ # resource_uri, so the app will not start until the gem is gone too.
146
+ # Saying so here is the difference between a clean uninstall and an
147
+ # application that suddenly refuses to boot.
148
+ def print_removal_steps
149
+ say ""
150
+ say "hitch-rails files removed.", :green
151
+ say "The application will not boot until you also:"
152
+ say " 1. Remove the hitch-rails line from your Gemfile, then: bundle install"
153
+ say " 2. Roll back Hitch's tables if you no longer want them"
154
+ say " 3. Delete any tools you generated under app/tools/"
155
+ say "See docs/removing.md for the full order."
156
+ end
157
+
158
+ def print_next_steps
159
+ say ""
160
+ say "hitch-rails installed.", :green
161
+ say "Next steps:"
162
+ say " 1. Set resource_uri and brand_name in #{INITIALIZER_PATH}"
163
+ say " 2. Run: bin/rails db:migrate"
164
+ say " 3. Generate your first tool: bin/rails generate hitch:tool echo"
165
+ end
166
+ end
167
+ end
168
+ end