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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class CountRecordsToolTest < ActiveSupport::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
User.create!(name: "Alice", email: "alice@example.com", active: true)
|
|
8
|
+
User.create!(name: "Bob", email: "bob@example.com", active: false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
test "counts all records" do
|
|
12
|
+
response = call(model: "User")
|
|
13
|
+
result = JSON.parse(response.content.first[:text])
|
|
14
|
+
assert_equal 2, result["count"]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
test "counts with conditions" do
|
|
18
|
+
response = call(model: "User", conditions: { "active" => true })
|
|
19
|
+
result = JSON.parse(response.content.first[:text])
|
|
20
|
+
assert_equal 1, result["count"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test "raises on unknown condition column" do
|
|
24
|
+
assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
25
|
+
call(model: "User", conditions: { "nonexistent" => 1 })
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
test "raises on denied column in conditions" do
|
|
30
|
+
RailsMcp.configuration.denied_columns = ["email"]
|
|
31
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
32
|
+
call(model: "User", conditions: { "email" => "alice@example.com" })
|
|
33
|
+
end
|
|
34
|
+
assert_match "Unknown column(s)", err.message
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
test "raises on hash condition value" do
|
|
38
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
39
|
+
call(model: "User", conditions: { "name" => { "starts_with" => "Al" } })
|
|
40
|
+
end
|
|
41
|
+
assert_match "Invalid condition value(s)", err.message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
test "count oracle blocked for denied column" do
|
|
45
|
+
RailsMcp.configuration.denied_columns = [/password/i]
|
|
46
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
47
|
+
call(model: "User", conditions: { "password_digest" => "$2a$12$abc" })
|
|
48
|
+
end
|
|
49
|
+
assert_match "Unknown column(s)", err.message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def call(**args)
|
|
55
|
+
RailsMcp::Tools::CountRecords.call(server_context: {}, **args)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class DescribeModelToolTest < ActiveSupport::TestCase
|
|
6
|
+
test "returns schema info for a model" do
|
|
7
|
+
response = call(model: "User")
|
|
8
|
+
result = JSON.parse(response.content.first[:text])
|
|
9
|
+
|
|
10
|
+
assert_equal "User", result["model"]
|
|
11
|
+
assert_equal "users", result["table"]
|
|
12
|
+
assert_equal "id", result["primary_key"]
|
|
13
|
+
|
|
14
|
+
col_names = result["columns"].map { |c| c["name"] }
|
|
15
|
+
assert_includes col_names, "name"
|
|
16
|
+
assert_includes col_names, "email"
|
|
17
|
+
assert_includes col_names, "age"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
test "includes associations" do
|
|
21
|
+
response = call(model: "User")
|
|
22
|
+
result = JSON.parse(response.content.first[:text])
|
|
23
|
+
assoc_names = result["associations"].map { |a| a["name"] }
|
|
24
|
+
assert_includes assoc_names, "posts"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test "raises on unknown model" do
|
|
28
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
29
|
+
call(model: "Ghost")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def call(**args)
|
|
36
|
+
RailsMcp::Tools::DescribeModel.call(server_context: {}, **args)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class FindRecordToolTest < ActiveSupport::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
@user = User.create!(name: "Alice", email: "alice@example.com")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
test "finds record by id with default fields" do
|
|
11
|
+
response = call(model: "User", id: @user.id)
|
|
12
|
+
result = JSON.parse(response.content.first[:text])
|
|
13
|
+
assert_equal @user.id, result["id"]
|
|
14
|
+
refute result.key?("name")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
test "finds record with requested fields" do
|
|
18
|
+
response = call(model: "User", id: @user.id, fields: %w[name email])
|
|
19
|
+
result = JSON.parse(response.content.first[:text])
|
|
20
|
+
assert_equal "Alice", result["name"]
|
|
21
|
+
assert_equal "alice@example.com", result["email"]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
test "raises when record not found" do
|
|
25
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
26
|
+
call(model: "User", id: 999_999)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
test "raises on unknown field" do
|
|
31
|
+
assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
32
|
+
call(model: "User", id: @user.id, fields: ["nonexistent"])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def call(**args)
|
|
39
|
+
RailsMcp::Tools::FindRecord.call(server_context: {}, **args)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class ListModelsToolTest < ActiveSupport::TestCase
|
|
6
|
+
test "returns sorted accessible model names" do
|
|
7
|
+
response = RailsMcp::Tools::ListModels.call(server_context: {})
|
|
8
|
+
models = JSON.parse(response.content.first[:text])
|
|
9
|
+
assert_includes models, "User"
|
|
10
|
+
assert_includes models, "Post"
|
|
11
|
+
assert_equal models.sort, models
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
test "respects denied_models config" do
|
|
15
|
+
RailsMcp.configuration.denied_models = ["Post"]
|
|
16
|
+
response = RailsMcp::Tools::ListModels.call(server_context: {})
|
|
17
|
+
models = JSON.parse(response.content.first[:text])
|
|
18
|
+
refute_includes models, "Post"
|
|
19
|
+
assert_includes models, "User"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class QueryRecordsToolTest < ActiveSupport::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
@user = User.create!(name: "Alice", email: "alice@example.com", age: 30, active: true)
|
|
8
|
+
User.create!(name: "Bob", email: "bob@example.com", age: 25, active: false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
test "returns default fields" do
|
|
12
|
+
response = call(model: "User")
|
|
13
|
+
records = JSON.parse(response.content.first[:text])
|
|
14
|
+
assert records.first.key?("id")
|
|
15
|
+
assert records.first.key?("created_at")
|
|
16
|
+
refute records.first.key?("name")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
test "returns specified fields" do
|
|
20
|
+
response = call(model: "User", fields: %w[name email])
|
|
21
|
+
records = JSON.parse(response.content.first[:text])
|
|
22
|
+
assert records.first.key?("name")
|
|
23
|
+
assert records.first.key?("email")
|
|
24
|
+
refute records.first.key?("id")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test "filters by conditions" do
|
|
28
|
+
response = call(model: "User", conditions: { "active" => true }, fields: ["name"])
|
|
29
|
+
records = JSON.parse(response.content.first[:text])
|
|
30
|
+
assert_equal 1, records.length
|
|
31
|
+
assert_equal "Alice", records.first["name"]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
test "raises on unknown model" do
|
|
35
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
36
|
+
call(model: "Ghost")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "raises on SQL injection in order" do
|
|
41
|
+
assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
42
|
+
call(model: "User", order: "1=1; DROP TABLE users")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def call(**args)
|
|
49
|
+
RailsMcp::Tools::QueryRecords.call(server_context: {}, **args)
|
|
50
|
+
end
|
|
51
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-mcp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Paulo Ancheta
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: doorkeeper
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.6'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.6'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: mcp
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.17'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.17'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rails
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '7.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '7.0'
|
|
55
|
+
description: A Rails Engine that implements a Model Context Protocol (MCP) server
|
|
56
|
+
using HTTP-only Streamable HTTP transport. Provides built-in ActiveRecord query
|
|
57
|
+
tools with configurable database roles, field filtering, and OAuth 2.1 + PKCE auth
|
|
58
|
+
via Doorkeeper.
|
|
59
|
+
email:
|
|
60
|
+
- paulo.ancheta@gmail.com
|
|
61
|
+
executables: []
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- ".rubocop.yml"
|
|
66
|
+
- CHANGELOG.md
|
|
67
|
+
- LICENSE
|
|
68
|
+
- README.md
|
|
69
|
+
- Rakefile
|
|
70
|
+
- config/routes.rb
|
|
71
|
+
- docs/advanced.md
|
|
72
|
+
- docs/authentication.md
|
|
73
|
+
- docs/configuration.md
|
|
74
|
+
- docs/querying.md
|
|
75
|
+
- lib/generators/rails_mcp/install/install_generator.rb
|
|
76
|
+
- lib/generators/rails_mcp/install/templates/initializer.rb
|
|
77
|
+
- lib/rails_mcp.rb
|
|
78
|
+
- lib/rails_mcp/auth/token_validator.rb
|
|
79
|
+
- lib/rails_mcp/configuration.rb
|
|
80
|
+
- lib/rails_mcp/controllers/well_known_controller.rb
|
|
81
|
+
- lib/rails_mcp/database/column_policy.rb
|
|
82
|
+
- lib/rails_mcp/database/model_resolver.rb
|
|
83
|
+
- lib/rails_mcp/database/query_builder.rb
|
|
84
|
+
- lib/rails_mcp/database/role_proxy.rb
|
|
85
|
+
- lib/rails_mcp/engine.rb
|
|
86
|
+
- lib/rails_mcp/schema_config.rb
|
|
87
|
+
- lib/rails_mcp/server.rb
|
|
88
|
+
- lib/rails_mcp/tool_dsl.rb
|
|
89
|
+
- lib/rails_mcp/tools/count_records.rb
|
|
90
|
+
- lib/rails_mcp/tools/describe_model.rb
|
|
91
|
+
- lib/rails_mcp/tools/find_record.rb
|
|
92
|
+
- lib/rails_mcp/tools/list_models.rb
|
|
93
|
+
- lib/rails_mcp/tools/query_records.rb
|
|
94
|
+
- lib/rails_mcp/version.rb
|
|
95
|
+
- rails-mcp.gemspec
|
|
96
|
+
- test/dummy/app/models/post.rb
|
|
97
|
+
- test/dummy/app/models/user.rb
|
|
98
|
+
- test/dummy/config/application.rb
|
|
99
|
+
- test/dummy/config/database.yml
|
|
100
|
+
- test/dummy/config/environment.rb
|
|
101
|
+
- test/dummy/config/initializers/doorkeeper.rb
|
|
102
|
+
- test/dummy/config/routes.rb
|
|
103
|
+
- test/dummy/db/schema.rb
|
|
104
|
+
- test/fixtures/rails_mcp.yml
|
|
105
|
+
- test/test_helper.rb
|
|
106
|
+
- test/tmp/generator_output/config/initializers/rails_mcp.rb
|
|
107
|
+
- test/unit/auth/token_validator_test.rb
|
|
108
|
+
- test/unit/database/denied_columns_test.rb
|
|
109
|
+
- test/unit/database/model_resolver_test.rb
|
|
110
|
+
- test/unit/database/query_builder_test.rb
|
|
111
|
+
- test/unit/generators/install_generator_test.rb
|
|
112
|
+
- test/unit/schema_config_test.rb
|
|
113
|
+
- test/unit/tools/count_records_test.rb
|
|
114
|
+
- test/unit/tools/describe_model_test.rb
|
|
115
|
+
- test/unit/tools/find_record_test.rb
|
|
116
|
+
- test/unit/tools/list_models_test.rb
|
|
117
|
+
- test/unit/tools/query_records_test.rb
|
|
118
|
+
homepage: https://github.com/pauloancheta/rails-mcp
|
|
119
|
+
licenses:
|
|
120
|
+
- MIT
|
|
121
|
+
metadata:
|
|
122
|
+
homepage_uri: https://github.com/pauloancheta/rails-mcp
|
|
123
|
+
source_code_uri: https://github.com/pauloancheta/rails-mcp
|
|
124
|
+
changelog_uri: https://github.com/pauloancheta/rails-mcp/blob/main/CHANGELOG.md
|
|
125
|
+
rubygems_mfa_required: 'true'
|
|
126
|
+
post_install_message:
|
|
127
|
+
rdoc_options: []
|
|
128
|
+
require_paths:
|
|
129
|
+
- lib
|
|
130
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
131
|
+
requirements:
|
|
132
|
+
- - ">="
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: 3.1.0
|
|
135
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
136
|
+
requirements:
|
|
137
|
+
- - ">="
|
|
138
|
+
- !ruby/object:Gem::Version
|
|
139
|
+
version: '0'
|
|
140
|
+
requirements: []
|
|
141
|
+
rubygems_version: 3.4.20
|
|
142
|
+
signing_key:
|
|
143
|
+
specification_version: 4
|
|
144
|
+
summary: MCP server for Rails apps — safe, role-aware database query tools over Streamable
|
|
145
|
+
HTTP
|
|
146
|
+
test_files: []
|