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,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class TokenValidatorTest < ActiveSupport::TestCase
|
|
6
|
+
def app
|
|
7
|
+
inner = ->(_env) { [200, { "Content-Type" => "application/json" }, ["ok"]] }
|
|
8
|
+
RailsMcp::Auth::TokenValidator.new(inner)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
test "allows OPTIONS requests without token" do
|
|
12
|
+
status, = app.call(env("OPTIONS", "/mcp"))
|
|
13
|
+
assert_equal 200, status
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test "allows /.well-known/ without token" do
|
|
17
|
+
status, = app.call(env("GET", "/.well-known/oauth-authorization-server"))
|
|
18
|
+
assert_equal 200, status
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test "rejects request with no Authorization header" do
|
|
22
|
+
status, _, body = app.call(env("POST", "/mcp"))
|
|
23
|
+
assert_equal 401, status
|
|
24
|
+
assert_match "Bearer token required", body.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test "rejects invalid token" do
|
|
28
|
+
status, _, body = app.call(env("POST", "/mcp", token: "bogus"))
|
|
29
|
+
assert_equal 401, status
|
|
30
|
+
assert_match "Invalid or expired token", body.join
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
test "rejects expired token" do
|
|
34
|
+
token = create_valid_token(expires_in: -1)
|
|
35
|
+
status, = app.call(env("POST", "/mcp", token: token.token))
|
|
36
|
+
assert_equal 401, status
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
test "rejects revoked token" do
|
|
40
|
+
token = create_valid_token
|
|
41
|
+
token.revoke
|
|
42
|
+
status, = app.call(env("POST", "/mcp", token: token.token))
|
|
43
|
+
assert_equal 401, status
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "passes valid token through and sets env key" do
|
|
47
|
+
token = create_valid_token
|
|
48
|
+
rack_env = env("POST", "/mcp", token: token.token)
|
|
49
|
+
status, = app.call(rack_env)
|
|
50
|
+
assert_equal 200, status
|
|
51
|
+
assert_equal token.id, rack_env["rails_mcp.access_token"].id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
test "rejects token missing required scope" do
|
|
55
|
+
token = create_valid_token(scopes: "")
|
|
56
|
+
status, _, body = app.call(env("POST", "/mcp", token: token.token))
|
|
57
|
+
assert_equal 403, status
|
|
58
|
+
assert_match "insufficient_scope", body.join
|
|
59
|
+
assert_match "mcp", body.join
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
test "rejects token with different scope" do
|
|
63
|
+
token = create_valid_token(scopes: "read")
|
|
64
|
+
status, headers, = app.call(env("POST", "/mcp", token: token.token))
|
|
65
|
+
assert_equal 403, status
|
|
66
|
+
assert_match "mcp", headers["WWW-Authenticate"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
test "scope check skipped when scope config is nil" do
|
|
70
|
+
RailsMcp.configuration.scope = nil
|
|
71
|
+
token = create_valid_token(scopes: "")
|
|
72
|
+
status, = app.call(env("POST", "/mcp", token: token.token))
|
|
73
|
+
assert_equal 200, status
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test "scope check skipped when scope config is empty string" do
|
|
77
|
+
RailsMcp.configuration.scope = ""
|
|
78
|
+
token = create_valid_token(scopes: "")
|
|
79
|
+
status, = app.call(env("POST", "/mcp", token: token.token))
|
|
80
|
+
assert_equal 200, status
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def env(method, path, token: nil)
|
|
86
|
+
e = Rack::MockRequest.env_for(path, method: method)
|
|
87
|
+
e["HTTP_AUTHORIZATION"] = "Bearer #{token}" if token
|
|
88
|
+
e
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def create_valid_token(expires_in: 3600, scopes: "mcp")
|
|
92
|
+
app_record = Doorkeeper::Application.create!(
|
|
93
|
+
name: "test-#{SecureRandom.hex(4)}",
|
|
94
|
+
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
|
|
95
|
+
confidential: false,
|
|
96
|
+
scopes: "mcp read"
|
|
97
|
+
)
|
|
98
|
+
Doorkeeper::AccessToken.create!(application: app_record, expires_in: expires_in, scopes: scopes)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class DeniedColumnsTest < ActiveSupport::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
User.create!(name: "Alice", email: "alice@example.com", age: 30)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
teardown do
|
|
11
|
+
User.delete_all
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# --- exact string match ---
|
|
15
|
+
|
|
16
|
+
test "denied column by exact string cannot be used in fields" do
|
|
17
|
+
RailsMcp.configuration.denied_columns = ["age"]
|
|
18
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
19
|
+
build(User, fields: ["age"]).execute
|
|
20
|
+
end
|
|
21
|
+
assert_match "Unknown field(s)", err.message
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
test "denied column by exact string cannot be used in conditions" do
|
|
25
|
+
RailsMcp.configuration.denied_columns = ["age"]
|
|
26
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
27
|
+
build(User, conditions: { "age" => 30 }).execute
|
|
28
|
+
end
|
|
29
|
+
assert_match "Unknown column(s) in conditions", err.message
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "denied column by exact string cannot be used in order" do
|
|
33
|
+
RailsMcp.configuration.denied_columns = ["age"]
|
|
34
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
35
|
+
build(User, order: "age DESC").execute
|
|
36
|
+
end
|
|
37
|
+
assert_match "Unknown order column", err.message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test "denied column does not appear in default results" do
|
|
41
|
+
RailsMcp.configuration.denied_columns = ["age"]
|
|
42
|
+
RailsMcp.configuration.default_fields = %i[id age created_at]
|
|
43
|
+
results = build(User).execute
|
|
44
|
+
refute results.first.key?("age")
|
|
45
|
+
assert results.first.key?("id")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- regex match ---
|
|
49
|
+
|
|
50
|
+
test "denied column by regex cannot be used in fields" do
|
|
51
|
+
RailsMcp.configuration.denied_columns = [/password/i]
|
|
52
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
53
|
+
build(User, fields: ["password_digest"]).execute
|
|
54
|
+
end
|
|
55
|
+
assert_match "Unknown field(s)", err.message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test "regex matches multiple columns" do
|
|
59
|
+
RailsMcp.configuration.denied_columns = [/\Aactive\z/]
|
|
60
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
61
|
+
build(User, fields: ["active"]).execute
|
|
62
|
+
end
|
|
63
|
+
assert_match "Unknown field(s)", err.message
|
|
64
|
+
# other columns still work
|
|
65
|
+
results = build(User, fields: ["name"]).execute
|
|
66
|
+
assert results.first.key?("name")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --- mix of strings and regexes ---
|
|
70
|
+
|
|
71
|
+
test "accepts a mix of strings and regexes" do
|
|
72
|
+
RailsMcp.configuration.denied_columns = ["age", /email/i]
|
|
73
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
74
|
+
build(User, fields: ["age"]).execute
|
|
75
|
+
end
|
|
76
|
+
assert_match "Unknown field(s)", err.message
|
|
77
|
+
|
|
78
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
79
|
+
build(User, fields: ["email"]).execute
|
|
80
|
+
end
|
|
81
|
+
assert_match "Unknown field(s)", err.message
|
|
82
|
+
|
|
83
|
+
# non-denied columns still work
|
|
84
|
+
results = build(User, fields: ["name"]).execute
|
|
85
|
+
assert results.first.key?("name")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --- interaction with schema_file ---
|
|
89
|
+
|
|
90
|
+
test "denied_columns applies on top of schema_file" do
|
|
91
|
+
fixture = File.expand_path("../../fixtures/rails_mcp.yml", __dir__)
|
|
92
|
+
RailsMcp.configure do |c|
|
|
93
|
+
c.schema_file = fixture
|
|
94
|
+
c.denied_columns = ["email"] # email is in the schema but denied here
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
98
|
+
build(User, fields: ["email"]).execute
|
|
99
|
+
end
|
|
100
|
+
assert_match "Unknown field(s)", err.message
|
|
101
|
+
|
|
102
|
+
# name is still accessible
|
|
103
|
+
results = build(User, fields: ["name"]).execute
|
|
104
|
+
assert results.first.key?("name")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- describe_model does not leak denied columns ---
|
|
108
|
+
|
|
109
|
+
test "describe_model hides denied columns from schema output" do
|
|
110
|
+
RailsMcp.configuration.denied_columns = [/age/]
|
|
111
|
+
response = RailsMcp::Tools::DescribeModel.call(model: "User", server_context: {})
|
|
112
|
+
result = JSON.parse(response.content.first[:text])
|
|
113
|
+
col_names = result["columns"].map { |c| c["name"] }
|
|
114
|
+
refute_includes col_names, "age"
|
|
115
|
+
assert_includes col_names, "name"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
test "describe_model hides denied columns matched by regex" do
|
|
119
|
+
RailsMcp.configuration.denied_columns = [/email/i]
|
|
120
|
+
response = RailsMcp::Tools::DescribeModel.call(model: "User", server_context: {})
|
|
121
|
+
result = JSON.parse(response.content.first[:text])
|
|
122
|
+
col_names = result["columns"].map { |c| c["name"] }
|
|
123
|
+
refute_includes col_names, "email"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- count oracle blocked ---
|
|
127
|
+
|
|
128
|
+
test "count_records cannot use denied column as condition" do
|
|
129
|
+
RailsMcp.configuration.denied_columns = ["email"]
|
|
130
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
131
|
+
RailsMcp::Tools::CountRecords.call(
|
|
132
|
+
model: "User",
|
|
133
|
+
conditions: { "email" => "alice@example.com" },
|
|
134
|
+
server_context: {}
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
assert_match "Unknown column(s)", err.message
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# --- non-denied columns are unaffected ---
|
|
141
|
+
|
|
142
|
+
test "non-denied columns are still accessible" do
|
|
143
|
+
RailsMcp.configuration.denied_columns = ["age"]
|
|
144
|
+
results = build(User, fields: %w[name email]).execute
|
|
145
|
+
assert results.first.key?("name")
|
|
146
|
+
assert results.first.key?("email")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def build(klass, **opts)
|
|
152
|
+
RailsMcp::Database::QueryBuilder.new(klass, **opts)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class ModelResolverTest < ActiveSupport::TestCase
|
|
6
|
+
test "resolves a valid model name" do
|
|
7
|
+
assert_equal User, RailsMcp::Database::ModelResolver.resolve("User")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
test "raises UnknownModel for non-existent constant" do
|
|
11
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
12
|
+
RailsMcp::Database::ModelResolver.resolve("Nonexistent")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test "raises UnknownModel for non-AR class" do
|
|
17
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
18
|
+
RailsMcp::Database::ModelResolver.resolve("String")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
test "raises UnknownModel for path traversal attempt" do
|
|
23
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
24
|
+
RailsMcp::Database::ModelResolver.resolve("../../etc/passwd")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
test "raises UnknownModel for lowercase name" do
|
|
29
|
+
assert_raises(RailsMcp::Database::ModelResolver::UnknownModel) do
|
|
30
|
+
RailsMcp::Database::ModelResolver.resolve("user")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
test "denied_models blocks access" do
|
|
35
|
+
RailsMcp.configuration.denied_models = ["User"]
|
|
36
|
+
assert_raises(RailsMcp::Database::ModelResolver::AccessDenied) do
|
|
37
|
+
RailsMcp::Database::ModelResolver.resolve("User")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
test "allowed_models restricts access" do
|
|
42
|
+
RailsMcp.configuration.allowed_models = ["Post"]
|
|
43
|
+
assert_raises(RailsMcp::Database::ModelResolver::AccessDenied) do
|
|
44
|
+
RailsMcp::Database::ModelResolver.resolve("User")
|
|
45
|
+
end
|
|
46
|
+
assert_equal Post, RailsMcp::Database::ModelResolver.resolve("Post")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
test "empty allowed_models permits all" do
|
|
50
|
+
RailsMcp.configuration.allowed_models = []
|
|
51
|
+
assert_equal User, RailsMcp::Database::ModelResolver.resolve("User")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
test "all_accessible returns AR models respecting config" do
|
|
55
|
+
RailsMcp.configuration.denied_models = ["Post"]
|
|
56
|
+
models = RailsMcp::Database::ModelResolver.all_accessible.map(&:name)
|
|
57
|
+
assert_includes models, "User"
|
|
58
|
+
refute_includes models, "Post"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class QueryBuilderTest < ActiveSupport::TestCase
|
|
6
|
+
setup do
|
|
7
|
+
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 when none specified" do
|
|
12
|
+
results = build(User).execute
|
|
13
|
+
assert_equal %w[id created_at updated_at].sort, results.first.keys.sort
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test "returns requested fields" do
|
|
17
|
+
results = build(User, fields: %w[name email]).execute
|
|
18
|
+
assert_equal %w[email name], results.first.keys.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
test "filters by conditions" do
|
|
22
|
+
results = build(User, conditions: { "active" => true }, fields: ["name"]).execute
|
|
23
|
+
assert_equal 1, results.length
|
|
24
|
+
assert_equal "Alice", results.first["name"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test "respects limit" do
|
|
28
|
+
results = build(User, limit: 1).execute
|
|
29
|
+
assert_equal 1, results.length
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
test "caps limit at max_limit" do
|
|
33
|
+
RailsMcp.configuration.max_limit = 1
|
|
34
|
+
results = build(User, limit: 999).execute
|
|
35
|
+
assert_equal 1, results.length
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
test "respects offset" do
|
|
39
|
+
all = build(User, fields: ["name"], limit: 10).execute
|
|
40
|
+
offset = build(User, fields: ["name"], limit: 10, offset: 1).execute
|
|
41
|
+
assert_equal all.length - 1, offset.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
test "orders results" do
|
|
45
|
+
results = build(User, fields: ["name"], order: "name ASC").execute
|
|
46
|
+
assert_equal "Alice", results.first["name"]
|
|
47
|
+
|
|
48
|
+
results = build(User, fields: ["name"], order: "name DESC").execute
|
|
49
|
+
assert_equal "Bob", results.first["name"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
test "raises on unknown condition column" do
|
|
53
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
54
|
+
build(User, conditions: { "nonexistent" => 1 }).execute
|
|
55
|
+
end
|
|
56
|
+
assert_match "Unknown column(s) in conditions", err.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
test "raises on unknown field" do
|
|
60
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
61
|
+
build(User, fields: ["nonexistent"]).execute
|
|
62
|
+
end
|
|
63
|
+
assert_match "Unknown field(s)", err.message
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
test "raises on unknown order column" do
|
|
67
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
68
|
+
build(User, order: "nonexistent DESC").execute
|
|
69
|
+
end
|
|
70
|
+
assert_match "Unknown order column", err.message
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
test "raises on invalid order direction" do
|
|
74
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
75
|
+
build(User, order: "name DROPTABLE").execute
|
|
76
|
+
end
|
|
77
|
+
assert_match "Invalid order direction", err.message
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
test "SQL injection in order column is rejected" do
|
|
81
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
82
|
+
build(User, order: "name; DROP TABLE users").execute
|
|
83
|
+
end
|
|
84
|
+
# "name;" is not a valid column name, so the column check fires first
|
|
85
|
+
assert_match(/Unknown order column/, err.message)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
test "hash value in conditions is rejected" do
|
|
89
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
90
|
+
build(User, conditions: { "name" => { "starts_with" => "Al" } }).execute
|
|
91
|
+
end
|
|
92
|
+
assert_match "Invalid condition value(s)", err.message
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
test "array of scalars in conditions is accepted" do
|
|
96
|
+
results = build(User, conditions: { "name" => %w[Alice Bob] }, fields: ["name"]).execute
|
|
97
|
+
assert_equal 2, results.length
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
test "raises when offset exceeds max_offset" do
|
|
101
|
+
RailsMcp.configuration.max_offset = 500
|
|
102
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
103
|
+
build(User, offset: 501).execute
|
|
104
|
+
end
|
|
105
|
+
assert_match "exceeds maximum allowed offset", err.message
|
|
106
|
+
assert_match "500", err.message
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
test "offset at exactly max_offset is accepted" do
|
|
110
|
+
RailsMcp.configuration.max_offset = 500
|
|
111
|
+
assert_nothing_raised { build(User, offset: 500).execute }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
test "negative offset is treated as zero" do
|
|
115
|
+
results_zero = build(User, fields: ["name"]).execute
|
|
116
|
+
results_negative = build(User, fields: ["name"], offset: -10).execute
|
|
117
|
+
assert_equal results_zero, results_negative
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
test "array containing hash in conditions is rejected" do
|
|
121
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
122
|
+
build(User, conditions: { "name" => [{ "starts_with" => "Al" }] }).execute
|
|
123
|
+
end
|
|
124
|
+
assert_match "Invalid condition value(s)", err.message
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def build(klass, **opts)
|
|
130
|
+
RailsMcp::Database::QueryBuilder.new(klass, **opts)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "rails/generators/test_case"
|
|
5
|
+
require "generators/rails_mcp/install/install_generator"
|
|
6
|
+
|
|
7
|
+
class InstallGeneratorTest < Rails::Generators::TestCase
|
|
8
|
+
tests RailsMcp::Generators::InstallGenerator
|
|
9
|
+
destination File.expand_path("../../tmp/generator_output", __dir__)
|
|
10
|
+
setup :prepare_destination
|
|
11
|
+
|
|
12
|
+
test "creates the initializer file" do
|
|
13
|
+
run_generator
|
|
14
|
+
assert_file "config/initializers/rails_mcp.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
test "initializer contains all config keys commented out" do
|
|
18
|
+
run_generator
|
|
19
|
+
assert_file "config/initializers/rails_mcp.rb" do |content|
|
|
20
|
+
%w[
|
|
21
|
+
database_role
|
|
22
|
+
default_fields
|
|
23
|
+
allowed_models
|
|
24
|
+
denied_models
|
|
25
|
+
denied_columns
|
|
26
|
+
max_limit
|
|
27
|
+
max_offset
|
|
28
|
+
schema_file
|
|
29
|
+
scope
|
|
30
|
+
].each do |key|
|
|
31
|
+
assert_match(/#\s*config\.#{key}/, content, "Expected #{key} to be present and commented out")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
test "initializer wraps config in RailsMcp.configure block" do
|
|
37
|
+
run_generator
|
|
38
|
+
assert_file "config/initializers/rails_mcp.rb" do |content|
|
|
39
|
+
assert_match "RailsMcp.configure do |config|", content
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test "does not overwrite an existing initializer by default" do
|
|
44
|
+
FileUtils.mkdir_p File.join(destination_root, "config/initializers")
|
|
45
|
+
File.write(File.join(destination_root, "config/initializers/rails_mcp.rb"), "# existing content")
|
|
46
|
+
|
|
47
|
+
run_generator [], behavior: :skip
|
|
48
|
+
assert_file "config/initializers/rails_mcp.rb" do |content|
|
|
49
|
+
assert_match "# existing content", content
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
class SchemaConfigTest < ActiveSupport::TestCase
|
|
6
|
+
FIXTURE = File.expand_path("../fixtures/rails_mcp.yml", __dir__)
|
|
7
|
+
|
|
8
|
+
test "loads model names from YAML" do
|
|
9
|
+
schema = RailsMcp::SchemaConfig.new(FIXTURE)
|
|
10
|
+
assert_includes schema.model_names, "User"
|
|
11
|
+
assert_includes schema.model_names, "Post"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
test "accessible? returns true for listed models" do
|
|
15
|
+
schema = RailsMcp::SchemaConfig.new(FIXTURE)
|
|
16
|
+
assert schema.accessible?("User")
|
|
17
|
+
assert schema.accessible?("Post")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
test "accessible? returns false for unlisted models" do
|
|
21
|
+
schema = RailsMcp::SchemaConfig.new(FIXTURE)
|
|
22
|
+
refute schema.accessible?("Order")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
test "allowed_columns returns columns for a model" do
|
|
26
|
+
schema = RailsMcp::SchemaConfig.new(FIXTURE)
|
|
27
|
+
assert_equal %w[name email], schema.allowed_columns("User")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
test "allowed_columns returns empty array for unknown model" do
|
|
31
|
+
schema = RailsMcp::SchemaConfig.new(FIXTURE)
|
|
32
|
+
assert_equal [], schema.allowed_columns("Ghost")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test "raises when file does not exist" do
|
|
36
|
+
assert_raises(RailsMcp::SchemaConfig::Error) do
|
|
37
|
+
RailsMcp::SchemaConfig.new("/nonexistent/path.yml")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
test "raises when file is not a mapping" do
|
|
42
|
+
Tempfile.create(["schema", ".yml"]) do |f|
|
|
43
|
+
f.write("- just_a_list\n")
|
|
44
|
+
f.flush
|
|
45
|
+
assert_raises(RailsMcp::SchemaConfig::Error) do
|
|
46
|
+
RailsMcp::SchemaConfig.new(f.path)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test "raises when model name is invalid" do
|
|
52
|
+
Tempfile.create(["schema", ".yml"]) do |f|
|
|
53
|
+
f.write("lowercase:\n - id\n")
|
|
54
|
+
f.flush
|
|
55
|
+
assert_raises(RailsMcp::SchemaConfig::Error) do
|
|
56
|
+
RailsMcp::SchemaConfig.new(f.path)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
test "raises when columns are not an array of strings" do
|
|
62
|
+
Tempfile.create(["schema", ".yml"]) do |f|
|
|
63
|
+
f.write("User: not_an_array\n")
|
|
64
|
+
f.flush
|
|
65
|
+
assert_raises(RailsMcp::SchemaConfig::Error) do
|
|
66
|
+
RailsMcp::SchemaConfig.new(f.path)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class SchemaConfigIntegrationTest < ActiveSupport::TestCase
|
|
73
|
+
FIXTURE = File.expand_path("../fixtures/rails_mcp.yml", __dir__)
|
|
74
|
+
|
|
75
|
+
setup do
|
|
76
|
+
RailsMcp.configure { |c| c.schema_file = FIXTURE }
|
|
77
|
+
User.create!(name: "Alice", email: "alice@example.com")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
teardown do
|
|
81
|
+
User.delete_all
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
test "ModelResolver only exposes listed models" do
|
|
85
|
+
assert_equal User, RailsMcp::Database::ModelResolver.resolve("User")
|
|
86
|
+
assert_raises(RailsMcp::Database::ModelResolver::AccessDenied) do
|
|
87
|
+
# Doorkeeper models are not in the schema
|
|
88
|
+
RailsMcp::Database::ModelResolver.resolve("Doorkeeper::Application")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
test "ModelResolver.all_accessible respects schema" do
|
|
93
|
+
names = RailsMcp::Database::ModelResolver.all_accessible.map(&:name)
|
|
94
|
+
assert_includes names, "User"
|
|
95
|
+
assert_includes names, "Post"
|
|
96
|
+
refute(names.any? { |n| n.start_with?("Doorkeeper") })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
test "id and timestamps are returned by default even when not listed in schema" do
|
|
100
|
+
results = RailsMcp::Database::QueryBuilder.new(User).execute
|
|
101
|
+
assert results.first.key?("id")
|
|
102
|
+
assert results.first.key?("created_at")
|
|
103
|
+
assert results.first.key?("updated_at")
|
|
104
|
+
refute results.first.key?("name")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
test "id and timestamps are queryable in conditions even when not listed in schema" do
|
|
108
|
+
user = User.first
|
|
109
|
+
results = RailsMcp::Database::QueryBuilder.new(User, conditions: { "id" => user.id },
|
|
110
|
+
fields: ["name"]).execute
|
|
111
|
+
assert_equal 1, results.length
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
test "QueryBuilder allows schema-listed columns" do
|
|
115
|
+
results = RailsMcp::Database::QueryBuilder.new(User, fields: ["name"]).execute
|
|
116
|
+
assert results.first.key?("name")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
test "QueryBuilder rejects columns not in schema and not in default_fields" do
|
|
120
|
+
# 'age' is on the User table but not in the schema or default_fields
|
|
121
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
122
|
+
RailsMcp::Database::QueryBuilder.new(User, fields: ["age"]).execute
|
|
123
|
+
end
|
|
124
|
+
assert_match "Unknown field(s)", err.message
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
test "QueryBuilder rejects conditions on columns not in schema or default_fields" do
|
|
128
|
+
err = assert_raises(RailsMcp::Database::QueryBuilder::Error) do
|
|
129
|
+
RailsMcp::Database::QueryBuilder.new(User, conditions: { "age" => 30 }).execute
|
|
130
|
+
end
|
|
131
|
+
assert_match "Unknown column(s) in conditions", err.message
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
test "schema_file takes precedence over allowed_models config" do
|
|
135
|
+
RailsMcp.configure do |c|
|
|
136
|
+
c.schema_file = FIXTURE
|
|
137
|
+
c.allowed_models = ["Post"] # would normally block User
|
|
138
|
+
end
|
|
139
|
+
# schema_file wins — User is accessible because it's in the YAML
|
|
140
|
+
assert_equal User, RailsMcp::Database::ModelResolver.resolve("User")
|
|
141
|
+
end
|
|
142
|
+
end
|