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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +51 -0
  3. data/CHANGELOG.md +30 -0
  4. data/LICENSE +21 -0
  5. data/README.md +133 -0
  6. data/Rakefile +16 -0
  7. data/config/routes.rb +6 -0
  8. data/docs/advanced.md +137 -0
  9. data/docs/authentication.md +122 -0
  10. data/docs/configuration.md +152 -0
  11. data/docs/querying.md +171 -0
  12. data/lib/generators/rails_mcp/install/install_generator.rb +17 -0
  13. data/lib/generators/rails_mcp/install/templates/initializer.rb +69 -0
  14. data/lib/rails_mcp/auth/token_validator.rb +63 -0
  15. data/lib/rails_mcp/configuration.rb +33 -0
  16. data/lib/rails_mcp/controllers/well_known_controller.rb +18 -0
  17. data/lib/rails_mcp/database/column_policy.rb +21 -0
  18. data/lib/rails_mcp/database/model_resolver.rb +63 -0
  19. data/lib/rails_mcp/database/query_builder.rb +109 -0
  20. data/lib/rails_mcp/database/role_proxy.rb +11 -0
  21. data/lib/rails_mcp/engine.rb +28 -0
  22. data/lib/rails_mcp/schema_config.rb +51 -0
  23. data/lib/rails_mcp/server.rb +51 -0
  24. data/lib/rails_mcp/tool_dsl.rb +52 -0
  25. data/lib/rails_mcp/tools/count_records.rb +51 -0
  26. data/lib/rails_mcp/tools/describe_model.rb +46 -0
  27. data/lib/rails_mcp/tools/find_record.rb +50 -0
  28. data/lib/rails_mcp/tools/list_models.rb +20 -0
  29. data/lib/rails_mcp/tools/query_records.rb +41 -0
  30. data/lib/rails_mcp/version.rb +5 -0
  31. data/lib/rails_mcp.rb +41 -0
  32. data/rails-mcp.gemspec +35 -0
  33. data/test/dummy/app/models/post.rb +6 -0
  34. data/test/dummy/app/models/user.rb +6 -0
  35. data/test/dummy/config/application.rb +18 -0
  36. data/test/dummy/config/database.yml +13 -0
  37. data/test/dummy/config/environment.rb +5 -0
  38. data/test/dummy/config/initializers/doorkeeper.rb +11 -0
  39. data/test/dummy/config/routes.rb +6 -0
  40. data/test/dummy/db/schema.rb +58 -0
  41. data/test/fixtures/rails_mcp.yml +5 -0
  42. data/test/test_helper.rb +47 -0
  43. data/test/tmp/generator_output/config/initializers/rails_mcp.rb +69 -0
  44. data/test/unit/auth/token_validator_test.rb +100 -0
  45. data/test/unit/database/denied_columns_test.rb +154 -0
  46. data/test/unit/database/model_resolver_test.rb +60 -0
  47. data/test/unit/database/query_builder_test.rb +132 -0
  48. data/test/unit/generators/install_generator_test.rb +52 -0
  49. data/test/unit/schema_config_test.rb +142 -0
  50. data/test/unit/tools/count_records_test.rb +57 -0
  51. data/test/unit/tools/describe_model_test.rb +38 -0
  52. data/test/unit/tools/find_record_test.rb +41 -0
  53. data/test/unit/tools/list_models_test.rb +21 -0
  54. data/test/unit/tools/query_records_test.rb +51 -0
  55. 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
+ [![CI](https://github.com/pauloancheta/rails-mcp/actions/workflows/main.yml/badge.svg)](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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsMcp::Engine.routes.draw do
4
+ mount RailsMcp::Server.transport => "/"
5
+ get "/.well-known/oauth-authorization-server", to: "rails_mcp/well_known#oauth_metadata"
6
+ end
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.