activerecord-mcp 0.1.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/.rubocop.yml +51 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +133 -0
- data/Rakefile +16 -0
- data/config/routes.rb +6 -0
- data/docs/advanced.md +137 -0
- data/docs/authentication.md +122 -0
- data/docs/configuration.md +152 -0
- data/docs/querying.md +171 -0
- data/lib/generators/rails_mcp/install/install_generator.rb +17 -0
- data/lib/generators/rails_mcp/install/templates/initializer.rb +69 -0
- data/lib/rails_mcp/auth/token_validator.rb +63 -0
- data/lib/rails_mcp/configuration.rb +33 -0
- data/lib/rails_mcp/controllers/well_known_controller.rb +18 -0
- data/lib/rails_mcp/database/column_policy.rb +21 -0
- data/lib/rails_mcp/database/model_resolver.rb +63 -0
- data/lib/rails_mcp/database/query_builder.rb +109 -0
- data/lib/rails_mcp/database/role_proxy.rb +11 -0
- data/lib/rails_mcp/engine.rb +28 -0
- data/lib/rails_mcp/schema_config.rb +51 -0
- data/lib/rails_mcp/server.rb +51 -0
- data/lib/rails_mcp/tool_dsl.rb +52 -0
- data/lib/rails_mcp/tools/count_records.rb +51 -0
- data/lib/rails_mcp/tools/describe_model.rb +46 -0
- data/lib/rails_mcp/tools/find_record.rb +50 -0
- data/lib/rails_mcp/tools/list_models.rb +20 -0
- data/lib/rails_mcp/tools/query_records.rb +41 -0
- data/lib/rails_mcp/version.rb +5 -0
- data/lib/rails_mcp.rb +41 -0
- data/rails-mcp.gemspec +35 -0
- data/test/dummy/app/models/post.rb +6 -0
- data/test/dummy/app/models/user.rb +6 -0
- data/test/dummy/config/application.rb +18 -0
- data/test/dummy/config/database.yml +13 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/initializers/doorkeeper.rb +11 -0
- data/test/dummy/config/routes.rb +6 -0
- data/test/dummy/db/schema.rb +58 -0
- data/test/fixtures/rails_mcp.yml +5 -0
- data/test/test_helper.rb +47 -0
- data/test/tmp/generator_output/config/initializers/rails_mcp.rb +69 -0
- data/test/unit/auth/token_validator_test.rb +100 -0
- data/test/unit/database/denied_columns_test.rb +154 -0
- data/test/unit/database/model_resolver_test.rb +60 -0
- data/test/unit/database/query_builder_test.rb +132 -0
- data/test/unit/generators/install_generator_test.rb +52 -0
- data/test/unit/schema_config_test.rb +142 -0
- data/test/unit/tools/count_records_test.rb +57 -0
- data/test/unit/tools/describe_model_test.rb +38 -0
- data/test/unit/tools/find_record_test.rb +41 -0
- data/test/unit/tools/list_models_test.rb +21 -0
- data/test/unit/tools/query_records_test.rb +51 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c9497890ce1878eb9de6a624e8db95ecaa6e5b8e6d011e1392757cd62630d31f
|
|
4
|
+
data.tar.gz: a6910fcd4b686d11a5eb5e99dd57cea4b9fbb5fde7120d26d387a16f7ccada13
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e3f176ad0d260adecf0ebd94229ad1bd81a8ca37613efc96883c32f1bcfe057c9ad5888bce9b5ed45a25b3062842e3e037ace65a95d665d7dcc10717abfc74b1
|
|
7
|
+
data.tar.gz: 1608b80a2888a30830ca9d2b96c3b305b32e5880702bc456b990f032d83a4b74a3594f90bcef36193b9c0885aa4fabe1d295ef9ffaf91b6ff402bc180ec009b5
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.1
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "bin/**/*"
|
|
7
|
+
- "vendor/**/*"
|
|
8
|
+
- "test/dummy/**/*"
|
|
9
|
+
- "test/tmp/**/*"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Style/StringLiterals:
|
|
13
|
+
Enabled: true
|
|
14
|
+
EnforcedStyle: double_quotes
|
|
15
|
+
|
|
16
|
+
Style/StringLiteralsInInterpolation:
|
|
17
|
+
Enabled: true
|
|
18
|
+
EnforcedStyle: double_quotes
|
|
19
|
+
|
|
20
|
+
Layout/LineLength:
|
|
21
|
+
Max: 120
|
|
22
|
+
|
|
23
|
+
# Class/module docs are not required
|
|
24
|
+
Style/Documentation:
|
|
25
|
+
Enabled: false
|
|
26
|
+
|
|
27
|
+
# server_context: is required by the MCP SDK interface even when unused in a tool
|
|
28
|
+
Lint/UnusedMethodArgument:
|
|
29
|
+
AllowUnusedKeywordArguments: true
|
|
30
|
+
|
|
31
|
+
# Error subclasses nested in the same file is intentional
|
|
32
|
+
Style/OneClassPerFile:
|
|
33
|
+
Enabled: false
|
|
34
|
+
|
|
35
|
+
# We use compact nested module style throughout
|
|
36
|
+
Style/ClassAndModuleChildren:
|
|
37
|
+
Enabled: false
|
|
38
|
+
|
|
39
|
+
# Tool and builder classes are data-heavy by design
|
|
40
|
+
Metrics/MethodLength:
|
|
41
|
+
Max: 20
|
|
42
|
+
Metrics/AbcSize:
|
|
43
|
+
Max: 30
|
|
44
|
+
Metrics/CyclomaticComplexity:
|
|
45
|
+
Max: 10
|
|
46
|
+
Metrics/PerceivedComplexity:
|
|
47
|
+
Max: 10
|
|
48
|
+
Metrics/ClassLength:
|
|
49
|
+
Max: 150
|
|
50
|
+
Metrics/ParameterLists:
|
|
51
|
+
Max: 8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-05-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Five built-in ActiveRecord query tools: `list_models`, `describe_model`, `query_records`, `find_record`, `count_records`
|
|
14
|
+
- Read-only by default via `database_role` config (uses Rails' `connected_to`)
|
|
15
|
+
- `schema_file` option — YAML file defining per-model column allowlists; `id`, `created_at`, `updated_at` auto-included
|
|
16
|
+
- `denied_columns` config accepting exact strings and regexes; applied as a final layer over all other config
|
|
17
|
+
- Two-pass security model: denied columns blocked before query (validation) and stripped after (output sanitisation)
|
|
18
|
+
- `ColumnPolicy` as the single source of truth for allowed columns across all query paths
|
|
19
|
+
- Hash condition value validation — rejects Hash values to block Rails 7.1+ predicate operators
|
|
20
|
+
- Quoted SELECT via Arel — column identifiers are always fully qualified and DB-quoted
|
|
21
|
+
- `max_limit` config (default 100) — silently caps `query_records` limit
|
|
22
|
+
- `max_offset` config (default 10,000) — raises an error on deep pagination attempts
|
|
23
|
+
- OAuth 2.1 + PKCE authentication via Doorkeeper; `TokenValidator` Rack middleware
|
|
24
|
+
- `scope` config (default `"mcp"`) — tokens without the required scope are rejected with `403 insufficient_scope`
|
|
25
|
+
- `GET /.well-known/oauth-authorization-server` — public OAuth discovery endpoint
|
|
26
|
+
- Custom tool DSL — `RailsMcp::Server.tool("name") { ... }` registers tools before first request
|
|
27
|
+
- `bin/rails generate rails_mcp:install` — scaffolds a documented initializer with all options commented out
|
|
28
|
+
- Streamable HTTP transport via the official MCP Ruby SDK (`mcp` gem); no SSE, no standalone process
|
|
29
|
+
- Mounts as a Rails Engine — shares Puma thread pool and ActiveRecord connection pool
|
|
30
|
+
- Full documentation in `docs/`: authentication, querying, configuration, advanced usage
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paulo Ancheta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# activerecord-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/pauloancheta/rails-mcp/actions/workflows/main.yml)
|
|
4
|
+
|
|
5
|
+
The read-only MCP server Rails developers have been looking for.
|
|
6
|
+
|
|
7
|
+
Drop it into any Rails app and your AI tools can introspect your ActiveRecord models and query your database instantly — no standalone process, no raw SQL, no credentials handed to a client. By default every query runs against a read-only database role, so production data stays safe. If you need writes, swap in any role your app already has.
|
|
8
|
+
|
|
9
|
+
Built on the [official MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) and mounted as a Rails Engine, it shares Puma's thread pool and your existing connection pool. Nothing extra to run.
|
|
10
|
+
|
|
11
|
+
## Why activerecord-mcp
|
|
12
|
+
|
|
13
|
+
- **Read-only by default** — queries run against a named database role (`:reading`); your write replica or primary is never touched unless you configure it
|
|
14
|
+
- **No raw SQL** — all queries go through hash conditions validated against actual column names; no string interpolation reaches the database
|
|
15
|
+
- **Fine-grained access control** — allowlist models, denylist columns by exact name or regex, or define a per-model column allowlist in a YAML file
|
|
16
|
+
- **OAuth 2.1 + PKCE** — every request requires a scoped Bearer token via [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper); tokens from other clients are rejected at the middleware layer
|
|
17
|
+
- **Zero extra infrastructure** — mounts as a Rails Engine; shares Puma threads and ActiveRecord connections with the rest of your app
|
|
18
|
+
- **Extensible** — register custom tools alongside the built-ins using a simple DSL
|
|
19
|
+
|
|
20
|
+
## Table of contents
|
|
21
|
+
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [Basic setup](#basic-setup)
|
|
24
|
+
- [Quick example](#quick-example)
|
|
25
|
+
- [Documentation](#documentation)
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add to your Gemfile:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "activerecord-mcp"
|
|
33
|
+
gem "doorkeeper"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle install
|
|
38
|
+
bin/rails generate doorkeeper:install
|
|
39
|
+
bin/rails generate doorkeeper:migration
|
|
40
|
+
bin/rails db:migrate
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Basic setup
|
|
44
|
+
|
|
45
|
+
**1. Configure Doorkeeper** (`config/initializers/doorkeeper.rb`):
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Doorkeeper.configure do
|
|
49
|
+
orm :active_record
|
|
50
|
+
pkce_code_challenge_methods %w[S256]
|
|
51
|
+
|
|
52
|
+
resource_owner_authenticator do
|
|
53
|
+
current_user || redirect_to(new_user_session_url)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**2. Mount the engine** (`config/routes.rb`):
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Rails.application.routes.draw do
|
|
62
|
+
use_doorkeeper
|
|
63
|
+
mount RailsMcp::Engine, at: "/mcp"
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**3. Generate the initializer:**
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
bin/rails generate rails_mcp:install
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This creates `config/initializers/rails_mcp.rb` with every option documented and commented out. Edit it to restrict access:
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
RailsMcp.configure do |config|
|
|
78
|
+
config.allowed_models = %w[User Post Order]
|
|
79
|
+
config.denied_columns = ["password_digest", /token/i, /secret/i]
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That's it — the five built-in query tools are live at `/mcp`.
|
|
84
|
+
|
|
85
|
+
## Quick example
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Get a Bearer token (see docs/authentication.md)
|
|
89
|
+
TOKEN="eyJhbGc..."
|
|
90
|
+
|
|
91
|
+
# List accessible models
|
|
92
|
+
curl -X POST https://your-app.com/mcp \
|
|
93
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
94
|
+
-H "Content-Type: application/json" \
|
|
95
|
+
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_models","arguments":{}},"id":1}'
|
|
96
|
+
|
|
97
|
+
# Query records
|
|
98
|
+
curl -X POST https://your-app.com/mcp \
|
|
99
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
100
|
+
-H "Content-Type: application/json" \
|
|
101
|
+
-d '{
|
|
102
|
+
"jsonrpc": "2.0",
|
|
103
|
+
"method": "tools/call",
|
|
104
|
+
"params": {
|
|
105
|
+
"name": "query_records",
|
|
106
|
+
"arguments": {
|
|
107
|
+
"model": "User",
|
|
108
|
+
"conditions": { "active": true },
|
|
109
|
+
"fields": ["id", "name", "email"],
|
|
110
|
+
"limit": 10
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"id": 2
|
|
114
|
+
}'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
| Topic | Description |
|
|
120
|
+
|-------|-------------|
|
|
121
|
+
| [Authentication](docs/authentication.md) | OAuth 2.1 + PKCE setup, Bearer tokens, discovery endpoint |
|
|
122
|
+
| [Querying](docs/querying.md) | All five built-in tools with full argument reference |
|
|
123
|
+
| [Configuration](docs/configuration.md) | All config options with defaults and explanations |
|
|
124
|
+
| [Advanced usage](docs/advanced.md) | YAML model allowlist, explicit column deny, custom tools DSL |
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
bundle install
|
|
130
|
+
bundle exec rake test
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Bug reports and pull requests welcome at https://github.com/pauloancheta/rails-mcp.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require "rubocop/rake_task"
|
|
13
|
+
|
|
14
|
+
RuboCop::RakeTask.new
|
|
15
|
+
|
|
16
|
+
task default: %i[test rubocop]
|
data/config/routes.rb
ADDED
data/docs/advanced.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Advanced Usage
|
|
2
|
+
|
|
3
|
+
## YAML model allowlist
|
|
4
|
+
|
|
5
|
+
For production deployments you typically want an explicit, auditable list of which models and columns the MCP can expose. The `schema_file` option points to a YAML file that defines this.
|
|
6
|
+
|
|
7
|
+
### Format
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# config/rails_mcp.yml
|
|
11
|
+
User:
|
|
12
|
+
- name
|
|
13
|
+
- email
|
|
14
|
+
- active
|
|
15
|
+
- created_at
|
|
16
|
+
|
|
17
|
+
Post:
|
|
18
|
+
- title
|
|
19
|
+
- body
|
|
20
|
+
- published_at
|
|
21
|
+
- created_at
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Each key is a model name; each value is the list of columns that can appear in `fields`, `conditions`, and `order`. Model names must match your AR class name exactly (including namespace, e.g. `Admin::User`).
|
|
25
|
+
|
|
26
|
+
You do **not** need to include `id`, `created_at`, or `updated_at` — they are auto-included from `default_fields` regardless of what the file lists. If you want to exclude them, override `default_fields` in the initializer.
|
|
27
|
+
|
|
28
|
+
### Behaviour when set
|
|
29
|
+
|
|
30
|
+
- Only models listed in the file are accessible. Any other model name returns an error.
|
|
31
|
+
- Each model's columns are restricted to the listed set plus `default_fields`.
|
|
32
|
+
- `allowed_models` and `denied_models` config options are ignored — the file is the authoritative list.
|
|
33
|
+
- `denied_columns` still applies on top of the file — a column listed in the YAML but matching a denied pattern is still denied.
|
|
34
|
+
|
|
35
|
+
### Referencing it in the initializer
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# config/initializers/rails_mcp.rb
|
|
39
|
+
RailsMcp.configure do |config|
|
|
40
|
+
config.schema_file = Rails.root.join("config/rails_mcp.yml")
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Explicit column deny
|
|
47
|
+
|
|
48
|
+
`denied_columns` lets you block specific columns across all models regardless of any other configuration. It is the right tool for sensitive columns that should never be accessible — password hashes, tokens, secrets, PII you want excluded from AI context.
|
|
49
|
+
|
|
50
|
+
### Strings and regexes
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
config.denied_columns = [
|
|
54
|
+
"password_digest", # exact match
|
|
55
|
+
"encrypted_password", # exact match
|
|
56
|
+
/token/i, # case-insensitive regex — matches reset_token, api_token, etc.
|
|
57
|
+
/secret/i, # matches client_secret, secret_key, etc.
|
|
58
|
+
/api_key/i,
|
|
59
|
+
/ssn/i,
|
|
60
|
+
/credit_card/i
|
|
61
|
+
]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### What "denied" means
|
|
65
|
+
|
|
66
|
+
A denied column is invisible at every layer:
|
|
67
|
+
|
|
68
|
+
- **`describe_model`** — the column does not appear in the schema output
|
|
69
|
+
- **`query_records` / `find_record`** — requesting the column in `fields` raises an error
|
|
70
|
+
- **`query_records` / `count_records`** — using the column in `conditions` raises an error; this closes the count-oracle attack where an attacker could confirm a hash value by filtering on it and checking the count
|
|
71
|
+
- **`query_records`** — using the column in `order` raises an error
|
|
72
|
+
- **Output strip** — denied columns are removed from serialized results after the query, as a second independent safety net
|
|
73
|
+
|
|
74
|
+
### Interaction with schema_file
|
|
75
|
+
|
|
76
|
+
`denied_columns` always wins. If your YAML file lists `email` and you also have `/email/i` in `denied_columns`, the column is denied.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# config/rails_mcp.yml lists email
|
|
80
|
+
# config/initializers/rails_mcp.rb denies it
|
|
81
|
+
config.schema_file = Rails.root.join("config/rails_mcp.yml")
|
|
82
|
+
config.denied_columns = [/email/i]
|
|
83
|
+
# result: email is inaccessible despite being in the YAML
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Custom tools
|
|
89
|
+
|
|
90
|
+
Register your own MCP tools alongside the built-ins. Tools must be registered **before the first request** because the MCP server is built at boot time. An initializer is the right place.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# config/initializers/rails_mcp.rb
|
|
94
|
+
RailsMcp::Server.tool("business_summary") do
|
|
95
|
+
description "Return a revenue summary for a given date"
|
|
96
|
+
|
|
97
|
+
parameter :date, type: :string, description: "ISO 8601 date (e.g. 2024-01-15)", required: true
|
|
98
|
+
parameter :currency, type: :string, description: "Currency code, defaults to USD"
|
|
99
|
+
|
|
100
|
+
call do |params, _server_context|
|
|
101
|
+
date = Date.parse(params[:date])
|
|
102
|
+
orders = Order.where(created_at: date.all_day)
|
|
103
|
+
{
|
|
104
|
+
date: date.iso8601,
|
|
105
|
+
count: orders.count,
|
|
106
|
+
total: orders.sum(:amount_cents),
|
|
107
|
+
currency: params[:currency] || "USD"
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### DSL reference
|
|
114
|
+
|
|
115
|
+
| Method | Arguments | Description |
|
|
116
|
+
|--------|-----------|-------------|
|
|
117
|
+
| `description` | string | Human-readable description shown in `tools/list` |
|
|
118
|
+
| `parameter` | `name, type:, description: nil, required: false` | Declares an input parameter |
|
|
119
|
+
| `call` | block `\|params, server_context\|` | The tool's implementation; return value is JSON-serialized |
|
|
120
|
+
|
|
121
|
+
Supported `type` values: `:string`, `:integer`, `:number`, `:boolean`, `:array`, `:object`.
|
|
122
|
+
|
|
123
|
+
### Accessing `server_context`
|
|
124
|
+
|
|
125
|
+
`server_context` contains request-scoped data set by the middleware layer. The Doorkeeper access token is available as:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
call do |params, server_context|
|
|
129
|
+
token = server_context["rails_mcp.access_token"]
|
|
130
|
+
user = token&.resource_owner
|
|
131
|
+
# ...
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Registration timing
|
|
136
|
+
|
|
137
|
+
The MCP server is memoized on first use. Registering a tool after the server has been built has no effect. All `RailsMcp::Server.tool` calls must complete before the first request reaches the engine. Rails initializers run before the server is built, so they are the correct place.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
activerecord-mcp uses [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper) for OAuth 2.1 server-side authentication with PKCE. Every request to the MCP endpoint requires a valid Bearer token.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
A Rack middleware (`RailsMcp::Auth::TokenValidator`) sits in front of the MCP transport. It validates the Bearer token against Doorkeeper before the request ever reaches the JSON-RPC layer. Invalid, expired, or revoked tokens are rejected with a `401` response — no tool code runs.
|
|
8
|
+
|
|
9
|
+
Two paths bypass the middleware:
|
|
10
|
+
- `OPTIONS` requests (CORS preflight)
|
|
11
|
+
- `/.well-known/` paths (OAuth discovery — must be public)
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
### 1. Install Doorkeeper
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem "doorkeeper"
|
|
20
|
+
gem "activerecord-mcp"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bin/rails generate doorkeeper:install
|
|
25
|
+
bin/rails generate doorkeeper:migration
|
|
26
|
+
bin/rails db:migrate
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Configure Doorkeeper with PKCE
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# config/initializers/doorkeeper.rb
|
|
33
|
+
Doorkeeper.configure do
|
|
34
|
+
orm :active_record
|
|
35
|
+
|
|
36
|
+
# PKCE S256 is required — activerecord-mcp warns at boot if this is missing
|
|
37
|
+
pkce_code_challenge_methods %w[S256]
|
|
38
|
+
|
|
39
|
+
resource_owner_authenticator do
|
|
40
|
+
current_user || redirect_to(new_user_session_url)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
activerecord-mcp logs a warning at boot if PKCE S256 is not enabled. It does not raise — Doorkeeper config is owned by the host app.
|
|
46
|
+
|
|
47
|
+
### 3. Add routes
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# config/routes.rb
|
|
51
|
+
Rails.application.routes.draw do
|
|
52
|
+
use_doorkeeper
|
|
53
|
+
mount RailsMcp::Engine, at: "/mcp"
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Required scope
|
|
58
|
+
|
|
59
|
+
Every token must carry the `mcp` scope (configurable via `config.scope`). A valid, non-expired token that lacks the scope is rejected with `403 insufficient_scope` before any tool code runs. This prevents tokens issued to other parts of your app (e.g. a mobile API client) from being used to access the MCP endpoint.
|
|
60
|
+
|
|
61
|
+
To issue a token with the right scope, ensure your Doorkeeper config includes it:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
# config/initializers/doorkeeper.rb
|
|
65
|
+
Doorkeeper.configure do
|
|
66
|
+
optional_scopes :mcp
|
|
67
|
+
# ...
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then request or create tokens that include the `mcp` scope. To use a different scope name, or to disable the check entirely, see [`configuration.scope`](configuration.md#scope).
|
|
72
|
+
|
|
73
|
+
## Making authenticated requests
|
|
74
|
+
|
|
75
|
+
Include the token in the `Authorization` header:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Authorization: Bearer <access_token>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
curl -X POST https://your-app.com/mcp \
|
|
85
|
+
-H "Authorization: Bearer eyJhbGc..." \
|
|
86
|
+
-H "Content-Type: application/json" \
|
|
87
|
+
-d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## OAuth discovery
|
|
91
|
+
|
|
92
|
+
The endpoint `GET /mcp/.well-known/oauth-authorization-server` is public and returns standard OAuth 2.1 discovery metadata pointing to your Doorkeeper endpoints. MCP clients that support OAuth discovery can use this to auto-configure themselves.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
curl https://your-app.com/mcp/.well-known/oauth-authorization-server
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Error responses
|
|
99
|
+
|
|
100
|
+
| Condition | Status | Body |
|
|
101
|
+
|-----------|--------|------|
|
|
102
|
+
| Missing `Authorization` header | `401` | `{"error":"invalid_token"}` |
|
|
103
|
+
| Token not found | `401` | `{"error":"invalid_token"}` |
|
|
104
|
+
| Token revoked | `401` | `{"error":"invalid_token"}` |
|
|
105
|
+
| Token expired | `401` | `{"error":"invalid_token"}` |
|
|
106
|
+
| Token lacks required scope | `403` | `{"error":"insufficient_scope"}` |
|
|
107
|
+
|
|
108
|
+
All `401` responses include a `WWW-Authenticate: Bearer realm="activerecord-mcp"` header.
|
|
109
|
+
|
|
110
|
+
## Creating tokens (development)
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# bin/rails console
|
|
114
|
+
app = Doorkeeper::Application.create!(
|
|
115
|
+
name: "my-mcp-client",
|
|
116
|
+
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
|
|
117
|
+
confidential: false,
|
|
118
|
+
scopes: ""
|
|
119
|
+
)
|
|
120
|
+
token = Doorkeeper::AccessToken.create!(application: app, expires_in: 7200)
|
|
121
|
+
puts token.token
|
|
122
|
+
```
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Configure activerecord-mcp in an initializer. All settings have defaults — you only need to set what you want to change.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# config/initializers/rails_mcp.rb
|
|
7
|
+
RailsMcp.configure do |config|
|
|
8
|
+
config.database_role = :reading
|
|
9
|
+
config.default_fields = [:id, :created_at, :updated_at]
|
|
10
|
+
config.allowed_models = []
|
|
11
|
+
config.denied_models = []
|
|
12
|
+
config.denied_columns = []
|
|
13
|
+
config.max_limit = 100
|
|
14
|
+
config.schema_file = nil
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Options
|
|
19
|
+
|
|
20
|
+
### `database_role`
|
|
21
|
+
|
|
22
|
+
**Default:** `:reading`
|
|
23
|
+
|
|
24
|
+
The ActiveRecord role passed to `connected_to(role:)` for every query. Use any role defined in your `database.yml`.
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
config.database_role = :reading
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Rails 6.1+. If your app uses a single database without named roles, set this to `:writing` (the default primary role in Rails).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
### `default_fields`
|
|
35
|
+
|
|
36
|
+
**Default:** `[:id, :created_at, :updated_at]`
|
|
37
|
+
|
|
38
|
+
Columns returned when a tool call includes no `fields` argument. Also automatically included when a `schema_file` is configured, even if the file omits them.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
config.default_fields = [:id, :name, :created_at, :updated_at]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
### `allowed_models`
|
|
47
|
+
|
|
48
|
+
**Default:** `[]` (empty — all models accessible)
|
|
49
|
+
|
|
50
|
+
When non-empty, only the listed model names are accessible. Any other model name returns an error.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
config.allowed_models = %w[User Post Order]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Ignored when `schema_file` is set — the schema file's top-level keys serve as the allowlist.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
### `denied_models`
|
|
61
|
+
|
|
62
|
+
**Default:** `[]` (none denied)
|
|
63
|
+
|
|
64
|
+
Model names that are never accessible, regardless of `allowed_models`.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
config.denied_models = %w[AdminUser AuditLog]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Ignored when `schema_file` is set.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### `denied_columns`
|
|
75
|
+
|
|
76
|
+
**Default:** `[]` (none denied)
|
|
77
|
+
|
|
78
|
+
An array of exact strings and/or regexes. Any matching column is completely invisible across all models and all tools — it cannot appear in query results, conditions, fields, or order clauses.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
config.denied_columns = [
|
|
82
|
+
"password_digest",
|
|
83
|
+
"encrypted_password",
|
|
84
|
+
/token/i,
|
|
85
|
+
/secret/i,
|
|
86
|
+
/api_key/i
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`denied_columns` is applied after all other column resolution (schema file, `default_fields`). It always wins — a column listed in a schema file but matching a denied pattern is still denied.
|
|
91
|
+
|
|
92
|
+
See [Advanced Usage → Explicit column deny](advanced.md#explicit-column-deny) for details.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### `max_limit`
|
|
97
|
+
|
|
98
|
+
**Default:** `100`
|
|
99
|
+
|
|
100
|
+
Maximum number of records any `query_records` call can return. Client-supplied `limit` values are silently capped to this. Nil or zero limits also resolve to this value.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
config.max_limit = 50
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### `max_offset`
|
|
109
|
+
|
|
110
|
+
**Default:** `10_000`
|
|
111
|
+
|
|
112
|
+
Maximum `offset` value accepted by `query_records`. Unlike `max_limit`, exceeding this raises an error rather than silently clamping — a clamped offset would return the wrong page without telling the caller. Negative offsets are silently treated as `0`.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
config.max_offset = 5_000
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
### `scope`
|
|
121
|
+
|
|
122
|
+
**Default:** `"mcp"`
|
|
123
|
+
|
|
124
|
+
The OAuth scope that every Bearer token must include. Tokens with a valid signature and expiry but without this scope are rejected with `403 insufficient_scope`. This prevents tokens issued to other clients (mobile apps, webhooks, etc.) from reaching the MCP endpoint.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
config.scope = "mcp" # default
|
|
128
|
+
config.scope = "admin:mcp" # custom scope name
|
|
129
|
+
config.scope = nil # disable scope check entirely
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Your Doorkeeper config must declare the scope you choose:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
Doorkeeper.configure do
|
|
136
|
+
optional_scopes :mcp
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### `schema_file`
|
|
143
|
+
|
|
144
|
+
**Default:** `nil`
|
|
145
|
+
|
|
146
|
+
Path to a YAML file that defines exactly which models and columns the MCP can access. When set, it replaces `allowed_models` / `denied_models` with the file's model list.
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
config.schema_file = Rails.root.join("config/rails_mcp.yml")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
See [Advanced Usage → YAML model allowlist](advanced.md#yaml-model-allowlist) for the file format.
|