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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ module Server
7
+ class << self
8
+ def transport
9
+ @transport ||= build_transport
10
+ end
11
+
12
+ def tool(name, &)
13
+ dsl = ToolDSL.new(name)
14
+ dsl.instance_eval(&)
15
+ @custom_tools ||= []
16
+ @custom_tools << dsl.to_mcp_tool
17
+ end
18
+
19
+ def all_tools
20
+ built_in_tools + (@custom_tools || [])
21
+ end
22
+
23
+ # Allows tests and reloads to reset state
24
+ def reset!
25
+ @transport = nil
26
+ @custom_tools = nil
27
+ end
28
+
29
+ private
30
+
31
+ def built_in_tools
32
+ [
33
+ Tools::ListModels,
34
+ Tools::DescribeModel,
35
+ Tools::QueryRecords,
36
+ Tools::FindRecord,
37
+ Tools::CountRecords
38
+ ]
39
+ end
40
+
41
+ def build_transport
42
+ mcp_server = MCP::Server.new(
43
+ name: "activerecord-mcp",
44
+ version: RailsMcp::VERSION,
45
+ tools: all_tools
46
+ )
47
+ MCP::Server::Transports::StreamableHTTPTransport.new(mcp_server, stateless: true)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ class ToolDSL
7
+ def initialize(name)
8
+ @name = name.to_s
9
+ @description_text = nil
10
+ @parameters = []
11
+ @call_block = nil
12
+ end
13
+
14
+ def description(text)
15
+ @description_text = text
16
+ end
17
+
18
+ def parameter(name, type:, description: nil, required: false)
19
+ @parameters << { name: name.to_s, type: type.to_s, description: description, required: required }
20
+ end
21
+
22
+ def call(&block)
23
+ @call_block = block
24
+ end
25
+
26
+ def to_mcp_tool
27
+ name = @name
28
+ description = @description_text
29
+ parameters = @parameters
30
+ call_block = @call_block
31
+
32
+ properties = parameters.to_h do |p|
33
+ schema = { type: p[:type] }
34
+ schema[:description] = p[:description] if p[:description]
35
+ [p[:name].to_sym, schema]
36
+ end
37
+ required = parameters.select { |p| p[:required] }.map { |p| p[:name] }
38
+ schema = { properties: properties }
39
+ schema[:required] = required if required.any?
40
+
41
+ Class.new(MCP::Tool) do
42
+ tool_name name
43
+ description description
44
+ input_schema(**schema)
45
+
46
+ define_singleton_method(:call) do |server_context:, **args|
47
+ call_block.call(args, server_context)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ module Tools
7
+ class CountRecords < MCP::Tool
8
+ tool_name "count_records"
9
+ description "Count records matching hash conditions"
10
+ input_schema(
11
+ properties: {
12
+ model: { type: "string", description: "Model class name, e.g. \"User\"" },
13
+ conditions: { type: "object", description: "Hash of column => value pairs" }
14
+ },
15
+ required: ["model"]
16
+ )
17
+
18
+ SCALAR_TYPES = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
19
+
20
+ def self.call(model:, server_context:, conditions: {})
21
+ count = Database::RoleProxy.with_role do
22
+ klass = Database::ModelResolver.resolve(model)
23
+ conditions = (conditions || {}).transform_keys(&:to_s)
24
+ allowed = Database::ColumnPolicy.allowed_for(klass)
25
+
26
+ unknown = conditions.keys - allowed
27
+ raise Database::QueryBuilder::Error, "Unknown column(s): #{unknown.join(", ")}" if unknown.any?
28
+
29
+ invalid = conditions.reject { |_, v| valid_condition_value?(v) }
30
+ if invalid.any?
31
+ raise Database::QueryBuilder::Error,
32
+ "Invalid condition value(s) for: #{invalid.keys.join(", ")} (scalars and arrays only)"
33
+ end
34
+
35
+ klass.where(conditions).count
36
+ end
37
+ MCP::Tool::Response.new([{ type: "text", text: { count: count }.to_json }])
38
+ end
39
+
40
+ def self.valid_condition_value?(value)
41
+ if value.is_a?(Array)
42
+ value.all? { |v| SCALAR_TYPES.any? { |t| v.is_a?(t) } }
43
+ else
44
+ SCALAR_TYPES.any? { |t| value.is_a?(t) }
45
+ end
46
+ end
47
+
48
+ private_class_method :valid_condition_value?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ module Tools
7
+ class DescribeModel < MCP::Tool
8
+ tool_name "describe_model"
9
+ description "Return schema, columns, and associations for a model"
10
+ input_schema(
11
+ properties: {
12
+ model: { type: "string", description: "Model class name, e.g. \"User\"" }
13
+ },
14
+ required: ["model"]
15
+ )
16
+
17
+ def self.call(model:, server_context:)
18
+ result = Database::RoleProxy.with_role do
19
+ klass = Database::ModelResolver.resolve(model)
20
+ {
21
+ model: klass.name,
22
+ table: klass.table_name,
23
+ primary_key: klass.primary_key,
24
+ columns: column_info(klass),
25
+ associations: association_info(klass)
26
+ }
27
+ end
28
+ MCP::Tool::Response.new([{ type: "text", text: result.to_json }])
29
+ end
30
+
31
+ def self.column_info(klass)
32
+ klass.columns
33
+ .reject { |col| RailsMcp.configuration.column_denied?(col.name) }
34
+ .map { |col| { name: col.name, type: col.type.to_s, null: col.null, default: col.default } }
35
+ end
36
+
37
+ def self.association_info(klass)
38
+ klass.reflect_on_all_associations.map do |assoc|
39
+ { name: assoc.name.to_s, macro: assoc.macro.to_s, class_name: assoc.class_name }
40
+ end
41
+ end
42
+
43
+ private_class_method :column_info, :association_info
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ module Tools
7
+ class FindRecord < MCP::Tool
8
+ tool_name "find_record"
9
+ description "Find a single record by primary key"
10
+ input_schema(
11
+ properties: {
12
+ model: { type: "string", description: "Model class name, e.g. \"User\"" },
13
+ id: { type: "integer", description: "Primary key value" },
14
+ fields: { type: "array", description: "Columns to return. Defaults to [id, created_at, updated_at]",
15
+ items: { type: "string" } }
16
+ },
17
+ required: %w[model id]
18
+ )
19
+
20
+ def self.call(model:, id:, server_context:, fields: [])
21
+ result = Database::RoleProxy.with_role do
22
+ klass = Database::ModelResolver.resolve(model)
23
+ record = klass.find_by(klass.primary_key => id)
24
+
25
+ raise Database::ModelResolver::UnknownModel, "#{model} with id=#{id} not found" unless record
26
+
27
+ # Pre-query: resolve and validate fields against the same allowed set QueryBuilder uses
28
+ allowed = allowed_columns(klass)
29
+ resolved = Array(fields).map(&:to_s)
30
+ resolved = RailsMcp.configuration.default_fields.map(&:to_s) & allowed if resolved.empty?
31
+
32
+ unknown = resolved - allowed
33
+ raise Database::QueryBuilder::Error, "Unknown field(s): #{unknown.join(", ")}" if unknown.any?
34
+
35
+ # Post-query: strip denied columns from output regardless of how resolved was built
36
+ resolved
37
+ .reject { |f| RailsMcp.configuration.column_denied?(f) }
38
+ .to_h { |f| [f, record.public_send(f)] }
39
+ end
40
+ MCP::Tool::Response.new([{ type: "text", text: result.to_json }])
41
+ end
42
+
43
+ def self.allowed_columns(klass)
44
+ Database::ColumnPolicy.allowed_for(klass)
45
+ end
46
+
47
+ private_class_method :allowed_columns
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ module Tools
7
+ class ListModels < MCP::Tool
8
+ tool_name "list_models"
9
+ description "List all accessible ActiveRecord model classes"
10
+ input_schema(properties: {})
11
+
12
+ def self.call(server_context:)
13
+ models = Database::RoleProxy.with_role do
14
+ Database::ModelResolver.all_accessible.map(&:name).sort
15
+ end
16
+ MCP::Tool::Response.new([{ type: "text", text: models.to_json }])
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsMcp
6
+ module Tools
7
+ class QueryRecords < MCP::Tool
8
+ tool_name "query_records"
9
+ description "Query records using hash conditions. Returns only id, created_at, and updated_at " \
10
+ "by default — specify fields to retrieve other columns."
11
+ input_schema(
12
+ properties: {
13
+ model: { type: "string", description: "Model class name, e.g. \"User\"" },
14
+ conditions: { type: "object", description: "Hash of column => value pairs, e.g. {\"active\": true}" },
15
+ fields: { type: "array", description: "Columns to return. Defaults to [id, created_at, updated_at]",
16
+ items: { type: "string" } },
17
+ limit: { type: "integer", description: "Max records to return (capped at max_limit, default 100)" },
18
+ offset: { type: "integer",
19
+ description: "Number of records to skip (must not exceed max_offset, default 10000)" },
20
+ order: { type: "string", description: "Order clause, e.g. \"created_at DESC\"" }
21
+ },
22
+ required: ["model"]
23
+ )
24
+
25
+ def self.call(model:, server_context:, conditions: {}, fields: [], limit: nil, offset: 0, order: nil)
26
+ records = Database::RoleProxy.with_role do
27
+ klass = Database::ModelResolver.resolve(model)
28
+ Database::QueryBuilder.new(
29
+ klass,
30
+ conditions: conditions || {},
31
+ fields: Array(fields),
32
+ limit: limit,
33
+ offset: offset || 0,
34
+ order: order
35
+ ).execute
36
+ end
37
+ MCP::Tool::Response.new([{ type: "text", text: records.to_json }])
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMcp
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rails_mcp.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_mcp/version"
4
+ require "rails_mcp/configuration"
5
+ require "rails_mcp/schema_config"
6
+ require "rails_mcp/database/role_proxy"
7
+ require "rails_mcp/database/model_resolver"
8
+ require "rails_mcp/database/column_policy"
9
+ require "rails_mcp/database/query_builder"
10
+ require "rails_mcp/tools/list_models"
11
+ require "rails_mcp/tools/describe_model"
12
+ require "rails_mcp/tools/query_records"
13
+ require "rails_mcp/tools/find_record"
14
+ require "rails_mcp/tools/count_records"
15
+ require "rails_mcp/tool_dsl"
16
+ require "rails_mcp/server"
17
+ require "rails_mcp/auth/token_validator"
18
+ require "rails_mcp/engine"
19
+
20
+ module RailsMcp
21
+ class << self
22
+ def configure
23
+ yield configuration
24
+ end
25
+
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def schema_config
31
+ return nil unless configuration.schema_file
32
+
33
+ @schema_config ||= SchemaConfig.new(configuration.schema_file)
34
+ end
35
+
36
+ def reset_configuration!
37
+ @configuration = Configuration.new
38
+ @schema_config = nil
39
+ end
40
+ end
41
+ end
data/rails-mcp.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/rails_mcp/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "activerecord-mcp"
7
+ spec.version = RailsMcp::VERSION
8
+ spec.authors = ["Paulo Ancheta"]
9
+ spec.email = ["paulo.ancheta@gmail.com"]
10
+
11
+ spec.summary = "MCP server for Rails apps — safe, role-aware database query tools over Streamable HTTP"
12
+ spec.description = "A Rails Engine that implements a Model Context Protocol (MCP) server using " \
13
+ "HTTP-only Streamable HTTP transport. Provides built-in ActiveRecord query tools " \
14
+ "with configurable database roles, field filtering, and OAuth 2.1 + PKCE auth via Doorkeeper."
15
+ spec.homepage = "https://github.com/pauloancheta/rails-mcp"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.1.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
+ spec.metadata["rubygems_mfa_required"] = "true"
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ spec/ .git .github appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.require_paths = ["lib"]
31
+
32
+ spec.add_dependency "doorkeeper", "~> 5.6"
33
+ spec.add_dependency "mcp", "~> 0.17"
34
+ spec.add_dependency "rails", ">= 7.0"
35
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Post < ActiveRecord::Base
4
+ belongs_to :user
5
+ validates :title, presence: true
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User < ActiveRecord::Base
4
+ has_many :posts
5
+ validates :name, :email, presence: true
6
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_record/railtie"
5
+ require "action_controller/railtie"
6
+ require "action_dispatch/railtie"
7
+ require "doorkeeper"
8
+ require "rails_mcp"
9
+
10
+ module Dummy
11
+ class Application < Rails::Application
12
+ config.root = File.expand_path("..", __dir__)
13
+ config.load_defaults 7.0
14
+ config.eager_load = false
15
+ config.logger = Logger.new(nil)
16
+ config.active_record.sqlite3_adapter_strict_strings_by_default = false
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ default: &default
2
+ adapter: sqlite3
3
+ pool: 5
4
+ timeout: 5000
5
+
6
+ test:
7
+ <<: *default
8
+ database: ":memory:"
9
+
10
+ # Alias reading role to the same connection so connected_to(:reading) works in tests
11
+ test_reading:
12
+ <<: *default
13
+ database: ":memory:"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+
5
+ Rails.application.initialize!
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ Doorkeeper.configure do
4
+ orm :active_record
5
+ resource_owner_authenticator { nil }
6
+ default_scopes
7
+ optional_scopes :mcp
8
+ pkce_code_challenge_methods %w[S256]
9
+ access_token_expires_in 2.hours
10
+ skip_authorization { true }
11
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ use_doorkeeper
5
+ mount RailsMcp::Engine, at: "/mcp"
6
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Schema.define(version: 1) do
4
+ create_table :users, force: true do |t|
5
+ t.string :name, null: false
6
+ t.string :email, null: false
7
+ t.integer :age
8
+ t.boolean :active, default: true
9
+ t.timestamps
10
+ end
11
+
12
+ create_table :posts, force: true do |t|
13
+ t.string :title, null: false
14
+ t.text :body
15
+ t.references :user, null: false, foreign_key: true
16
+ t.timestamps
17
+ end
18
+
19
+ # Doorkeeper tables
20
+ create_table :oauth_applications, force: true do |t|
21
+ t.string :name, null: false
22
+ t.string :uid, null: false
23
+ t.string :secret, null: false, default: ""
24
+ t.text :redirect_uri, null: false
25
+ t.text :scopes, null: false, default: ""
26
+ t.boolean :confidential, null: false, default: true
27
+ t.timestamps null: false
28
+ end
29
+ add_index :oauth_applications, :uid, unique: true
30
+
31
+ create_table :oauth_access_grants, force: true do |t|
32
+ t.references :resource_owner, null: false, index: true
33
+ t.references :application, null: false
34
+ t.string :token, null: false
35
+ t.integer :expires_in, null: false
36
+ t.text :redirect_uri, null: false
37
+ t.text :scopes, null: false, default: ""
38
+ t.string :code_challenge
39
+ t.string :code_challenge_method
40
+ t.datetime :revoked_at
41
+ t.timestamps null: false
42
+ end
43
+ add_index :oauth_access_grants, :token, unique: true
44
+
45
+ create_table :oauth_access_tokens, force: true do |t|
46
+ t.references :resource_owner, index: true
47
+ t.references :application
48
+ t.text :token, null: false
49
+ t.text :refresh_token
50
+ t.integer :expires_in
51
+ t.text :scopes
52
+ t.datetime :revoked_at
53
+ t.string :previous_refresh_token, null: false, default: ""
54
+ t.timestamps null: false
55
+ end
56
+ add_index :oauth_access_tokens, :token, unique: true
57
+ add_index :oauth_access_tokens, :refresh_token, unique: true, where: "(refresh_token IS NOT NULL)"
58
+ end
@@ -0,0 +1,5 @@
1
+ User:
2
+ - name
3
+ - email
4
+ Post:
5
+ - title
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["RAILS_ENV"] = "test"
4
+
5
+ require_relative "dummy/config/environment"
6
+ require "minitest/autorun"
7
+ require "active_support/test_case"
8
+ require "action_dispatch/testing/integration"
9
+
10
+ # Load schema into the in-memory DB
11
+ ActiveRecord::Schema.verbose = false
12
+ load File.expand_path("dummy/db/schema.rb", __dir__)
13
+
14
+ # Stub connected_to so SQLite :reading role works in tests
15
+ module ActiveRecord
16
+ class Base
17
+ class << self
18
+ alias _original_connected_to connected_to
19
+
20
+ def connected_to(**_kwargs)
21
+ yield
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ class ActiveSupport::TestCase
28
+ setup do
29
+ RailsMcp.reset_configuration!
30
+ RailsMcp::Server.reset!
31
+ end
32
+
33
+ teardown do
34
+ User.delete_all
35
+ Post.delete_all
36
+ end
37
+
38
+ # Build a valid Doorkeeper access token for request tests
39
+ def create_access_token
40
+ app = Doorkeeper::Application.create!(
41
+ name: "test",
42
+ redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
43
+ confidential: false
44
+ )
45
+ Doorkeeper::AccessToken.create!(application: app, expires_in: 3600)
46
+ end
47
+ 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