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
@@ -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