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,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: []