rails-active-mcp 0.1.7 → 2.0.7
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 +4 -4
- data/README.md +106 -279
- data/changelog.md +69 -0
- data/docs/DEBUGGING.md +5 -5
- data/docs/README.md +130 -142
- data/exe/rails-active-mcp-server +153 -76
- data/lib/generators/rails_active_mcp/install/install_generator.rb +19 -39
- data/lib/generators/rails_active_mcp/install/templates/README.md +30 -164
- data/lib/generators/rails_active_mcp/install/templates/initializer.rb +37 -38
- data/lib/generators/rails_active_mcp/install/templates/mcp.ru +7 -3
- data/lib/rails_active_mcp/configuration.rb +37 -98
- data/lib/rails_active_mcp/console_executor.rb +13 -3
- data/lib/rails_active_mcp/engine.rb +36 -24
- data/lib/rails_active_mcp/sdk/server.rb +183 -0
- data/lib/rails_active_mcp/sdk/tools/console_execute_tool.rb +103 -0
- data/lib/rails_active_mcp/sdk/tools/dry_run_tool.rb +73 -0
- data/lib/rails_active_mcp/sdk/tools/model_info_tool.rb +106 -0
- data/lib/rails_active_mcp/sdk/tools/safe_query_tool.rb +77 -0
- data/lib/rails_active_mcp/version.rb +1 -1
- data/lib/rails_active_mcp.rb +5 -11
- data/rails_active_mcp.gemspec +4 -1
- metadata +22 -11
- data/app/controllers/rails_active_mcp/mcp_controller.rb +0 -80
- data/lib/rails_active_mcp/mcp_server.rb +0 -383
- data/lib/rails_active_mcp/railtie.rb +0 -70
- data/lib/rails_active_mcp/stdio_server.rb +0 -517
- data/lib/rails_active_mcp/tools/console_execute_tool.rb +0 -61
- data/lib/rails_active_mcp/tools/dry_run_tool.rb +0 -41
- data/lib/rails_active_mcp/tools/model_info_tool.rb +0 -70
- data/lib/rails_active_mcp/tools/safe_query_tool.rb +0 -41
@@ -6,6 +6,37 @@ module RailsActiveMcp
|
|
6
6
|
|
7
7
|
config.rails_active_mcp = ActiveSupport::OrderedOptions.new
|
8
8
|
|
9
|
+
# Ensure configuration is available very early
|
10
|
+
initializer 'rails_active_mcp.early_configuration', before: :load_config_initializers do
|
11
|
+
RailsActiveMcp.configure unless RailsActiveMcp.configuration
|
12
|
+
end
|
13
|
+
|
14
|
+
# Configure logging with Rails 7.1+ compatibility
|
15
|
+
initializer 'rails_active_mcp.logger', after: :initialize_logger, before: :set_clear_dependencies_hook do
|
16
|
+
# Only set logger if Rails logger is available and responds to logging methods
|
17
|
+
RailsActiveMcp.logger = if defined?(Rails.logger) && Rails.logger.respond_to?(:info)
|
18
|
+
# Check if Rails logger is using semantic logger or other custom loggers
|
19
|
+
if Rails.logger.class.name.include?('SemanticLogger')
|
20
|
+
# For semantic logger, we need to create a tagged logger
|
21
|
+
Rails.logger.tagged('RailsActiveMcp')
|
22
|
+
else
|
23
|
+
# For standard Rails logger, use it directly
|
24
|
+
Rails.logger
|
25
|
+
end
|
26
|
+
else
|
27
|
+
# Fallback to our own logger if Rails logger is not available
|
28
|
+
Logger.new(STDERR).tap do |logger|
|
29
|
+
logger.level = Rails.env.production? ? Logger::WARN : Logger::INFO
|
30
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
31
|
+
"[#{datetime}] #{severity} -- RailsActiveMcp: #{msg}\n"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Log that the logger has been initialized
|
37
|
+
RailsActiveMcp.logger.info "Rails Active MCP logger initialized (#{RailsActiveMcp.logger.class.name})"
|
38
|
+
end
|
39
|
+
|
9
40
|
# Add generators configuration
|
10
41
|
config.generators do |g|
|
11
42
|
g.test_framework :rspec, fixture: false
|
@@ -13,22 +44,6 @@ module RailsActiveMcp
|
|
13
44
|
g.helper false
|
14
45
|
end
|
15
46
|
|
16
|
-
# Define routes for the engine
|
17
|
-
routes do
|
18
|
-
# Main MCP endpoint for HTTP clients
|
19
|
-
post '/', to: 'mcp#handle'
|
20
|
-
post '/messages', to: 'mcp#handle'
|
21
|
-
|
22
|
-
# SSE endpoint for better MCP client compatibility
|
23
|
-
get '/sse', to: 'mcp#sse'
|
24
|
-
|
25
|
-
# Health check endpoint
|
26
|
-
get '/health', to: 'mcp#health'
|
27
|
-
|
28
|
-
# Root redirect
|
29
|
-
root to: 'mcp#info'
|
30
|
-
end
|
31
|
-
|
32
47
|
initializer 'rails_active_mcp.configure' do |app|
|
33
48
|
# Load configuration from Rails config if present
|
34
49
|
if app.config.respond_to?(:rails_active_mcp)
|
@@ -39,18 +54,15 @@ module RailsActiveMcp
|
|
39
54
|
end
|
40
55
|
end
|
41
56
|
|
42
|
-
# Set default audit file location
|
43
|
-
RailsActiveMcp.config.audit_file ||= Rails.root.join('log', 'rails_active_mcp.log')
|
44
|
-
|
45
57
|
# Validate configuration
|
46
|
-
RailsActiveMcp.config.
|
58
|
+
RailsActiveMcp.config.valid?
|
47
59
|
end
|
48
60
|
|
49
|
-
# Add our tools directory to the load path
|
50
|
-
config.autoload_paths << root.join('lib', 'rails_active_mcp', 'tools')
|
61
|
+
# Add our SDK tools directory to the load path
|
62
|
+
config.autoload_paths << root.join('lib', 'rails_active_mcp', 'sdk', 'tools')
|
51
63
|
|
52
|
-
# Ensure our tools are eager loaded in production
|
53
|
-
config.eager_load_paths << root.join('lib', 'rails_active_mcp', 'tools')
|
64
|
+
# Ensure our SDK tools are eager loaded in production
|
65
|
+
config.eager_load_paths << root.join('lib', 'rails_active_mcp', 'sdk', 'tools')
|
54
66
|
|
55
67
|
# Add rake tasks
|
56
68
|
rake_tasks do
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp'
|
4
|
+
require 'stringio'
|
5
|
+
require 'fileutils'
|
6
|
+
|
7
|
+
# Require all SDK tools
|
8
|
+
require_relative 'tools/console_execute_tool'
|
9
|
+
require_relative 'tools/model_info_tool'
|
10
|
+
require_relative 'tools/safe_query_tool'
|
11
|
+
require_relative 'tools/dry_run_tool'
|
12
|
+
|
13
|
+
module RailsActiveMcp
|
14
|
+
module Sdk
|
15
|
+
class Server
|
16
|
+
attr_reader :mcp_server
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
# Store original streams for restoration
|
20
|
+
@original_stdout = $stdout
|
21
|
+
@original_stderr = $stderr
|
22
|
+
|
23
|
+
# Set up output redirection BEFORE any Rails interaction
|
24
|
+
setup_output_redirection
|
25
|
+
|
26
|
+
# Configure MCP first
|
27
|
+
configure_mcp
|
28
|
+
|
29
|
+
# Create the MCP server with our tools
|
30
|
+
@mcp_server = MCP::Server.new(
|
31
|
+
name: 'rails-active-mcp',
|
32
|
+
version: RailsActiveMcp::VERSION,
|
33
|
+
tools: discover_tools,
|
34
|
+
server_context: server_context
|
35
|
+
)
|
36
|
+
|
37
|
+
# Set up server handlers
|
38
|
+
setup_server_handlers
|
39
|
+
end
|
40
|
+
|
41
|
+
def run_stdio
|
42
|
+
# Ensure output redirection is active for stdio mode
|
43
|
+
ensure_output_redirection_for_stdio
|
44
|
+
|
45
|
+
require 'mcp/transports/stdio'
|
46
|
+
transport = MCP::Transports::StdioTransport.new(@mcp_server)
|
47
|
+
transport.open
|
48
|
+
rescue StandardError => e
|
49
|
+
# Log to stderr (which is redirected to file) and re-raise
|
50
|
+
warn "[#{Time.now}] [RAILS-MCP] FATAL: SDK Server crashed: #{e.message}"
|
51
|
+
warn "[#{Time.now}] [RAILS-MCP] FATAL: #{e.backtrace.join("\n")}"
|
52
|
+
raise
|
53
|
+
ensure
|
54
|
+
restore_output_streams
|
55
|
+
end
|
56
|
+
|
57
|
+
def run_http(port: 3001)
|
58
|
+
# HTTP transport might not be available in the SDK yet
|
59
|
+
# For now, fall back to a basic implementation or error
|
60
|
+
raise NotImplementedError, 'HTTP transport not yet implemented with official MCP SDK'
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def setup_output_redirection
|
66
|
+
# Skip redirection if in debug mode
|
67
|
+
return if ENV['RAILS_MCP_DEBUG'] == '1'
|
68
|
+
|
69
|
+
# Create log directory
|
70
|
+
log_dir = File.join(Dir.pwd, 'log')
|
71
|
+
FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
|
72
|
+
|
73
|
+
# Redirect stderr to log file
|
74
|
+
stderr_log = File.join(log_dir, 'rails_mcp_stderr.log')
|
75
|
+
@stderr_file = File.open(stderr_log, 'a')
|
76
|
+
@stderr_file.sync = true
|
77
|
+
$stderr = @stderr_file
|
78
|
+
|
79
|
+
# Capture stdout during initialization to prevent interference
|
80
|
+
@stdout_buffer = StringIO.new
|
81
|
+
$stdout = @stdout_buffer
|
82
|
+
|
83
|
+
# Log redirection setup
|
84
|
+
warn "[#{Time.now}] [RAILS-MCP] INFO: Output redirection enabled. stderr -> #{stderr_log}"
|
85
|
+
end
|
86
|
+
|
87
|
+
def ensure_output_redirection_for_stdio
|
88
|
+
# Skip if in debug mode
|
89
|
+
return if ENV['RAILS_MCP_DEBUG'] == '1'
|
90
|
+
|
91
|
+
# Check if anything was captured during initialization
|
92
|
+
if @stdout_buffer && !@stdout_buffer.string.empty?
|
93
|
+
captured = @stdout_buffer.string
|
94
|
+
warn "[#{Time.now}] [RAILS-MCP] WARNING: Captured stdout during initialization: #{captured.inspect}"
|
95
|
+
end
|
96
|
+
|
97
|
+
# Restore original stdout for MCP communication, keep stderr redirected
|
98
|
+
$stdout = @original_stdout
|
99
|
+
warn "[#{Time.now}] [RAILS-MCP] INFO: stdout restored for MCP communication, stderr remains redirected"
|
100
|
+
end
|
101
|
+
|
102
|
+
def restore_output_streams
|
103
|
+
return if ENV['RAILS_MCP_DEBUG'] == '1'
|
104
|
+
|
105
|
+
begin
|
106
|
+
$stdout = @original_stdout if @original_stdout
|
107
|
+
$stderr = @original_stderr if @original_stderr
|
108
|
+
@stderr_file&.close
|
109
|
+
rescue StandardError => e
|
110
|
+
# Best effort cleanup
|
111
|
+
warn "Failed to restore output streams: #{e.message}" if @original_stderr
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def discover_tools
|
116
|
+
[
|
117
|
+
RailsActiveMcp::Sdk::Tools::ConsoleExecuteTool,
|
118
|
+
RailsActiveMcp::Sdk::Tools::ModelInfoTool,
|
119
|
+
RailsActiveMcp::Sdk::Tools::SafeQueryTool,
|
120
|
+
RailsActiveMcp::Sdk::Tools::DryRunTool
|
121
|
+
]
|
122
|
+
end
|
123
|
+
|
124
|
+
def configure_mcp
|
125
|
+
# Configure MCP SDK with Rails-specific handlers
|
126
|
+
MCP.configure do |config|
|
127
|
+
config.exception_reporter = method(:handle_rails_exception)
|
128
|
+
config.instrumentation_callback = method(:log_mcp_calls)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def setup_server_handlers
|
133
|
+
# Set up resource read handler (for future use)
|
134
|
+
@mcp_server.resources_read_handler do |params|
|
135
|
+
[
|
136
|
+
{
|
137
|
+
uri: params[:uri],
|
138
|
+
mimeType: 'text/plain',
|
139
|
+
text: "Rails Active MCP Resource: #{params[:uri]}"
|
140
|
+
}
|
141
|
+
]
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def handle_rails_exception(exception, context)
|
146
|
+
# Use stderr which is redirected to log file
|
147
|
+
warn "[#{Time.now}] [RAILS-MCP] ERROR: MCP Exception: #{exception.message}"
|
148
|
+
warn "[#{Time.now}] [RAILS-MCP] ERROR: #{exception.backtrace.join("\n")}" if ENV['RAILS_MCP_DEBUG'] == '1'
|
149
|
+
|
150
|
+
# Log context for debugging
|
151
|
+
return unless context && ENV['RAILS_MCP_DEBUG'] == '1'
|
152
|
+
|
153
|
+
warn "[#{Time.now}] [RAILS-MCP] DEBUG: MCP Context: #{context.inspect}"
|
154
|
+
end
|
155
|
+
|
156
|
+
def log_mcp_calls(data)
|
157
|
+
return unless ENV['RAILS_MCP_DEBUG'] == '1'
|
158
|
+
|
159
|
+
duration_ms = (data[:duration] * 1000).round(2)
|
160
|
+
|
161
|
+
log_message = "MCP #{data[:method]}"
|
162
|
+
log_message += " [#{data[:tool_name]}]" if data[:tool_name]
|
163
|
+
log_message += " (#{duration_ms}ms)"
|
164
|
+
|
165
|
+
warn "[#{Time.now}] [RAILS-MCP] DEBUG: #{log_message}"
|
166
|
+
|
167
|
+
# Log errors separately
|
168
|
+
return unless data[:error]
|
169
|
+
|
170
|
+
warn "[#{Time.now}] [RAILS-MCP] ERROR: MCP Call Error: #{data[:error]}"
|
171
|
+
end
|
172
|
+
|
173
|
+
def server_context
|
174
|
+
{
|
175
|
+
rails_env: defined?(Rails) ? Rails.env : 'unknown',
|
176
|
+
rails_root: defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.to_s : Dir.pwd,
|
177
|
+
config: RailsActiveMcp.config,
|
178
|
+
gem_version: RailsActiveMcp::VERSION
|
179
|
+
}
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp'
|
4
|
+
|
5
|
+
module RailsActiveMcp
|
6
|
+
module Sdk
|
7
|
+
module Tools
|
8
|
+
class ConsoleExecuteTool < MCP::Tool
|
9
|
+
description 'Execute Ruby code in Rails console with safety checks'
|
10
|
+
|
11
|
+
input_schema(
|
12
|
+
properties: {
|
13
|
+
code: {
|
14
|
+
type: 'string',
|
15
|
+
description: 'Ruby code to execute in Rails console'
|
16
|
+
},
|
17
|
+
safe_mode: {
|
18
|
+
type: 'boolean',
|
19
|
+
description: 'Enable safety checks (default: true)'
|
20
|
+
},
|
21
|
+
timeout: {
|
22
|
+
type: 'integer',
|
23
|
+
description: 'Timeout in seconds (default: 30)'
|
24
|
+
},
|
25
|
+
capture_output: {
|
26
|
+
type: 'boolean',
|
27
|
+
description: 'Capture console output (default: true)'
|
28
|
+
}
|
29
|
+
},
|
30
|
+
required: ['code']
|
31
|
+
)
|
32
|
+
|
33
|
+
annotations(
|
34
|
+
title: 'Rails Console Executor',
|
35
|
+
destructive_hint: true,
|
36
|
+
read_only_hint: false,
|
37
|
+
idempotent_hint: false,
|
38
|
+
open_world_hint: false
|
39
|
+
)
|
40
|
+
|
41
|
+
def self.call(code:, server_context:, safe_mode: true, timeout: 30, capture_output: true)
|
42
|
+
config = RailsActiveMcp.config
|
43
|
+
|
44
|
+
# Create executor with config
|
45
|
+
executor = RailsActiveMcp::ConsoleExecutor.new(config)
|
46
|
+
|
47
|
+
begin
|
48
|
+
result = executor.execute(
|
49
|
+
code,
|
50
|
+
timeout: timeout,
|
51
|
+
safe_mode: safe_mode,
|
52
|
+
capture_output: capture_output
|
53
|
+
)
|
54
|
+
|
55
|
+
if result[:success]
|
56
|
+
MCP::Tool::Response.new([
|
57
|
+
{ type: 'text', text: format_success_result(result) }
|
58
|
+
])
|
59
|
+
else
|
60
|
+
MCP::Tool::Response.new([
|
61
|
+
{ type: 'text', text: format_error_result(result) }
|
62
|
+
])
|
63
|
+
end
|
64
|
+
rescue RailsActiveMcp::SafetyError => e
|
65
|
+
error_response("Safety check failed: #{e.message}")
|
66
|
+
rescue RailsActiveMcp::TimeoutError => e
|
67
|
+
error_response("Execution timed out: #{e.message}")
|
68
|
+
rescue StandardError => e
|
69
|
+
error_response("Execution failed: #{e.message}")
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def self.format_success_result(result)
|
76
|
+
output = []
|
77
|
+
output << "Code: #{result[:code]}"
|
78
|
+
output << "Result: #{result[:return_value_string] || result[:return_value]}"
|
79
|
+
|
80
|
+
output << "Output: #{result[:output]}" if result[:output].present?
|
81
|
+
|
82
|
+
output << "Execution time: #{result[:execution_time]}s" if result[:execution_time]
|
83
|
+
|
84
|
+
output << "Note: #{result[:note]}" if result[:note]
|
85
|
+
|
86
|
+
output.join("\n")
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.format_error_result(result)
|
90
|
+
error_msg = "Error: #{result[:error]}"
|
91
|
+
error_msg += " (#{result[:error_class]})" if result[:error_class]
|
92
|
+
error_msg
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.error_response(message)
|
96
|
+
MCP::Tool::Response.new([
|
97
|
+
{ type: 'text', text: message }
|
98
|
+
])
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp'
|
4
|
+
|
5
|
+
module RailsActiveMcp
|
6
|
+
module Sdk
|
7
|
+
module Tools
|
8
|
+
class DryRunTool < MCP::Tool
|
9
|
+
description 'Analyze Ruby code for safety without executing it'
|
10
|
+
|
11
|
+
input_schema(
|
12
|
+
properties: {
|
13
|
+
code: {
|
14
|
+
type: 'string',
|
15
|
+
description: 'Ruby code to analyze for safety'
|
16
|
+
}
|
17
|
+
},
|
18
|
+
required: ['code']
|
19
|
+
)
|
20
|
+
|
21
|
+
annotations(
|
22
|
+
title: 'Code Safety Analyzer',
|
23
|
+
destructive_hint: false,
|
24
|
+
read_only_hint: true,
|
25
|
+
idempotent_hint: true,
|
26
|
+
open_world_hint: false
|
27
|
+
)
|
28
|
+
|
29
|
+
def self.call(code:, server_context:, check_safety: true, analyze_dependencies: true)
|
30
|
+
config = RailsActiveMcp.config
|
31
|
+
|
32
|
+
# Create safety checker
|
33
|
+
|
34
|
+
executor = RailsActiveMcp::ConsoleExecutor.new(config)
|
35
|
+
analysis = executor.dry_run(code)
|
36
|
+
|
37
|
+
output = []
|
38
|
+
output << "Code: #{analysis[:code]}"
|
39
|
+
output << "Safe: #{analysis[:safety_analysis][:safe] ? 'Yes' : 'No'}"
|
40
|
+
output << "Read-only: #{analysis[:safety_analysis][:read_only] ? 'Yes' : 'No'}"
|
41
|
+
output << "Risk level: #{analysis[:estimated_risk]}"
|
42
|
+
output << "Summary: #{analysis[:safety_analysis][:summary]}"
|
43
|
+
|
44
|
+
if analysis[:safety_analysis][:violations].any?
|
45
|
+
output << "\nViolations:"
|
46
|
+
analysis[:safety_analysis][:violations].each do |violation|
|
47
|
+
output << " - #{violation[:description]} (#{violation[:severity]})"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
if analysis[:recommendations].any?
|
52
|
+
output << "\nRecommendations:"
|
53
|
+
analysis[:recommendations].each do |rec|
|
54
|
+
output << " - #{rec}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
MCP::Tool::Response.new([
|
59
|
+
{ type: 'text', text: output.join("\n") }
|
60
|
+
])
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def self.error_response(message)
|
66
|
+
MCP::Tool::Response.new([
|
67
|
+
{ type: 'text', text: message }
|
68
|
+
])
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp'
|
4
|
+
|
5
|
+
module RailsActiveMcp
|
6
|
+
module Sdk
|
7
|
+
module Tools
|
8
|
+
class ModelInfoTool < MCP::Tool
|
9
|
+
description 'Get information about Rails models including schema and associations'
|
10
|
+
|
11
|
+
input_schema(
|
12
|
+
properties: {
|
13
|
+
model: {
|
14
|
+
type: 'string',
|
15
|
+
description: 'Model class name'
|
16
|
+
},
|
17
|
+
include_schema: {
|
18
|
+
type: 'boolean',
|
19
|
+
description: 'Include database schema information'
|
20
|
+
},
|
21
|
+
include_associations: {
|
22
|
+
type: 'boolean',
|
23
|
+
description: 'Include model associations'
|
24
|
+
},
|
25
|
+
include_validations: {
|
26
|
+
type: 'boolean',
|
27
|
+
description: 'Include model validations'
|
28
|
+
}
|
29
|
+
},
|
30
|
+
required: ['model']
|
31
|
+
)
|
32
|
+
|
33
|
+
annotations(
|
34
|
+
title: 'Rails Model Inspector',
|
35
|
+
destructive_hint: false,
|
36
|
+
read_only_hint: true,
|
37
|
+
idempotent_hint: true,
|
38
|
+
open_world_hint: false
|
39
|
+
)
|
40
|
+
|
41
|
+
def self.call(model:, server_context:, include_schema: true, include_associations: true,
|
42
|
+
include_validations: true)
|
43
|
+
config = RailsActiveMcp.config
|
44
|
+
|
45
|
+
begin
|
46
|
+
model_class = model.constantize
|
47
|
+
|
48
|
+
output = []
|
49
|
+
output << "Model: #{model}"
|
50
|
+
output << "Table: #{model_class.table_name}"
|
51
|
+
output << "Primary Key: #{model_class.primary_key}"
|
52
|
+
|
53
|
+
if include_schema
|
54
|
+
output << "\nSchema:"
|
55
|
+
model_class.columns.each do |column|
|
56
|
+
output << " #{column.name}: #{column.type} (#{column.sql_type})"
|
57
|
+
output << " - Null: #{column.null}"
|
58
|
+
output << " - Default: #{column.default}" if column.default
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
if include_associations
|
63
|
+
output << "\nAssociations:"
|
64
|
+
model_class.reflections.each do |name, reflection|
|
65
|
+
output << " #{name}: #{reflection.class.name.split('::').last} -> #{reflection.class_name}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
if include_validations
|
70
|
+
validations = {}
|
71
|
+
model_class.validators.each do |validator|
|
72
|
+
validator.attributes.each do |attribute|
|
73
|
+
validations[attribute] ||= []
|
74
|
+
validations[attribute] << validator.class.name.split('::').last
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
if validations.any?
|
79
|
+
output << "\nValidations:"
|
80
|
+
validations.each do |attr, validators|
|
81
|
+
output << " #{attr}: #{validators.join(', ')}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
MCP::Tool::Response.new([
|
87
|
+
{ type: 'text', text: output.join("\n") }
|
88
|
+
])
|
89
|
+
rescue NameError
|
90
|
+
error_response("Model '#{model}' not found")
|
91
|
+
rescue StandardError => e
|
92
|
+
error_response("Error analyzing model: #{e.message}")
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def self.error_response(message)
|
99
|
+
MCP::Tool::Response.new([
|
100
|
+
{ type: 'text', text: message }
|
101
|
+
])
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp'
|
4
|
+
|
5
|
+
module RailsActiveMcp
|
6
|
+
module Sdk
|
7
|
+
module Tools
|
8
|
+
class SafeQueryTool < MCP::Tool
|
9
|
+
description 'Execute safe read-only database queries on Rails models'
|
10
|
+
|
11
|
+
input_schema(
|
12
|
+
properties: {
|
13
|
+
model: {
|
14
|
+
type: 'string',
|
15
|
+
description: 'Model class name (e.g., "User", "Product")'
|
16
|
+
},
|
17
|
+
method: {
|
18
|
+
type: 'string',
|
19
|
+
description: 'Query method (find, where, count, etc.)'
|
20
|
+
},
|
21
|
+
args: {
|
22
|
+
type: 'array',
|
23
|
+
description: 'Arguments for the query method'
|
24
|
+
},
|
25
|
+
limit: {
|
26
|
+
type: 'integer',
|
27
|
+
description: 'Limit results (default: 100)'
|
28
|
+
}
|
29
|
+
},
|
30
|
+
required: %w[model method]
|
31
|
+
)
|
32
|
+
|
33
|
+
annotations(
|
34
|
+
title: 'Safe Query Executor',
|
35
|
+
destructive_hint: false,
|
36
|
+
read_only_hint: true,
|
37
|
+
idempotent_hint: true,
|
38
|
+
open_world_hint: false
|
39
|
+
)
|
40
|
+
|
41
|
+
def self.call(model:, method:, server_context:, args: [], limit: 100)
|
42
|
+
config = RailsActiveMcp.config
|
43
|
+
|
44
|
+
executor = RailsActiveMcp::ConsoleExecutor.new(config)
|
45
|
+
|
46
|
+
result = executor.execute_safe_query(
|
47
|
+
model: model,
|
48
|
+
method: method,
|
49
|
+
args: args,
|
50
|
+
limit: limit
|
51
|
+
)
|
52
|
+
|
53
|
+
if result[:success]
|
54
|
+
output = []
|
55
|
+
output << "Query: #{model}.#{method}(#{args.join(', ')})"
|
56
|
+
output << "Count: #{result[:count]}" if result[:count]
|
57
|
+
output << "Result: #{result[:result].inspect}"
|
58
|
+
|
59
|
+
MCP::Tool::Response.new([
|
60
|
+
{ type: 'text', text: output.join("\n") }
|
61
|
+
])
|
62
|
+
else
|
63
|
+
error_response(result[:error])
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def self.error_response(message)
|
70
|
+
MCP::Tool::Response.new([
|
71
|
+
{ type: 'text', text: message }
|
72
|
+
])
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
data/lib/rails_active_mcp.rb
CHANGED
@@ -5,18 +5,17 @@ require_relative 'rails_active_mcp/version'
|
|
5
5
|
require_relative 'rails_active_mcp/configuration'
|
6
6
|
require_relative 'rails_active_mcp/safety_checker'
|
7
7
|
require_relative 'rails_active_mcp/console_executor'
|
8
|
-
|
8
|
+
|
9
|
+
# Load SDK server
|
10
|
+
require_relative 'rails_active_mcp/sdk/server'
|
9
11
|
|
10
12
|
# Load Engine for Rails integration
|
11
13
|
require_relative 'rails_active_mcp/engine' if defined?(Rails)
|
12
14
|
|
13
15
|
module RailsActiveMcp
|
14
16
|
class Error < StandardError; end
|
15
|
-
|
16
17
|
class SafetyError < Error; end
|
17
|
-
|
18
18
|
class ExecutionError < Error; end
|
19
|
-
|
20
19
|
class TimeoutError < Error; end
|
21
20
|
|
22
21
|
class << self
|
@@ -42,16 +41,11 @@ module RailsActiveMcp
|
|
42
41
|
ConsoleExecutor.new(config).execute(code, **options)
|
43
42
|
end
|
44
43
|
|
45
|
-
#
|
46
|
-
def server
|
47
|
-
@server ||= McpServer.new
|
48
|
-
end
|
49
|
-
|
50
|
-
# Logger accessor - configured by railtie or defaults to stderr
|
44
|
+
# Logger accessor - configured by engine or defaults to stderr
|
51
45
|
attr_accessor :logger
|
52
46
|
|
53
47
|
def logger
|
54
|
-
@logger ||= Logger.new(
|
48
|
+
@logger ||= Logger.new($stderr).tap do |logger|
|
55
49
|
logger.level = Logger::INFO
|
56
50
|
logger.formatter = proc do |severity, datetime, progname, msg|
|
57
51
|
"[#{datetime}] #{severity} -- RailsActiveMcp: #{msg}\n"
|
data/rails_active_mcp.gemspec
CHANGED
@@ -32,7 +32,10 @@ Gem::Specification.new do |spec|
|
|
32
32
|
|
33
33
|
# Runtime dependencies - more flexible Rails version support
|
34
34
|
spec.add_runtime_dependency 'concurrent-ruby', '~> 1.3'
|
35
|
-
spec.add_runtime_dependency 'rails', '>= 6.1', '<
|
35
|
+
spec.add_runtime_dependency 'rails', '>= 6.1', '< 9.0'
|
36
|
+
|
37
|
+
# MCP SDK - Core protocol implementation
|
38
|
+
spec.add_runtime_dependency 'mcp', '~> 0.1.0'
|
36
39
|
|
37
40
|
# Core dependencies
|
38
41
|
spec.add_dependency 'json', '~> 2.0'
|