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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 58f06a693bf0801c38fec53dab3f46b4ce8cc5d0e362d39eafebdfaf9b13d1b1
4
+ data.tar.gz: 35259384a42618dc255dd3a05c06f71bdaca5e17067eacc6b08a1d7564ab10ae
5
+ SHA512:
6
+ metadata.gz: '0803444dfe9b103310b55e5d154b1e139d17541506d9297a0b00d0774a070176240a46567cbe5c041ecc6c88fa80753dc48f40d508a2f5deabff81283a0b0fa5'
7
+ data.tar.gz: 22a5065e45a1ec72fe71a70a6bd24fb5c2c6cfbe6a1cda59e4fd581959899d0442dbded72c46a90752553404eb6f09e1502730bd5e96ceb69ce0c77469481202
data/CHANGELOG.md ADDED
@@ -0,0 +1,103 @@
1
+ # Changelog
2
+
3
+ All notable changes to hitch-rails will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-08-22
9
+
10
+ Initial public release: a mountable Rails engine that turns a Rails app into
11
+ an authenticated MCP server per the MCP 2026-07-28 authorization profile.
12
+
13
+ ### OAuth 2.1 authorization server
14
+
15
+ - OAuth 2.1 + PKCE (S256) authorization-code flow; PKCE mandatory, `plain`
16
+ rejected. Exact `redirect_uri` matching (loopback port excepted per
17
+ RFC 8252). RFC 9207 `iss` on authorization responses.
18
+ - Client ID Metadata Documents (MCP 2026-07-28's successor to DCR): an
19
+ `https` URL as `client_id`, fetched under strict SSRF constraints (443
20
+ only, no redirects, DNS pinned after non-public-range checks, wall-clock
21
+ budget, streamed size cap), cached with the configured TTL as a ceiling,
22
+ and bounded by concurrency and per-principal fetch caps.
23
+ `bin/rails 'hitch:cimd:check[URL]'` verifies egress.
24
+ - Optional Dynamic Client Registration (RFC 7591), disabled by the generated
25
+ initializer; capped, strictly parsed registration documents; unauthenticated
26
+ registration rate-limiting fails closed through the host cache store.
27
+ - RFC 8707 audience binding (`resource` persisted at issue, revalidated at
28
+ exchange), discovery metadata (RFC 8414 + RFC 9728, path-aware), and token
29
+ revocation (RFC 7009, always `200`).
30
+ - Tokens and codes stored as SHA-256 digests; single-use codes consumed with
31
+ a conditional state transition safe under concurrency. Host models with
32
+ integer, UUID, or ULID primary keys work: principal IDs are stored
33
+ losslessly as strings.
34
+ - Confidential token exchange accepts `client_secret_basic` only, tolerating
35
+ the official Python SDK's repeated body `client_id` only when it exactly
36
+ matches the Basic username.
37
+
38
+ ### Authenticated MCP endpoint
39
+
40
+ - The public `Hitch::MCP::Endpoint` concern owns Host/Origin/method/
41
+ authentication/admission ordering, exact media and header checks, bounded
42
+ duplicate-rejecting JSON parsing, and dispatch through the official Ruby
43
+ MCP SDK (`mcp >= 1.2, < 2`) behind a private per-request adapter.
44
+ - Explicit tool registry and DSL: `Hitch::MCP::Registry` and
45
+ `Hitch::MCP::Tool` build one immutable, MCP-name-sorted snapshot per Rails
46
+ prepare cycle; invalid declarations fail the whole reload. Every request
47
+ resolves a host scope once, applies deny-default `available_to?`, then
48
+ filters by registered static OAuth scopes. Unknown and unavailable calls
49
+ are indistinguishable; only a known available tool can return a 403
50
+ `insufficient_scope` step-up.
51
+ - Safe invocation and results: SDK input-schema validation precedes one
52
+ recursively frozen, string-keyed arguments Hash; deny-default `authorize!`
53
+ runs before `perform`. Results go through the closed
54
+ `Hitch::MCP::Result.text` / `.structured` / `.error` channel, validated
55
+ against the registered output schema and size-capped after serialization.
56
+ Only an explicit `Result.error` message reaches the wire; every other
57
+ failure is generic and reports sanitized structural context through
58
+ `Rails.error`. In development and test the real exception is also written
59
+ to the local log, so a tool is debuggable without the client ever learning
60
+ anything. The endpoint is stateless POST/OPTIONS and performs no
61
+ notification/202 response shaping.
62
+ - Request admission through the host cache store: one fixed-window quota per
63
+ principal/client counted via `increment` on `config.cache_store` (or
64
+ `config.mcp.rate_limit_store`) with HMAC keys — no Redis dependency, no
65
+ raw identifiers as keys, no quota reset on token rotation. Requests halted
66
+ by authentication never consume quota. Production refuses stores that
67
+ cannot count across processes.
68
+ - Structural observation: version-1 `request.hitch_mcp` and
69
+ `invocation.hitch_mcp` notifications expose only structural fields — never
70
+ credentials, bodies, arguments, or exception messages.
71
+
72
+ ### Host integration
73
+
74
+ - `rails generate hitch:install` creates the initializer, a host-owned
75
+ endpoint controller, the empty explicit registry under `app/tools/`, and
76
+ ordered routes; `rails generate hitch:tool NAME` emits a working,
77
+ registered tool with an integration test that proves it responds over real
78
+ HTTP (`--deny-default` for the hardened variant). Both reverse with
79
+ `rails destroy`.
80
+ - `Hitch::MCP::TestHelper` builds authenticated JSON-RPC integration
81
+ requests and mints real access tokens through the production PKCE exchange
82
+ with `mint_mcp_token`.
83
+ - The read-only `hitch:doctor` reports configuration, discovery, routing,
84
+ migrations, registry, ingress, and admission-store findings without
85
+ exposing credentials.
86
+ - `config.principal_method` (default `:current_user`, with Rails 8
87
+ `Current.user` fallback), overridable consent view, auto-appended
88
+ migrations, `Hitch::AccessToken.cleanup_expired!`.
89
+
90
+ ### Operator tasks
91
+
92
+ - `bin/rails hitch:tokens:issue PRINCIPAL=User:1` issues a long-lived access
93
+ token for a headless agent that cannot complete a consent redirect, through
94
+ the same authorization-code exchange the browser flow runs.
95
+ `Hitch::AccessToken.issue!` is the console equivalent. Disclosure follows
96
+ the client-secret tasks: one write to a new `0600` file or an attached
97
+ terminal, never stdout, and only the digest at rest.
98
+
99
+ ### Requirements
100
+
101
+ - Rails `>= 8.0, < 9`, Ruby `>= 3.3, < 4.1`, `mcp >= 1.2, < 2`, SQLite or
102
+ PostgreSQL. CI covers Rails 8.0 and 8.1; later 8.x, including edge Rails,
103
+ installs without a lane behind it.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tyler Klose
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,460 @@
1
+ # Hitch
2
+
3
+ **Couple your Rails app to anything that speaks MCP.** Hitch is the hitch:
4
+ it turns your Rails app into an authorization server implemented against the
5
+ MCP 2026-07-28 authorization profile, so
6
+ Claude, ChatGPT, Cursor, Grok, and any other MCP client can connect to your
7
+ app's tools with OAuth handled for you.
8
+
9
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
10
+
11
+ ## Why
12
+
13
+ **No Redis, no separate auth server, no new sign-in system.** Hitch uses what
14
+ your app already has: request admission counts through your configured
15
+ `config.cache_store`, and the OAuth consent screen identifies whoever your
16
+ app's own authentication says is signed in (`current_user` or Rails 8's
17
+ `Current.user`).
18
+
19
+ The official Ruby MCP SDK (the `mcp` gem) ships client-side OAuth but no
20
+ server-side auth helpers, so the authorization server is left to you. Hitch
21
+ is that server — on the current profile, with the authenticated endpoint and
22
+ tool registry in the same gem. It is opinionated about **what** to implement
23
+ (the [2026-07-28 MCP authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization))
24
+ but unopinionated about **whom** that auth identifies — the host supplies the
25
+ signed-in record.
26
+
27
+ ## What you get
28
+
29
+ A mountable Rails engine that bundles the pieces an authenticated MCP server
30
+ needs:
31
+
32
+ - **OAuth 2.1 + PKCE (S256)** — the auth flow MCP clients (Claude Code,
33
+ Claude.ai, Cursor, ChatGPT, etc.) use
34
+ - **An authenticated `/mcp` endpoint** — stateless POST/OPTIONS with strict
35
+ host/origin admission, bounded JSON, modern MCP headers, and dispatch
36
+ through the official Ruby SDK behind a private adapter
37
+ - **An explicit tool registry** — deny-default availability, per-principal
38
+ filtering, static OAuth scope checks, and a closed result channel with
39
+ schema validation and size caps
40
+ - **Client ID Metadata Documents** — the mechanism MCP 2026-07-28 deprecates
41
+ DCR in favour of; an `https` URL as `client_id`, with the metadata fetched
42
+ from it (opt-in)
43
+ - **Optional Dynamic Client Registration** (RFC 7591) — the generated
44
+ initializer disables it; the library default stays `true`, so adding the
45
+ gem to an existing installation never changes its behaviour silently
46
+ - **Resource Indicators with audience binding** (RFC 8707), discovery
47
+ metadata (RFC 8414 + RFC 9728), and token revocation (RFC 7009)
48
+ - **Default-deny CORS** with exact host-owned origin configuration
49
+ - **Generators, a test helper, and a read-only `hitch:doctor`** for
50
+ installing, testing, and diagnosing the integration
51
+
52
+ SQLite and PostgreSQL are supported, on Ruby >= 3.3 and Rails 8.x. CI tests
53
+ Rails 8.0 and 8.1 on every push; later 8.x releases — including edge Rails —
54
+ install and are expected to work, but are not covered by a lane.
55
+ Host models with integer, UUID, or ULID primary keys all work: access tokens
56
+ store principal IDs losslessly as strings.
57
+
58
+ ## Quickstart
59
+
60
+ ```ruby
61
+ # Gemfile
62
+ gem "hitch-rails"
63
+ ```
64
+
65
+ ```bash
66
+ bundle install
67
+ bin/rails generate hitch:install # initializer, /mcp controller, registry, routes
68
+ bin/rails db:migrate
69
+ bin/rails generate hitch:tool echo # a working, registered tool + integration test
70
+ ```
71
+
72
+ Set `resource_uri` and `brand_name` in `config/initializers/hitch.rb`, and
73
+ the generated tool answers `tools/call` — its generated test proves it over
74
+ real HTTP once you point the test's one `principal:` line at however your
75
+ tests get a signed-in user. Add `--deny-default` to generate a hardened tool
76
+ instead: hidden, denying, and unimplemented until you fill it in.
77
+ `bin/rails hitch:doctor` gives a read-only diagnosis of the install.
78
+ `bin/rails destroy hitch:tool NAME` and then `destroy hitch:install` reverse
79
+ the generators — tools first, because removing the initializer stops the app
80
+ booting and `destroy` cannot run after that. See
81
+ [docs/removing.md](docs/removing.md) for the full order.
82
+
83
+ ## Calling it
84
+
85
+ Everything below is required. Two of the headers and the `_meta` block are
86
+ easy to miss, so start from this and change one thing at a time. In
87
+ development and test, the reason for any refusal is written to your Rails
88
+ log — that is the fastest way to find which piece is wrong:
89
+
90
+ ```bash
91
+ TOKEN=$(cat agent.token) # see Headless agents, below
92
+
93
+ curl -sS -X POST https://your-app.example.com/mcp \
94
+ -H "Authorization: Bearer $TOKEN" \
95
+ -H "Content-Type: application/json" \
96
+ -H "Accept: application/json, text/event-stream" \
97
+ -H "MCP-Protocol-Version: 2026-07-28" \
98
+ -H "Mcp-Method: tools/call" \
99
+ -H "Mcp-Name: echo" \
100
+ -d '{
101
+ "jsonrpc": "2.0",
102
+ "id": "1",
103
+ "method": "tools/call",
104
+ "params": {
105
+ "name": "echo",
106
+ "arguments": {},
107
+ "_meta": {
108
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
109
+ "io.modelcontextprotocol/clientCapabilities": {}
110
+ }
111
+ }
112
+ }'
113
+ ```
114
+
115
+ - `Accept` must name **both** types. Either alone is a `406`. A wrong or
116
+ missing header answers `400` with `-32020`; bad `_meta` answers `400` with
117
+ `-32602`.
118
+ - `Mcp-Method` and `Mcp-Name` repeat the method and tool name from the body,
119
+ and must match it exactly. `Mcp-Name` is sent only for `tools/call`.
120
+ - `_meta` must carry **both** `protocolVersion` and `clientCapabilities`.
121
+ - `tools/list` and `server/discover` take the same shape without
122
+ `Mcp-Name`, and still require `params` with its `_meta`.
123
+
124
+ Real MCP clients send all of this for you. You need it for `curl`, and for
125
+ understanding a `400` while you are getting set up. In development and test,
126
+ a rejected request also writes the reason to your Rails log.
127
+
128
+ ### Running it locally
129
+
130
+ `resource_uri` is matched exactly — scheme, host, port, path and query — so
131
+ in development it has to name the address you are actually serving:
132
+
133
+ ```ruby
134
+ config.resource_uri = "http://localhost:3000/mcp" # match your real port
135
+ ```
136
+
137
+ Plain `http` is accepted for loopback hosts in development and test only.
138
+ If you change ports, change this too and reissue any token, since the
139
+ audience is bound at issue time.
140
+
141
+ A browser client also needs a `client_id` that resolves. Client ID Metadata
142
+ Documents need a public `https` host, and Dynamic Client Registration is off
143
+ in the generated initializer, so for local work register one directly:
144
+
145
+ ```ruby
146
+ Hitch::Client.register!(
147
+ client_id: "local-probe",
148
+ client_name: "Local Probe",
149
+ redirect_uris: [ "http://127.0.0.1:9999/callback" ]
150
+ )
151
+ ```
152
+
153
+ ## Configuration
154
+
155
+ The generated `config/initializers/hitch.rb` holds the knobs every host must
156
+ set; everything else has a working default:
157
+
158
+ ```ruby
159
+ Hitch.configure do |config|
160
+ config.resource_uri = "https://your-app.example.com/mcp" # RFC 8707
161
+ config.brand_name = "Your App"
162
+ config.allowed_origins = [] # exact browser origins; denied by default
163
+ config.client_id_metadata_enabled = true
164
+ config.dynamic_client_registration_enabled = false
165
+ config.mcp.enabled = true
166
+ config.mcp.registry = "McpToolRegistry"
167
+ end
168
+ ```
169
+
170
+ The defaults behind it: `server_info` derives from the application name,
171
+ `request_limit` is 120 requests per minute, request/result bodies cap at
172
+ 1 MiB, and admission counts through your `config.cache_store`. Tune them in
173
+ the same block when you need to:
174
+
175
+ ```ruby
176
+ config.allowed_hosts = [] # additional exact proxy hosts
177
+ config.supported_scopes = [ "mcp" ]
178
+ config.mcp.server_info = { name: "your-app", version: "1.0.0" }
179
+ config.mcp.scope_resolver = ->(principal:, access_token:, request:) {
180
+ principal.account # what tools see as context.scope
181
+ }
182
+ config.mcp.request_limit = { to: 120, within: 1.minute }
183
+ config.mcp.max_request_bytes = 1.megabyte
184
+ config.mcp.max_result_bytes = 1.megabyte
185
+ config.principal_method = :current_user # method on controllers
186
+ config.login_path = "/session/new" # where to redirect when unauth'd
187
+ ```
188
+
189
+ The generated route and controller:
190
+
191
+ ```ruby
192
+ # config/routes.rb — the MCP route must precede the engine mount
193
+ match "/mcp", to: "mcp#handle", via: :all
194
+ mount Hitch::Engine => "/" # exposes /oauth/* + /.well-known/*
195
+ ```
196
+
197
+ ```ruby
198
+ # app/controllers/mcp_controller.rb
199
+ class McpController < ActionController::API
200
+ include Hitch::MCP::Endpoint
201
+ end
202
+ ```
203
+
204
+ If you use Rails 8's built-in authentication generator, the signed-in user is
205
+ `Current.user` and there is no `current_user` method. Hitch falls back to
206
+ `Current.user` automatically; Devise and `has_secure_password` apps that
207
+ expose `current_user` work unchanged.
208
+
209
+ ## Tools
210
+
211
+ Tools live in `app/tools/`, and the registry in
212
+ `app/tools/mcp_tool_registry.rb`:
213
+
214
+ ```ruby
215
+ module McpTools
216
+ class AccountSummary < Hitch::MCP::Tool
217
+ tool_name "account_summary"
218
+ description "Describe one account"
219
+ input_schema(
220
+ type: "object",
221
+ properties: { account_id: { type: "string" } },
222
+ required: [ "account_id" ],
223
+ additionalProperties: false
224
+ )
225
+ annotations read_only_hint: true, destructive_hint: false
226
+
227
+ def self.available_to?(context)
228
+ # Whether this tool is listed and callable at all, for this principal.
229
+ context.principal.present?
230
+ end
231
+
232
+ def self.authorize!(context, arguments:)
233
+ # Returning without raising allows the call. This hook sees the
234
+ # arguments too, so policy can turn on what is asked, not only who
235
+ # asks. Hitch does not supply the policy — your app does.
236
+ raise Hitch::MCP::Forbidden unless
237
+ context.principal.may_read_account?(arguments.fetch("account_id"))
238
+ end
239
+
240
+ def self.perform(_context, arguments:)
241
+ Hitch::MCP::Result.text(Account.find(arguments.fetch("account_id")).summary)
242
+ end
243
+ end
244
+ end
245
+
246
+ class McpToolRegistry < Hitch::MCP::Registry
247
+ register McpTools::AccountSummary, scopes: [ "mcp" ]
248
+ end
249
+ ```
250
+
251
+ Everything is deny-default: a tool is listed and callable only if it is
252
+ registered, `available_to?` returns true for the resolved scope, and the
253
+ token carries a registered OAuth scope. Arguments arrive as one recursively
254
+ frozen, string-keyed Hash after SDK schema validation and `authorize!`.
255
+ Results go through the closed `Hitch::MCP::Result` channel — `.text`,
256
+ `.structured` (validated against the registered output schema), or `.error` —
257
+ and are size-capped after serialization. Host exception messages are never
258
+ exposed to clients.
259
+
260
+ Request admission shares one fixed-window quota per principal/client across
261
+ `server/discover`, `tools/list`, and `tools/call`, counted through your cache
262
+ store with HMAC keys (no raw identifiers, no reset on token rotation).
263
+ Production refuses a store that cannot count across processes
264
+ (`:memory_store`, `:null_store`, `:file_store`); see the
265
+ [request admission guide](docs/operator/rate_limiting.md).
266
+
267
+ ## Testing your tools
268
+
269
+ ```ruby
270
+ require "hitch/mcp/test_helper"
271
+
272
+ class AccountToolsTest < ActionDispatch::IntegrationTest
273
+ include Hitch::MCP::TestHelper
274
+
275
+ test "calls a reviewed tool" do
276
+ token = mint_mcp_token(principal: users(:one))
277
+
278
+ post_mcp(
279
+ method: "tools/call",
280
+ token: token,
281
+ params: {
282
+ name: "account_summary",
283
+ arguments: { account_id: accounts(:one).id.to_s }
284
+ }
285
+ )
286
+
287
+ assert_response :success
288
+ end
289
+ end
290
+ ```
291
+
292
+ `mint_mcp_token` mints a real access token through the production
293
+ authorization-code path for any persisted record your app signs in as;
294
+ `post_mcp` builds the JSON-RPC envelope with the canonical Host and modern
295
+ MCP headers; `mcp_headers(token:, method:)` is available for manual requests.
296
+ `rails g hitch:tool` generates a test in exactly this shape.
297
+
298
+ ## Headless agents
299
+
300
+ The OAuth flow needs a browser: a human signs in and presses Approve. An agent
301
+ running from cron or `claude -p` has neither, so issue it a token from the
302
+ console instead. The operator there is both the resource owner and the client,
303
+ so there is no third party for a consent screen to protect anyone from.
304
+
305
+ ```sh
306
+ bin/rails hitch:tokens:issue PRINCIPAL=User:1 OUTPUT_FILE=agent.token
307
+ # optional: SCOPES="mcp" EXPIRES_IN_DAYS=90 CLIENT_ID=cron-agent NAME="Nightly report"
308
+ ```
309
+
310
+ The token is written once to a new `0600` file (or to your terminal when one
311
+ is attached) and never to stdout; only its SHA-256 digest is stored, as in the
312
+ OAuth flow. The task defaults to 90 days, the first configured scope, and the
313
+ `client_id` `hitch-cli`. It can be revoked through `POST /oauth/revoke` or
314
+ `Hitch::AccessToken#revoke!` like any other.
315
+
316
+ `Hitch::AccessToken.issue!(principal:, client_id:, scopes:, expires_in:)` is
317
+ the same call from `rails console` or a seed script. Note it takes
318
+ `expires_in` in **seconds** and applies the ordinary
319
+ `access_token_lifetime_seconds` when you omit it — the 90-day default belongs
320
+ to the task, not the method.
321
+
322
+ The row it writes is deliberately indistinguishable from a browser-issued
323
+ grant, which is what lets it use the same code path. The practical marker is
324
+ `client_id`: leave it at `hitch-cli`, or give each agent its own, so a token
325
+ can be traced and revoked by who holds it. Anyone who can run this task can
326
+ already mint the same row by hand from a console, so it grants no authority
327
+ that database access did not already carry.
328
+
329
+ Refresh-token issuance is deliberately not implemented, so an expired agent
330
+ token is reissued the same way.
331
+
332
+ ## Operator diagnosis
333
+
334
+ ```sh
335
+ bin/rails hitch:doctor
336
+ HITCH_DOCTOR_FORMAT=json bin/rails hitch:doctor
337
+ ```
338
+
339
+ The read-only doctor reports on runtime versions, configuration, discovery,
340
+ route order, migrations, the Registry, host/origin posture, and the admission
341
+ store, without exposing credentials or mutating anything. Anything it finds
342
+ wrong is printed with what to do about it. See the
343
+ [doctor contract](docs/operator/doctor.md).
344
+
345
+ ## Client ID Metadata Documents
346
+
347
+ MCP 2026-07-28 deprecates Dynamic Client Registration in favour of CIMD: a
348
+ client uses an `https` URL as its `client_id` and the authorization server
349
+ fetches the metadata from it. Deprecated is not removed — DCR stays available
350
+ at MAY for authorization servers that do not support CIMD, and CIMD itself is
351
+ a SHOULD. The spec's selection order puts pre-registered client credentials
352
+ ahead of CIMD, with DCR after it. New installs get
353
+ `config.client_id_metadata_enabled = true` from the generated initializer;
354
+ the library default stays `false` so an upgrade never flips it silently.
355
+
356
+ Enabling CIMD means `/oauth/authorize` makes outbound HTTPS requests to
357
+ caller-chosen URLs, so each fetch is tightly constrained (https on 443 only,
358
+ no redirects, DNS pinned after a non-public-range check, wall-clock budget,
359
+ streamed size cap) and the volume is bounded by two caps:
360
+
361
+ ```ruby
362
+ config.client_id_metadata_enabled = true
363
+ config.client_id_metadata_cache_ttl = 3600 # document cache ceiling
364
+ config.client_id_metadata_max_concurrent_fetches = 4 # protects your request pool
365
+ config.client_id_metadata_fetches_per_minute = 20 # per signed-in principal
366
+ ```
367
+
368
+ The host must be able to reach arbitrary https hosts directly — Hitch ignores
369
+ `http_proxy`, which is part of what keeps this from being an SSRF hole. Verify
370
+ egress before enabling:
371
+
372
+ ```
373
+ bin/rails 'hitch:cimd:check[https://some-client.example/client.json]'
374
+ ```
375
+
376
+ ## Dynamic Client Registration
377
+
378
+ `POST /oauth/register` is disabled by the generated initializer and discovery
379
+ omits `registration_endpoint`. If you enable it, registration is
380
+ unauthenticated, so it is rate-limited per `request.remote_ip` through your
381
+ cache store and rejects malformed or oversized documents before persistence:
382
+
383
+ ```ruby
384
+ config.dynamic_client_registration_enabled = true
385
+ config.dynamic_client_registration_limit = { to: 20, within: 1.minute }
386
+ ```
387
+
388
+ Behind a CDN or load balancer, set
389
+ `config.action_dispatch.trusted_proxies` to exactly your proxy addresses —
390
+ the quota is only as trustworthy as `remote_ip`.
391
+
392
+ ## Hosts, origins, and preflight
393
+
394
+ The host in `config.resource_uri` is canonical. `config.allowed_hosts` adds
395
+ exact proxy hostnames; wildcards are rejected. Discovery, endpoint URLs,
396
+ RFC 9207 `iss`, and bearer challenges always derive from the fixed
397
+ `resource_uri` origin, never from request headers. `config.allowed_origins`
398
+ is an exact allowlist; preflights receive `204` only when Origin, method, and
399
+ every requested header are allowed.
400
+
401
+ ## Operational cleanup
402
+
403
+ Expired auth codes and long-dead tokens accumulate unless reaped. Schedule
404
+ the provided method with whatever job framework you use:
405
+
406
+ ```ruby
407
+ class CleanupMCPTokensJob < ApplicationJob
408
+ def perform
409
+ Hitch::AccessToken.cleanup_expired!(revoked_retention_days: 30)
410
+ end
411
+ end
412
+ ```
413
+
414
+ Idempotent; active tokens are never touched.
415
+
416
+ ## Customizing the consent view
417
+
418
+ Override the shipped consent screen by placing your own
419
+ `app/views/hitch/authorizations/new.html.erb` in the host app. Available
420
+ instance variables: `@client_name`, `@redirect_host`, `@brand_name`,
421
+ `@oauth_params`, `@resource`, and `@scopes` (already clamped to
422
+ `supported_scopes` — show them so consent is informed).
423
+
424
+ `@client_name` is derived from the verified redirect host through
425
+ `config.client_names`, a Hash of host matchers (exact `String` or `Regexp`)
426
+ to labels, checked in order. The default table labels the common MCP
427
+ clients; extend it with
428
+ `config.client_names = Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge("tool.example" => "My Tool")`.
429
+
430
+ ## Adopter security requirements
431
+
432
+ This gem is an OAuth **authorization server** — configure the host correctly
433
+ or undermine its guarantees:
434
+
435
+ - **`config.resource_uri`** — set it. Tokens are audience-bound to it
436
+ (RFC 8707) and validation fails closed without it.
437
+ - **`config.allowed_hosts` / `config.allowed_origins`** — exact allowlists;
438
+ keep them minimal.
439
+ - **`protect_from_forgery`** — keep CSRF protection active on the consent
440
+ (`POST /oauth/authorize`) path.
441
+ - **`config.action_dispatch.trusted_proxies`** — set correctly behind a
442
+ reverse proxy so `remote_ip` and scheme are interpreted correctly.
443
+
444
+ ## Status
445
+
446
+ 0.2.0 is the first public release. The public API may change before v1.0.0.
447
+ The exact public surface is documented in
448
+ [`docs/public_api/0.2.0.md`](docs/public_api/0.2.0.md); removal is covered in
449
+ [`docs/removing.md`](docs/removing.md).
450
+
451
+ ## Contributing
452
+
453
+ Issues and PRs welcome — see [CONTRIBUTING.md](https://github.com/tylerklose/hitch-rails/blob/main/CONTRIBUTING.md). Spec
454
+ conformance is the primary correctness bar; citations to the
455
+ [MCP authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
456
+ and the underlying RFCs are appreciated.
457
+
458
+ ## License
459
+
460
+ [MIT](MIT-LICENSE) © Tyler Klose