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
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
|
+
[](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
|