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
data/docs/querying.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Querying
|
|
2
|
+
|
|
3
|
+
activerecord-mcp exposes five built-in tools. All queries go through hash conditions validated against actual column names — no raw SQL is accepted at any layer.
|
|
4
|
+
|
|
5
|
+
## Default fields
|
|
6
|
+
|
|
7
|
+
Every query tool returns only `id`, `created_at`, and `updated_at` by default. Pass a `fields` array to retrieve additional columns. This default can be changed via [`configuration`](configuration.md).
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
### `list_models`
|
|
12
|
+
|
|
13
|
+
Lists all accessible ActiveRecord model names.
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"jsonrpc": "2.0",
|
|
18
|
+
"method": "tools/call",
|
|
19
|
+
"params": {
|
|
20
|
+
"name": "list_models",
|
|
21
|
+
"arguments": {}
|
|
22
|
+
},
|
|
23
|
+
"id": 1
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Response:
|
|
28
|
+
```json
|
|
29
|
+
["Order", "Post", "User"]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
### `describe_model`
|
|
35
|
+
|
|
36
|
+
Returns columns (name, type, nullability, default), and associations for a model.
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"name": "describe_model",
|
|
41
|
+
"arguments": { "model": "User" }
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Response:
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"model": "User",
|
|
49
|
+
"table": "users",
|
|
50
|
+
"primary_key": "id",
|
|
51
|
+
"columns": [
|
|
52
|
+
{ "name": "id", "type": "integer", "null": false, "default": null },
|
|
53
|
+
{ "name": "email", "type": "string", "null": false, "default": null },
|
|
54
|
+
{ "name": "created_at", "type": "datetime","null": false, "default": null }
|
|
55
|
+
],
|
|
56
|
+
"associations": [
|
|
57
|
+
{ "name": "posts", "macro": "has_many", "class_name": "Post" }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Denied columns are excluded from the output — they do not appear in `columns` at all.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### `query_records`
|
|
67
|
+
|
|
68
|
+
Queries records with hash conditions.
|
|
69
|
+
|
|
70
|
+
| Argument | Type | Required | Description |
|
|
71
|
+
|----------|------|----------|-------------|
|
|
72
|
+
| `model` | string | yes | Model class name, e.g. `"User"` |
|
|
73
|
+
| `conditions` | object | no | Column/value pairs for WHERE clause |
|
|
74
|
+
| `fields` | array of strings | no | Columns to return (defaults to id + timestamps) |
|
|
75
|
+
| `limit` | integer | no | Max records; silently capped at `max_limit` (default 100) |
|
|
76
|
+
| `offset` | integer | no | Records to skip; raises an error if it exceeds `max_offset` (default 10,000) |
|
|
77
|
+
| `order` | string | no | `"column_name ASC"` or `"column_name DESC"` |
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"name": "query_records",
|
|
82
|
+
"arguments": {
|
|
83
|
+
"model": "User",
|
|
84
|
+
"conditions": { "active": true },
|
|
85
|
+
"fields": ["id", "name", "email"],
|
|
86
|
+
"limit": 25,
|
|
87
|
+
"offset": 0,
|
|
88
|
+
"order": "created_at DESC"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Response:
|
|
94
|
+
```json
|
|
95
|
+
[
|
|
96
|
+
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
|
|
97
|
+
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
|
|
98
|
+
]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Condition values** must be scalars (`string`, `integer`, `float`, `boolean`, `null`) or arrays of scalars (for SQL `IN`). Hash values are rejected to prevent operator injection.
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{ "conditions": { "status": ["active", "pending"] } }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### `find_record`
|
|
110
|
+
|
|
111
|
+
Finds a single record by primary key.
|
|
112
|
+
|
|
113
|
+
| Argument | Type | Required | Description |
|
|
114
|
+
|----------|------|----------|-------------|
|
|
115
|
+
| `model` | string | yes | Model class name |
|
|
116
|
+
| `id` | integer | yes | Primary key value |
|
|
117
|
+
| `fields` | array of strings | no | Columns to return |
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"name": "find_record",
|
|
122
|
+
"arguments": {
|
|
123
|
+
"model": "User",
|
|
124
|
+
"id": 42,
|
|
125
|
+
"fields": ["name", "email", "created_at"]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Response:
|
|
131
|
+
```json
|
|
132
|
+
{ "name": "Alice", "email": "alice@example.com", "created_at": "2024-01-15T10:00:00.000Z" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Returns an error if the record does not exist.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `count_records`
|
|
140
|
+
|
|
141
|
+
Counts records matching hash conditions.
|
|
142
|
+
|
|
143
|
+
| Argument | Type | Required | Description |
|
|
144
|
+
|----------|------|----------|-------------|
|
|
145
|
+
| `model` | string | yes | Model class name |
|
|
146
|
+
| `conditions` | object | no | Column/value pairs for WHERE clause |
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"name": "count_records",
|
|
151
|
+
"arguments": {
|
|
152
|
+
"model": "Order",
|
|
153
|
+
"conditions": { "status": "pending" }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Response:
|
|
159
|
+
```json
|
|
160
|
+
{ "count": 17 }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Security model
|
|
166
|
+
|
|
167
|
+
- **Column validation** — every column in `fields`, `conditions`, and `order` is checked against the allowed column list before the query runs. Unknown or denied columns raise an error.
|
|
168
|
+
- **Value validation** — condition values must be scalars or arrays of scalars. Hash values (which could trigger Rails 7.1+ predicate operators) are rejected.
|
|
169
|
+
- **Quoted identifiers** — `SELECT` columns are quoted via Arel; `ORDER BY` columns are quoted via `quote_column_name`. No raw string interpolation reaches the DB.
|
|
170
|
+
- **Output strip** — denied columns are stripped from serialized output after the query, independent of the pre-query validation.
|
|
171
|
+
- **No raw SQL** — there is no `sql:` or `raw:` parameter on any tool.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module RailsMcp
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates an activerecord-mcp initializer in config/initializers"
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "initializer.rb", "config/initializers/rails_mcp.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsMcp.configure do |config|
|
|
4
|
+
# The database role used for every query.
|
|
5
|
+
# Queries run via ActiveRecord's connected_to(role:), so this maps directly
|
|
6
|
+
# to a role defined in your database.yml. The default :reading role works
|
|
7
|
+
# out of the box with Rails' standard replica setup. Set to :writing if
|
|
8
|
+
# your app uses a single database with no named roles.
|
|
9
|
+
#
|
|
10
|
+
# config.database_role = :reading
|
|
11
|
+
|
|
12
|
+
# Columns returned when no fields are specified in a tool call.
|
|
13
|
+
# These are also automatically included when a schema_file is configured,
|
|
14
|
+
# even if the file omits them.
|
|
15
|
+
#
|
|
16
|
+
# config.default_fields = [:id, :created_at, :updated_at]
|
|
17
|
+
|
|
18
|
+
# Allowlist of model names the MCP can access.
|
|
19
|
+
# When non-empty, any model not in this list returns an error.
|
|
20
|
+
# Ignored when schema_file is set — the file's model list takes precedence.
|
|
21
|
+
#
|
|
22
|
+
# config.allowed_models = %w[User Post Order]
|
|
23
|
+
|
|
24
|
+
# Denylist of model names that are never accessible, regardless of allowed_models.
|
|
25
|
+
# Ignored when schema_file is set.
|
|
26
|
+
#
|
|
27
|
+
# config.denied_models = %w[AdminUser AuditLog]
|
|
28
|
+
|
|
29
|
+
# Columns that are completely invisible across all models and all tools.
|
|
30
|
+
# Accepts exact strings and/or regexes. Matching columns cannot be returned,
|
|
31
|
+
# used in conditions, or used in order — even if they appear in schema_file.
|
|
32
|
+
# Applied as the final layer, so it always wins over every other config.
|
|
33
|
+
#
|
|
34
|
+
# config.denied_columns = [
|
|
35
|
+
# "password_digest",
|
|
36
|
+
# "encrypted_password",
|
|
37
|
+
# /token/i,
|
|
38
|
+
# /secret/i,
|
|
39
|
+
# /api_key/i,
|
|
40
|
+
# ]
|
|
41
|
+
|
|
42
|
+
# Maximum number of records a single query_records call can return.
|
|
43
|
+
# Client-supplied limit values are silently capped to this. Nil or zero
|
|
44
|
+
# limits also resolve to this value.
|
|
45
|
+
#
|
|
46
|
+
# config.max_limit = 100
|
|
47
|
+
|
|
48
|
+
# Maximum offset value accepted by query_records.
|
|
49
|
+
# Unlike max_limit, exceeding this raises an error rather than silently
|
|
50
|
+
# clamping — a clamped offset would return the wrong page without any
|
|
51
|
+
# indication to the caller.
|
|
52
|
+
#
|
|
53
|
+
# config.max_offset = 10_000
|
|
54
|
+
|
|
55
|
+
# Path to a YAML file that defines exactly which models and columns are
|
|
56
|
+
# accessible. When set, allowed_models and denied_models are ignored —
|
|
57
|
+
# the file's model list is the authoritative allowlist. id, created_at,
|
|
58
|
+
# and updated_at are still auto-included from default_fields even if
|
|
59
|
+
# omitted from the file. denied_columns still applies on top.
|
|
60
|
+
#
|
|
61
|
+
# config.schema_file = Rails.root.join("config/rails_mcp.yml")
|
|
62
|
+
|
|
63
|
+
# OAuth scope that every Bearer token must include. Tokens that are
|
|
64
|
+
# otherwise valid (not expired, not revoked) but lack this scope are
|
|
65
|
+
# rejected with 403 insufficient_scope. Set to nil to disable the check.
|
|
66
|
+
# Your Doorkeeper config must declare the same scope via optional_scopes.
|
|
67
|
+
#
|
|
68
|
+
# config.scope = "mcp"
|
|
69
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMcp
|
|
4
|
+
module Auth
|
|
5
|
+
class TokenValidator
|
|
6
|
+
WELL_KNOWN_PREFIX = "/.well-known/"
|
|
7
|
+
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
request = Rack::Request.new(env)
|
|
14
|
+
|
|
15
|
+
# CORS preflight and public discovery endpoints bypass auth
|
|
16
|
+
return @app.call(env) if request.options?
|
|
17
|
+
return @app.call(env) if request.path.start_with?(WELL_KNOWN_PREFIX)
|
|
18
|
+
|
|
19
|
+
token_string = extract_bearer_token(env)
|
|
20
|
+
return unauthorized("Bearer token required") if token_string.nil?
|
|
21
|
+
|
|
22
|
+
token = Doorkeeper::AccessToken.by_token(token_string)
|
|
23
|
+
return unauthorized("Invalid or expired token") if token.nil? || token.revoked? || token.expired?
|
|
24
|
+
|
|
25
|
+
required = RailsMcp.configuration.scope
|
|
26
|
+
return insufficient_scope(required) if required && !required.empty? && !token.scopes.include?(required)
|
|
27
|
+
|
|
28
|
+
env["rails_mcp.access_token"] = token
|
|
29
|
+
@app.call(env)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def extract_bearer_token(env)
|
|
35
|
+
auth = env["HTTP_AUTHORIZATION"]
|
|
36
|
+
return nil unless auth&.start_with?("Bearer ")
|
|
37
|
+
|
|
38
|
+
auth.delete_prefix("Bearer ").strip
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unauthorized(message)
|
|
42
|
+
body = { error: "invalid_token", error_description: message }.to_json
|
|
43
|
+
[
|
|
44
|
+
401,
|
|
45
|
+
{ "Content-Type" => "application/json", "WWW-Authenticate" => 'Bearer realm="activerecord-mcp"' },
|
|
46
|
+
[body]
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def insufficient_scope(scope)
|
|
51
|
+
body = { error: "insufficient_scope", error_description: "Token missing required scope: #{scope}" }.to_json
|
|
52
|
+
[
|
|
53
|
+
403,
|
|
54
|
+
{
|
|
55
|
+
"Content-Type" => "application/json",
|
|
56
|
+
"WWW-Authenticate" => "Bearer realm=\"activerecord-mcp\", error=\"insufficient_scope\", scope=\"#{scope}\""
|
|
57
|
+
},
|
|
58
|
+
[body]
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMcp
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :database_role,
|
|
6
|
+
:default_fields,
|
|
7
|
+
:allowed_models,
|
|
8
|
+
:denied_models,
|
|
9
|
+
:denied_columns,
|
|
10
|
+
:max_limit,
|
|
11
|
+
:max_offset,
|
|
12
|
+
:schema_file,
|
|
13
|
+
:scope
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@database_role = :reading
|
|
17
|
+
@default_fields = %i[id created_at updated_at]
|
|
18
|
+
@allowed_models = []
|
|
19
|
+
@denied_models = []
|
|
20
|
+
@denied_columns = []
|
|
21
|
+
@max_limit = 100
|
|
22
|
+
@max_offset = 10_000
|
|
23
|
+
@schema_file = nil
|
|
24
|
+
@scope = "mcp"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def column_denied?(name)
|
|
28
|
+
denied_columns.any? do |pattern|
|
|
29
|
+
pattern.is_a?(Regexp) ? pattern.match?(name.to_s) : pattern.to_s == name.to_s
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMcp
|
|
4
|
+
class WellKnownController < ActionController::API
|
|
5
|
+
def oauth_metadata
|
|
6
|
+
issuer = "#{request.scheme}://#{request.host_with_port}"
|
|
7
|
+
render json: {
|
|
8
|
+
issuer: issuer,
|
|
9
|
+
authorization_endpoint: "#{issuer}/oauth/authorize",
|
|
10
|
+
token_endpoint: "#{issuer}/oauth/token",
|
|
11
|
+
response_types_supported: ["code"],
|
|
12
|
+
grant_types_supported: ["authorization_code"],
|
|
13
|
+
code_challenge_methods_supported: ["S256"],
|
|
14
|
+
token_endpoint_auth_methods_supported: ["none"]
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMcp
|
|
4
|
+
module Database
|
|
5
|
+
# Single source of truth for which columns are visible for a given AR class.
|
|
6
|
+
# Applies schema_file allowlist, default_fields auto-include, and denied_columns
|
|
7
|
+
# in that order.
|
|
8
|
+
module ColumnPolicy
|
|
9
|
+
def self.allowed_for(klass)
|
|
10
|
+
schema = RailsMcp.schema_config
|
|
11
|
+
cols = if schema
|
|
12
|
+
auto = RailsMcp.configuration.default_fields.map(&:to_s) & klass.column_names
|
|
13
|
+
(schema.allowed_columns(klass.name) + auto).uniq
|
|
14
|
+
else
|
|
15
|
+
klass.column_names
|
|
16
|
+
end
|
|
17
|
+
cols.reject { |col| RailsMcp.configuration.column_denied?(col) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMcp
|
|
4
|
+
module Database
|
|
5
|
+
module ModelResolver
|
|
6
|
+
SAFE_CONSTANT_PATTERN = /\A[A-Z][A-Za-z0-9:]*\z/
|
|
7
|
+
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
class AccessDenied < Error; end
|
|
10
|
+
class UnknownModel < Error; end
|
|
11
|
+
|
|
12
|
+
def self.resolve(model_name)
|
|
13
|
+
klass = find_class!(model_name)
|
|
14
|
+
assert_accessible!(klass)
|
|
15
|
+
klass
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.all_accessible
|
|
19
|
+
eager_load_models!
|
|
20
|
+
ActiveRecord::Base.descendants
|
|
21
|
+
.reject(&:abstract_class?)
|
|
22
|
+
.select { |k| accessible?(k) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private_class_method def self.find_class!(name)
|
|
26
|
+
raise UnknownModel, "Invalid model name: #{name.inspect}" unless name.to_s.match?(SAFE_CONSTANT_PATTERN)
|
|
27
|
+
|
|
28
|
+
klass = name.to_s.safe_constantize
|
|
29
|
+
unless klass && klass < ActiveRecord::Base && !klass.abstract_class?
|
|
30
|
+
raise UnknownModel, "Unknown model: #{name}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
klass
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method def self.assert_accessible!(klass)
|
|
37
|
+
raise AccessDenied, "Model #{klass.name} is not accessible" unless accessible?(klass)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private_class_method def self.accessible?(klass)
|
|
41
|
+
# Schema file takes precedence over allowed_models/denied_models
|
|
42
|
+
schema = RailsMcp.schema_config
|
|
43
|
+
return schema.accessible?(klass.name) if schema
|
|
44
|
+
|
|
45
|
+
config = RailsMcp.configuration
|
|
46
|
+
name = klass.name
|
|
47
|
+
|
|
48
|
+
return false if config.denied_models.include?(name)
|
|
49
|
+
return true if config.allowed_models.empty?
|
|
50
|
+
|
|
51
|
+
config.allowed_models.include?(name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method def self.eager_load_models!
|
|
55
|
+
return unless defined?(Rails)
|
|
56
|
+
|
|
57
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
58
|
+
rescue StandardError
|
|
59
|
+
# best-effort in environments where eager load is not fully available
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsMcp
|
|
4
|
+
module Database
|
|
5
|
+
class QueryBuilder
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
ALLOWED_ORDER_DIRECTIONS = %w[ASC DESC].freeze
|
|
9
|
+
SCALAR_TYPES = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(klass, conditions: {}, fields: [], limit: nil, offset: 0, order: nil)
|
|
12
|
+
@klass = klass
|
|
13
|
+
@conditions = conditions.transform_keys(&:to_s)
|
|
14
|
+
@fields = Array(fields).map(&:to_s)
|
|
15
|
+
@limit = clamp_limit(limit)
|
|
16
|
+
@offset = [offset.to_i, 0].max
|
|
17
|
+
@order = order
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execute
|
|
21
|
+
validate_conditions!
|
|
22
|
+
validate_fields!
|
|
23
|
+
validate_order!
|
|
24
|
+
validate_offset!
|
|
25
|
+
|
|
26
|
+
scope = @klass.where(@conditions)
|
|
27
|
+
scope = scope.select(resolved_fields.map { |f| @klass.arel_table[f] })
|
|
28
|
+
scope = scope.limit(@limit)
|
|
29
|
+
scope = scope.offset(@offset) if @offset.positive?
|
|
30
|
+
scope = scope.order(safe_order_clause) if @order
|
|
31
|
+
|
|
32
|
+
scope.map { |record| serialize(record) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def column_names
|
|
38
|
+
@column_names ||= ColumnPolicy.allowed_for(@klass)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolved_fields
|
|
42
|
+
return @fields unless @fields.empty?
|
|
43
|
+
|
|
44
|
+
RailsMcp.configuration.default_fields.map(&:to_s) & column_names
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clamp_limit(limit)
|
|
48
|
+
max = RailsMcp.configuration.max_limit
|
|
49
|
+
return max if limit.nil?
|
|
50
|
+
|
|
51
|
+
[limit.to_i, max].min.then { |n| n.positive? ? n : max }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_conditions!
|
|
55
|
+
unknown = @conditions.keys - column_names
|
|
56
|
+
raise Error, "Unknown column(s) in conditions: #{unknown.join(", ")}" if unknown.any?
|
|
57
|
+
|
|
58
|
+
invalid = @conditions.reject { |_, v| valid_condition_value?(v) }
|
|
59
|
+
return unless invalid.any?
|
|
60
|
+
|
|
61
|
+
raise Error,
|
|
62
|
+
"Invalid condition value(s) for: #{invalid.keys.join(", ")} (scalars and arrays only)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_fields!
|
|
66
|
+
unknown = @fields - column_names
|
|
67
|
+
raise Error, "Unknown field(s): #{unknown.join(", ")}" if unknown.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_offset!
|
|
71
|
+
max = RailsMcp.configuration.max_offset
|
|
72
|
+
raise Error, "Offset #{@offset} exceeds maximum allowed offset of #{max}" if @offset > max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_order!
|
|
76
|
+
return unless @order
|
|
77
|
+
|
|
78
|
+
col, dir = @order.to_s.strip.split(/\s+/, 2)
|
|
79
|
+
raise Error, "Unknown order column: #{col}" unless column_names.include?(col)
|
|
80
|
+
|
|
81
|
+
return unless dir && !ALLOWED_ORDER_DIRECTIONS.include?(dir.upcase)
|
|
82
|
+
|
|
83
|
+
raise Error, "Invalid order direction: #{dir}. Use ASC or DESC"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def safe_order_clause
|
|
87
|
+
col, dir = @order.to_s.strip.split(/\s+/, 2)
|
|
88
|
+
dir = dir&.upcase == "DESC" ? "DESC" : "ASC"
|
|
89
|
+
quoted_col = @klass.connection.quote_column_name(col)
|
|
90
|
+
Arel.sql("#{quoted_col} #{dir}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def serialize(record)
|
|
94
|
+
resolved_fields
|
|
95
|
+
.select { |field| column_names.include?(field) }
|
|
96
|
+
.reject { |field| RailsMcp.configuration.column_denied?(field) }
|
|
97
|
+
.to_h { |field| [field, record.public_send(field)] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def valid_condition_value?(value)
|
|
101
|
+
if value.is_a?(Array)
|
|
102
|
+
value.all? { |v| SCALAR_TYPES.any? { |t| v.is_a?(t) } }
|
|
103
|
+
else
|
|
104
|
+
SCALAR_TYPES.any? { |t| value.is_a?(t) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "doorkeeper"
|
|
5
|
+
|
|
6
|
+
module RailsMcp
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace RailsMcp
|
|
9
|
+
|
|
10
|
+
generators do
|
|
11
|
+
require "generators/rails_mcp/install/install_generator"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
config.middleware.use RailsMcp::Auth::TokenValidator
|
|
15
|
+
|
|
16
|
+
initializer "rails_mcp.doorkeeper_pkce_check" do
|
|
17
|
+
ActiveSupport.on_load(:after_initialize) do
|
|
18
|
+
next unless defined?(Doorkeeper)
|
|
19
|
+
|
|
20
|
+
methods = Doorkeeper.configuration.pkce_code_challenge_methods
|
|
21
|
+
unless Array(methods).include?("S256")
|
|
22
|
+
Rails.logger.warn "[activerecord-mcp] Doorkeeper PKCE S256 is not enabled. " \
|
|
23
|
+
"Add `pkce_code_challenge_methods %w[S256]` to your Doorkeeper config."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RailsMcp
|
|
6
|
+
class SchemaConfig
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
def initialize(path)
|
|
10
|
+
@path = path.to_s
|
|
11
|
+
@data = load_yaml!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def accessible?(model_name)
|
|
15
|
+
@data.key?(model_name.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def model_names
|
|
19
|
+
@data.keys
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns the column allowlist for a model, or [] if model is not in schema.
|
|
23
|
+
def allowed_columns(model_name)
|
|
24
|
+
Array(@data[model_name.to_s]).map(&:to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def load_yaml!
|
|
30
|
+
raise Error, "Schema file not found: #{@path}" unless File.exist?(@path)
|
|
31
|
+
|
|
32
|
+
data = YAML.safe_load_file(@path)
|
|
33
|
+
raise Error, "Schema file must contain a YAML mapping" unless data.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
validate!(data)
|
|
36
|
+
data
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate!(data)
|
|
40
|
+
data.each do |model_name, columns|
|
|
41
|
+
unless model_name.is_a?(String) && model_name.match?(/\A[A-Z][A-Za-z0-9:]*\z/)
|
|
42
|
+
raise Error, "Invalid model name in schema: #{model_name.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless columns.is_a?(Array) && columns.all?(String)
|
|
46
|
+
raise Error, "Columns for #{model_name} must be an array of strings"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|