rails-active-mcp 0.1.7 → 2.0.8
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/examples/rails_app_integration.md +405 -0
- data/exe/rails-active-mcp-server +153 -76
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/lib/generators/rails_active_mcp/install/install_generator.rb +19 -39
- data/lib/generators/rails_active_mcp/install/templates/README.md +134 -188
- data/lib/generators/rails_active_mcp/install/templates/initializer.rb +65 -28
- 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/tasks.rake +236 -80
- data/lib/rails_active_mcp/version.rb +1 -1
- data/lib/rails_active_mcp.rb +5 -11
- data/rails_active_mcp.gemspec +62 -11
- metadata +83 -24
- 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
@@ -1,119 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'fileutils'
|
2
4
|
|
3
5
|
module RailsActiveMcp
|
4
6
|
class Configuration
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
# Core configuration options
|
8
|
+
attr_accessor :allowed_commands, :command_timeout, :enable_logging, :log_level
|
9
|
+
|
10
|
+
# Safety and execution options
|
11
|
+
attr_accessor :safe_mode, :default_timeout, :max_results, :log_executions, :audit_file
|
12
|
+
attr_accessor :custom_safety_patterns, :allowed_models
|
10
13
|
|
11
14
|
def initialize
|
12
|
-
@
|
15
|
+
@allowed_commands = %w[
|
16
|
+
ls pwd cat head tail grep find wc
|
17
|
+
rails console rails runner
|
18
|
+
bundle exec rspec bundle exec test
|
19
|
+
git status git log git diff
|
20
|
+
]
|
21
|
+
@command_timeout = 30
|
22
|
+
@enable_logging = true
|
23
|
+
@log_level = :info
|
24
|
+
|
25
|
+
# Safety and execution defaults
|
13
26
|
@safe_mode = true
|
14
27
|
@default_timeout = 30
|
15
28
|
@max_results = 100
|
16
|
-
@
|
17
|
-
@
|
29
|
+
@log_executions = false
|
30
|
+
@audit_file = nil
|
18
31
|
@custom_safety_patterns = []
|
19
|
-
@
|
20
|
-
# Safe Rails.root access
|
21
|
-
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
22
|
-
@audit_file = rails_root_join('log',
|
23
|
-
'rails_active_mcp.log')
|
24
|
-
end
|
25
|
-
@enable_mutation_tools = false
|
26
|
-
@require_confirmation_for = %i[delete destroy update_all delete_all]
|
27
|
-
@execution_environment = :current # :current, :sandbox, :readonly_replica
|
28
|
-
@server_mode = :stdio # :stdio, :http
|
29
|
-
@server_host = 'localhost'
|
30
|
-
@server_port = 3001
|
31
|
-
end
|
32
|
-
|
33
|
-
# Safety configuration
|
34
|
-
def strict_mode!
|
35
|
-
@safe_mode = true
|
36
|
-
@enable_mutation_tools = false
|
37
|
-
@default_timeout = 15
|
38
|
-
@max_results = 50
|
39
|
-
end
|
40
|
-
|
41
|
-
def permissive_mode!
|
42
|
-
@safe_mode = false
|
43
|
-
@enable_mutation_tools = true
|
44
|
-
@default_timeout = 60
|
45
|
-
@max_results = 1000
|
46
|
-
end
|
47
|
-
|
48
|
-
def production_mode!
|
49
|
-
strict_mode!
|
50
|
-
@execution_environment = :readonly_replica
|
51
|
-
@log_executions = true
|
52
|
-
@require_confirmation_for = %i[delete destroy update create save]
|
53
|
-
end
|
54
|
-
|
55
|
-
# Model access configuration
|
56
|
-
def allow_models(*models)
|
57
|
-
@allowed_models.concat(models.map(&:to_s))
|
58
|
-
end
|
59
|
-
|
60
|
-
def block_models(*models)
|
61
|
-
@blocked_models.concat(models.map(&:to_s))
|
32
|
+
@allowed_models = []
|
62
33
|
end
|
63
34
|
|
64
|
-
def add_safety_pattern(pattern, description = nil)
|
65
|
-
@custom_safety_patterns << { pattern: pattern, description: description }
|
66
|
-
end
|
67
|
-
|
68
|
-
# Server configuration
|
69
|
-
def stdio_mode!
|
70
|
-
@server_mode = :stdio
|
71
|
-
end
|
72
|
-
|
73
|
-
def http_mode!(host: 'localhost', port: 3001)
|
74
|
-
@server_mode = :http
|
75
|
-
@server_host = host
|
76
|
-
@server_port = port
|
77
|
-
end
|
78
|
-
|
79
|
-
def server_mode_valid?
|
80
|
-
%i[stdio http].include?(@server_mode)
|
81
|
-
end
|
82
|
-
|
83
|
-
# Validation
|
84
35
|
def model_allowed?(model_name)
|
85
|
-
|
86
|
-
|
87
|
-
# Check if specifically blocked
|
88
|
-
return false if @blocked_models.include?(model_str)
|
89
|
-
|
90
|
-
# If allow list is empty, allow all (except blocked)
|
91
|
-
return true if @allowed_models.empty?
|
36
|
+
return true if @allowed_models.empty? # Allow all if none specified
|
92
37
|
|
93
|
-
|
94
|
-
@allowed_models.include?(model_str)
|
38
|
+
@allowed_models.include?(model_name.to_s)
|
95
39
|
end
|
96
40
|
|
97
|
-
def
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
41
|
+
def valid?
|
42
|
+
allowed_commands.is_a?(Array) &&
|
43
|
+
command_timeout.is_a?(Numeric) && command_timeout > 0 &&
|
44
|
+
[true, false].include?(enable_logging) &&
|
45
|
+
%i[debug info warn error].include?(log_level) &&
|
46
|
+
[true, false].include?(safe_mode) &&
|
47
|
+
default_timeout.is_a?(Numeric) && default_timeout > 0 &&
|
48
|
+
max_results.is_a?(Numeric) && max_results > 0 &&
|
49
|
+
[true, false].include?(log_executions) &&
|
50
|
+
custom_safety_patterns.is_a?(Array) &&
|
51
|
+
allowed_models.is_a?(Array)
|
107
52
|
end
|
108
53
|
|
109
|
-
|
110
|
-
|
111
|
-
def rails_root_join(*args)
|
112
|
-
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
113
|
-
Rails.root.join(*args)
|
114
|
-
else
|
115
|
-
File.join(Dir.pwd, *args)
|
116
|
-
end
|
54
|
+
def reset!
|
55
|
+
initialize
|
117
56
|
end
|
118
57
|
end
|
119
58
|
end
|
@@ -194,10 +194,13 @@ module RailsActiveMcp
|
|
194
194
|
def execute_with_captured_output(code)
|
195
195
|
# Thread-safe output capture using mutex
|
196
196
|
@execution_mutex.synchronize do
|
197
|
-
# Capture both stdout and
|
197
|
+
# Capture both stdout and stderr to prevent any Rails output leakage
|
198
198
|
old_stdout = $stdout
|
199
|
+
old_stderr = $stderr
|
199
200
|
captured_output = StringIO.new
|
201
|
+
captured_errors = StringIO.new
|
200
202
|
$stdout = captured_output
|
203
|
+
$stderr = captured_errors
|
201
204
|
|
202
205
|
begin
|
203
206
|
# Create thread-safe execution context
|
@@ -209,17 +212,22 @@ module RailsActiveMcp
|
|
209
212
|
execution_time = Time.now - start_time
|
210
213
|
|
211
214
|
output = captured_output.string
|
215
|
+
errors = captured_errors.string
|
216
|
+
|
217
|
+
# Combine output and errors for comprehensive result
|
218
|
+
combined_output = [output, errors].reject(&:empty?).join("\n")
|
212
219
|
|
213
220
|
{
|
214
221
|
success: true,
|
215
222
|
return_value: return_value,
|
216
|
-
output:
|
223
|
+
output: combined_output,
|
217
224
|
return_value_string: safe_inspect(return_value),
|
218
225
|
execution_time: execution_time,
|
219
226
|
code: code
|
220
227
|
}
|
221
228
|
rescue StandardError => e
|
222
229
|
execution_time = Time.now - start_time if defined?(start_time)
|
230
|
+
errors = captured_errors.string
|
223
231
|
|
224
232
|
{
|
225
233
|
success: false,
|
@@ -227,10 +235,12 @@ module RailsActiveMcp
|
|
227
235
|
error_class: e.class.name,
|
228
236
|
backtrace: e.backtrace&.first(10),
|
229
237
|
execution_time: execution_time,
|
230
|
-
code: code
|
238
|
+
code: code,
|
239
|
+
stderr: errors.empty? ? nil : errors
|
231
240
|
}
|
232
241
|
ensure
|
233
242
|
$stdout = old_stdout if old_stdout
|
243
|
+
$stderr = old_stderr if old_stderr
|
234
244
|
end
|
235
245
|
end
|
236
246
|
end
|
@@ -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
|