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
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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMcp
4
+ module Database
5
+ module RoleProxy
6
+ def self.with_role(&)
7
+ ActiveRecord::Base.connected_to(role: RailsMcp.configuration.database_role, &)
8
+ end
9
+ end
10
+ end
11
+ 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