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,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
|
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,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,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,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
|
data/test/test_helper.rb
ADDED
|
@@ -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
|