rails-active-mcp 2.0.7 → 2.0.12
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/.rubocop.yml +141 -0
- data/README.md +3 -3
- data/examples/rails_app_integration.md +405 -0
- data/exe/rails-active-mcp-server +8 -8
- data/gemfiles/rails_6.0.gemfile +34 -0
- data/gemfiles/rails_6.1.gemfile +34 -0
- data/gemfiles/rails_7.0.gemfile +34 -0
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/lib/generators/rails_active_mcp/install/install_generator.rb +44 -94
- data/lib/generators/rails_active_mcp/install/templates/README.md +134 -54
- data/lib/generators/rails_active_mcp/install/templates/initializer.rb +44 -6
- data/lib/generators/rails_active_mcp/install/templates/rails-active-mcp-server +104 -0
- data/lib/generators/rails_active_mcp/install/templates/rails-active-mcp-wrapper +94 -0
- data/lib/rails_active_mcp/configuration.rb +44 -4
- data/lib/rails_active_mcp/console_executor.rb +185 -83
- data/lib/rails_active_mcp/engine.rb +2 -2
- data/lib/rails_active_mcp/garbage_collection_utils.rb +13 -0
- data/lib/rails_active_mcp/safety_checker.rb +17 -7
- data/lib/rails_active_mcp/sdk/server.rb +1 -1
- data/lib/rails_active_mcp/sdk/tools/console_execute_tool.rb +0 -2
- data/lib/rails_active_mcp/sdk/tools/dry_run_tool.rb +0 -2
- data/lib/rails_active_mcp/sdk/tools/model_info_tool.rb +1 -3
- data/lib/rails_active_mcp/sdk/tools/safe_query_tool.rb +0 -2
- data/lib/rails_active_mcp/tasks.rake +238 -82
- data/lib/rails_active_mcp/version.rb +1 -1
- data/lib/rails_active_mcp.rb +4 -3
- data/mcp.ru +2 -2
- data/rails_active_mcp.gemspec +62 -14
- metadata +72 -16
@@ -0,0 +1,94 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# Rails Active MCP Wrapper Script
|
4
|
+
# This script ensures Rails Active MCP works reliably across different Ruby version managers
|
5
|
+
|
6
|
+
# Function to detect Ruby version manager
|
7
|
+
detect_ruby_manager() {
|
8
|
+
if command -v asdf >/dev/null 2>&1 && [ -f .tool-versions ]; then
|
9
|
+
echo "asdf"
|
10
|
+
elif command -v rbenv >/dev/null 2>&1 && [ -f .ruby-version ]; then
|
11
|
+
echo "rbenv"
|
12
|
+
elif command -v rvm >/dev/null 2>&1 && [ -f .rvmrc ]; then
|
13
|
+
echo "rvm"
|
14
|
+
else
|
15
|
+
echo "system"
|
16
|
+
fi
|
17
|
+
}
|
18
|
+
|
19
|
+
# Function to setup environment for different Ruby managers
|
20
|
+
setup_ruby_environment() {
|
21
|
+
local manager=$1
|
22
|
+
|
23
|
+
case $manager in
|
24
|
+
"asdf")
|
25
|
+
if [ -n "$ASDF_DIR" ] && [ -f "$ASDF_DIR/asdf.sh" ]; then
|
26
|
+
source "$ASDF_DIR/asdf.sh"
|
27
|
+
elif [ -f "$HOME/.asdf/asdf.sh" ]; then
|
28
|
+
export ASDF_DIR="$HOME/.asdf"
|
29
|
+
source "$HOME/.asdf/asdf.sh"
|
30
|
+
fi
|
31
|
+
;;
|
32
|
+
"rbenv")
|
33
|
+
if [ -n "$RBENV_ROOT" ] && [ -f "$RBENV_ROOT/bin/rbenv" ]; then
|
34
|
+
export PATH="$RBENV_ROOT/bin:$PATH"
|
35
|
+
eval "$(rbenv init -)"
|
36
|
+
elif [ -f "$HOME/.rbenv/bin/rbenv" ]; then
|
37
|
+
export PATH="$HOME/.rbenv/bin:$PATH"
|
38
|
+
eval "$(rbenv init -)"
|
39
|
+
fi
|
40
|
+
;;
|
41
|
+
"rvm")
|
42
|
+
if [ -f "$HOME/.rvm/scripts/rvm" ]; then
|
43
|
+
source "$HOME/.rvm/scripts/rvm"
|
44
|
+
fi
|
45
|
+
;;
|
46
|
+
esac
|
47
|
+
}
|
48
|
+
|
49
|
+
# Get the directory where this script is located
|
50
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
51
|
+
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
52
|
+
|
53
|
+
# Change to the Rails project directory
|
54
|
+
cd "$PROJECT_ROOT" || {
|
55
|
+
echo "Error: Cannot change to project root directory: $PROJECT_ROOT" >&2
|
56
|
+
exit 1
|
57
|
+
}
|
58
|
+
|
59
|
+
# Detect and setup Ruby environment
|
60
|
+
RUBY_MANAGER=$(detect_ruby_manager)
|
61
|
+
setup_ruby_environment "$RUBY_MANAGER"
|
62
|
+
|
63
|
+
# Verify we have the necessary files
|
64
|
+
if [ ! -f "Gemfile" ]; then
|
65
|
+
echo "Error: Gemfile not found in $PROJECT_ROOT" >&2
|
66
|
+
echo "Please ensure you're running this from a Rails application root" >&2
|
67
|
+
exit 1
|
68
|
+
fi
|
69
|
+
|
70
|
+
if [ ! -f "config/environment.rb" ]; then
|
71
|
+
echo "Warning: config/environment.rb not found. This may not be a Rails application." >&2
|
72
|
+
fi
|
73
|
+
|
74
|
+
# Set default environment if not specified
|
75
|
+
export RAILS_ENV="${RAILS_ENV:-development}"
|
76
|
+
|
77
|
+
# Ensure we have the rails-active-mcp gem available
|
78
|
+
if ! bundle list | grep -q "rails-active-mcp"; then
|
79
|
+
echo "Error: rails-active-mcp gem not found in bundle" >&2
|
80
|
+
echo "Please run: bundle install" >&2
|
81
|
+
exit 1
|
82
|
+
fi
|
83
|
+
|
84
|
+
# Execute the Rails Active MCP server
|
85
|
+
# Try bundle exec first, then fall back to direct execution
|
86
|
+
if bundle exec rails-active-mcp-server "$@" 2>/dev/null; then
|
87
|
+
exit 0
|
88
|
+
elif command -v rails-active-mcp-server >/dev/null 2>&1; then
|
89
|
+
exec rails-active-mcp-server "$@"
|
90
|
+
else
|
91
|
+
echo "Error: Cannot find rails-active-mcp-server executable" >&2
|
92
|
+
echo "Please ensure the rails-active-mcp gem is properly installed" >&2
|
93
|
+
exit 1
|
94
|
+
fi
|
@@ -8,7 +8,7 @@ module RailsActiveMcp
|
|
8
8
|
attr_accessor :allowed_commands, :command_timeout, :enable_logging, :log_level
|
9
9
|
|
10
10
|
# Safety and execution options
|
11
|
-
attr_accessor :safe_mode, :
|
11
|
+
attr_accessor :safe_mode, :max_results, :log_executions, :audit_file, :enabled
|
12
12
|
attr_accessor :custom_safety_patterns, :allowed_models
|
13
13
|
|
14
14
|
def initialize
|
@@ -24,12 +24,12 @@ module RailsActiveMcp
|
|
24
24
|
|
25
25
|
# Safety and execution defaults
|
26
26
|
@safe_mode = true
|
27
|
-
@default_timeout = 30
|
28
27
|
@max_results = 100
|
29
28
|
@log_executions = false
|
30
29
|
@audit_file = nil
|
31
30
|
@custom_safety_patterns = []
|
32
31
|
@allowed_models = []
|
32
|
+
@enabled = true
|
33
33
|
end
|
34
34
|
|
35
35
|
def model_allowed?(model_name)
|
@@ -44,15 +44,55 @@ module RailsActiveMcp
|
|
44
44
|
[true, false].include?(enable_logging) &&
|
45
45
|
%i[debug info warn error].include?(log_level) &&
|
46
46
|
[true, false].include?(safe_mode) &&
|
47
|
-
default_timeout.is_a?(Numeric) && default_timeout > 0 &&
|
48
47
|
max_results.is_a?(Numeric) && max_results > 0 &&
|
49
48
|
[true, false].include?(log_executions) &&
|
50
49
|
custom_safety_patterns.is_a?(Array) &&
|
51
|
-
allowed_models.is_a?(Array)
|
50
|
+
allowed_models.is_a?(Array) &&
|
51
|
+
[true, false].include?(enabled)
|
52
|
+
end
|
53
|
+
|
54
|
+
def validate?
|
55
|
+
raise ArgumentError, 'allowed_commands must be an array' unless allowed_commands.is_a?(Array)
|
56
|
+
|
57
|
+
raise ArgumentError, 'command_timeout must be positive' unless command_timeout.is_a?(Numeric) && command_timeout > 0
|
58
|
+
|
59
|
+
raise ArgumentError, 'log_level must be one of: debug, info, warn, error' unless %i[debug info warn error].include?(log_level)
|
60
|
+
|
61
|
+
raise ArgumentError, 'safe_mode must be a boolean' unless [true, false].include?(safe_mode)
|
62
|
+
|
63
|
+
raise ArgumentError, 'max_results must be positive' unless max_results.is_a?(Numeric) && max_results > 0
|
64
|
+
|
65
|
+
raise ArgumentError, 'enabled must be a boolean' unless [true, false].include?(enabled)
|
66
|
+
|
67
|
+
true
|
52
68
|
end
|
53
69
|
|
54
70
|
def reset!
|
55
71
|
initialize
|
56
72
|
end
|
73
|
+
|
74
|
+
# Environment-specific configuration presets
|
75
|
+
def production_mode!
|
76
|
+
@safe_mode = true
|
77
|
+
@log_level = :warn
|
78
|
+
@command_timeout = 15
|
79
|
+
@max_results = 50
|
80
|
+
@log_executions = true
|
81
|
+
end
|
82
|
+
|
83
|
+
def development_mode!
|
84
|
+
@safe_mode = false
|
85
|
+
@log_level = :debug
|
86
|
+
@command_timeout = 60
|
87
|
+
@max_results = 200
|
88
|
+
@log_executions = false
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_mode!
|
92
|
+
@safe_mode = true
|
93
|
+
@log_level = :error
|
94
|
+
@command_timeout = 30
|
95
|
+
@log_executions = false
|
96
|
+
end
|
57
97
|
end
|
58
98
|
end
|
@@ -4,9 +4,11 @@ require 'concurrent-ruby'
|
|
4
4
|
require 'rails'
|
5
5
|
|
6
6
|
module RailsActiveMcp
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
7
8
|
class ConsoleExecutor
|
8
9
|
# Thread-safe execution errors
|
9
10
|
class ExecutionError < StandardError; end
|
11
|
+
|
10
12
|
class ThreadSafetyError < StandardError; end
|
11
13
|
|
12
14
|
def initialize(config)
|
@@ -17,13 +19,13 @@ module RailsActiveMcp
|
|
17
19
|
end
|
18
20
|
|
19
21
|
def execute(code, timeout: nil, safe_mode: nil, capture_output: true)
|
20
|
-
timeout ||= @config.
|
22
|
+
timeout ||= @config.command_timeout
|
21
23
|
safe_mode = @config.safe_mode if safe_mode.nil?
|
22
24
|
|
23
25
|
# Pre-execution safety check
|
24
26
|
if safe_mode
|
25
27
|
safety_analysis = @safety_checker.analyze(code)
|
26
|
-
raise SafetyError, "Code failed safety check: #{safety_analysis[:summary]}" unless safety_analysis[:safe]
|
28
|
+
raise RailsActiveMcp::SafetyError, "Code failed safety check: #{safety_analysis[:summary]}" unless safety_analysis[:safe]
|
27
29
|
end
|
28
30
|
|
29
31
|
# Log execution if enabled
|
@@ -34,41 +36,59 @@ module RailsActiveMcp
|
|
34
36
|
|
35
37
|
# Post-execution processing
|
36
38
|
process_result(result)
|
39
|
+
rescue RailsActiveMcp::SafetyError => e
|
40
|
+
{
|
41
|
+
success: false,
|
42
|
+
error: e.message,
|
43
|
+
error_class: 'SafetyError',
|
44
|
+
code: code
|
45
|
+
}
|
37
46
|
end
|
38
47
|
|
39
48
|
def execute_safe_query(model:, method:, args: [], limit: nil)
|
40
49
|
limit ||= @config.max_results
|
41
50
|
|
42
|
-
|
43
|
-
|
51
|
+
begin
|
52
|
+
# Validate model access
|
53
|
+
raise RailsActiveMcp::SafetyError, "Access to model '#{model}' is not allowed" unless @config.model_allowed?(model)
|
44
54
|
|
45
|
-
|
46
|
-
|
55
|
+
# Validate method safety
|
56
|
+
raise RailsActiveMcp::SafetyError, "Method '#{method}' is not allowed for safe queries" unless safe_query_method?(method)
|
47
57
|
|
48
|
-
|
49
|
-
|
50
|
-
|
58
|
+
# Execute with proper Rails executor and connection management
|
59
|
+
execute_with_rails_executor_and_connection do
|
60
|
+
model_class = model.to_s.constantize
|
51
61
|
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
62
|
+
# Build and execute query
|
63
|
+
query = if args.empty?
|
64
|
+
model_class.public_send(method)
|
65
|
+
else
|
66
|
+
model_class.public_send(method, *args)
|
67
|
+
end
|
58
68
|
|
59
|
-
|
60
|
-
|
69
|
+
# Apply limit for enumerable results
|
70
|
+
query = query.limit(limit) if query.respond_to?(:limit) && !count_method?(method)
|
61
71
|
|
62
|
-
|
72
|
+
result = execute_query_with_timeout(query)
|
63
73
|
|
74
|
+
{
|
75
|
+
success: true,
|
76
|
+
model: model,
|
77
|
+
method: method,
|
78
|
+
args: args,
|
79
|
+
result: serialize_result(result),
|
80
|
+
count: calculate_count(result),
|
81
|
+
executed_at: Time.now
|
82
|
+
}
|
83
|
+
end
|
84
|
+
rescue RailsActiveMcp::SafetyError => e
|
64
85
|
{
|
65
|
-
success:
|
86
|
+
success: false,
|
87
|
+
error: e.message,
|
88
|
+
error_class: 'SafetyError',
|
66
89
|
model: model,
|
67
90
|
method: method,
|
68
|
-
args: args
|
69
|
-
result: serialize_result(result),
|
70
|
-
count: calculate_count(result),
|
71
|
-
executed_at: Time.now
|
91
|
+
args: args
|
72
92
|
}
|
73
93
|
rescue StandardError => e
|
74
94
|
log_error(e, { model: model, method: method, args: args })
|
@@ -83,6 +103,86 @@ module RailsActiveMcp
|
|
83
103
|
end
|
84
104
|
end
|
85
105
|
|
106
|
+
def get_model_info(model_name)
|
107
|
+
# Validate model access
|
108
|
+
raise RailsActiveMcp::SafetyError, "Access to model '#{model_name}' is not allowed" unless @config.model_allowed?(model_name)
|
109
|
+
|
110
|
+
begin
|
111
|
+
model_class = model_name.to_s.constantize
|
112
|
+
|
113
|
+
# Ensure it's an ActiveRecord model
|
114
|
+
unless model_class.respond_to?(:columns) && model_class.respond_to?(:reflect_on_all_associations)
|
115
|
+
raise RailsActiveMcp::SafetyError, "#{model_name} is not a valid ActiveRecord model"
|
116
|
+
end
|
117
|
+
|
118
|
+
# Extract model information
|
119
|
+
columns_info = build_model_columns(model_class)
|
120
|
+
|
121
|
+
associations_info = build_model_reflections(model_class)
|
122
|
+
|
123
|
+
validators_info = build_model_validator_info(model_class)
|
124
|
+
|
125
|
+
{
|
126
|
+
success: true,
|
127
|
+
model_name: model_name,
|
128
|
+
table_name: model_class.table_name,
|
129
|
+
primary_key: model_class.primary_key,
|
130
|
+
columns: columns_info,
|
131
|
+
associations: associations_info,
|
132
|
+
validators: validators_info,
|
133
|
+
extracted_at: Time.now
|
134
|
+
}
|
135
|
+
rescue NameError => e
|
136
|
+
{
|
137
|
+
success: false,
|
138
|
+
error: "Model '#{model_name}' not found: #{e.message}",
|
139
|
+
error_class: 'NameError',
|
140
|
+
model_name: model_name
|
141
|
+
}
|
142
|
+
rescue StandardError => e
|
143
|
+
{
|
144
|
+
success: false,
|
145
|
+
error: e.message,
|
146
|
+
error_class: e.class.name,
|
147
|
+
model_name: model_name
|
148
|
+
}
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def build_model_reflections(model_class)
|
153
|
+
model_class.reflect_on_all_associations.map do |association|
|
154
|
+
{
|
155
|
+
name: association.name,
|
156
|
+
type: association.macro,
|
157
|
+
class_name: association.class_name
|
158
|
+
}
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def build_model_columns(model_class)
|
163
|
+
model_class.columns.map do |column|
|
164
|
+
{
|
165
|
+
name: column.name,
|
166
|
+
type: column.type,
|
167
|
+
primary: column.name == model_class.primary_key
|
168
|
+
}
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def build_model_validator_info(model_class)
|
173
|
+
if model_class.respond_to?(:validators)
|
174
|
+
model_class.validators.map do |validator|
|
175
|
+
{
|
176
|
+
type: validator.class.name,
|
177
|
+
attributes: validator.attributes,
|
178
|
+
options: validator.options
|
179
|
+
}
|
180
|
+
end
|
181
|
+
else
|
182
|
+
[]
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
86
186
|
def dry_run(code)
|
87
187
|
# Analyze without executing
|
88
188
|
safety_analysis = @safety_checker.analyze(code)
|
@@ -98,6 +198,21 @@ module RailsActiveMcp
|
|
98
198
|
|
99
199
|
private
|
100
200
|
|
201
|
+
def safe_query_method?(method)
|
202
|
+
# Define safe read-only methods for database queries
|
203
|
+
safe_methods = %w[
|
204
|
+
find find_by find_by! first last
|
205
|
+
where select limit offset order
|
206
|
+
count size length exists? empty?
|
207
|
+
pluck ids maximum minimum average sum
|
208
|
+
group having joins includes
|
209
|
+
readonly distinct unscope
|
210
|
+
all none
|
211
|
+
]
|
212
|
+
|
213
|
+
safe_methods.include?(method.to_s)
|
214
|
+
end
|
215
|
+
|
101
216
|
def execute_with_rails_executor(code, timeout, capture_output)
|
102
217
|
if defined?(Rails) && Rails.application
|
103
218
|
# Handle development mode reloading if needed
|
@@ -129,20 +244,15 @@ module RailsActiveMcp
|
|
129
244
|
|
130
245
|
# Manage ActiveRecord connection pool properly
|
131
246
|
def execute_with_connection_pool(code, timeout, capture_output)
|
132
|
-
if defined?(ActiveRecord::Base)
|
133
|
-
ActiveRecord::Base.connection_pool.with_connection do
|
247
|
+
if defined?(::ActiveRecord::Base)
|
248
|
+
::ActiveRecord::Base.connection_pool.with_connection do
|
134
249
|
execute_with_timeout(code, timeout, capture_output)
|
135
250
|
end
|
136
251
|
else
|
137
252
|
execute_with_timeout(code, timeout, capture_output)
|
138
253
|
end
|
139
254
|
ensure
|
140
|
-
|
141
|
-
if defined?(ActiveRecord::Base)
|
142
|
-
ActiveRecord::Base.clear_active_connections!
|
143
|
-
# Probabilistic garbage collection for long-running processes
|
144
|
-
GC.start if rand(100) < 5
|
145
|
-
end
|
255
|
+
RailsActiveMcp::GarbageCollectionUtils.probalistic_clean!
|
146
256
|
end
|
147
257
|
|
148
258
|
# Helper method for safe queries with proper Rails executor and connection management
|
@@ -151,8 +261,8 @@ module RailsActiveMcp
|
|
151
261
|
if defined?(ActiveSupport::Dependencies) && ActiveSupport::Dependencies.respond_to?(:interlock)
|
152
262
|
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
153
263
|
Rails.application.executor.wrap do
|
154
|
-
if defined?(ActiveRecord::Base)
|
155
|
-
ActiveRecord::Base.connection_pool.with_connection(&block)
|
264
|
+
if defined?(::ActiveRecord::Base)
|
265
|
+
::ActiveRecord::Base.connection_pool.with_connection(&block)
|
156
266
|
else
|
157
267
|
yield
|
158
268
|
end
|
@@ -160,8 +270,8 @@ module RailsActiveMcp
|
|
160
270
|
end
|
161
271
|
else
|
162
272
|
Rails.application.executor.wrap do
|
163
|
-
if defined?(ActiveRecord::Base)
|
164
|
-
ActiveRecord::Base.connection_pool.with_connection(&block)
|
273
|
+
if defined?(::ActiveRecord::Base)
|
274
|
+
::ActiveRecord::Base.connection_pool.with_connection(&block)
|
165
275
|
else
|
166
276
|
yield
|
167
277
|
end
|
@@ -171,12 +281,7 @@ module RailsActiveMcp
|
|
171
281
|
yield
|
172
282
|
end
|
173
283
|
ensure
|
174
|
-
|
175
|
-
if defined?(ActiveRecord::Base)
|
176
|
-
ActiveRecord::Base.clear_active_connections!
|
177
|
-
# Probabilistic garbage collection for long-running processes
|
178
|
-
GC.start if rand(100) < 5
|
179
|
-
end
|
284
|
+
RailsActiveMcp::GarbageCollectionUtils.probalistic_clean!
|
180
285
|
end
|
181
286
|
|
182
287
|
def execute_with_timeout(code, timeout, capture_output)
|
@@ -208,32 +313,39 @@ module RailsActiveMcp
|
|
208
313
|
|
209
314
|
# Execute code
|
210
315
|
start_time = Time.now
|
211
|
-
|
212
|
-
|
213
|
-
|
316
|
+
begin
|
317
|
+
return_value = binding_context.eval(code)
|
318
|
+
rescue SyntaxError => e
|
319
|
+
return {
|
320
|
+
success: false,
|
321
|
+
error: "Syntax Error: #{e.message}",
|
322
|
+
error_class: 'SyntaxError',
|
323
|
+
backtrace: e.backtrace&.first(10),
|
324
|
+
code: code,
|
325
|
+
output: nil
|
326
|
+
}
|
327
|
+
end
|
214
328
|
output = captured_output.string
|
215
329
|
errors = captured_errors.string
|
216
330
|
|
217
331
|
# Combine output and errors for comprehensive result
|
218
332
|
combined_output = [output, errors].reject(&:empty?).join("\n")
|
219
|
-
|
220
333
|
{
|
221
334
|
success: true,
|
222
335
|
return_value: return_value,
|
223
336
|
output: combined_output,
|
224
337
|
return_value_string: safe_inspect(return_value),
|
225
|
-
execution_time:
|
338
|
+
execution_time: Time.now - start_time,
|
226
339
|
code: code
|
227
340
|
}
|
228
341
|
rescue StandardError => e
|
229
|
-
execution_time = Time.now - start_time if
|
342
|
+
execution_time = Time.now - start_time if start_time.present?
|
230
343
|
errors = captured_errors.string
|
231
|
-
|
232
344
|
{
|
233
345
|
success: false,
|
234
|
-
error: e
|
346
|
+
error: e&.message || 'Unknown error',
|
235
347
|
error_class: e.class.name,
|
236
|
-
backtrace: e
|
348
|
+
backtrace: e&.backtrace&.first(10) || [],
|
237
349
|
execution_time: execution_time,
|
238
350
|
code: code,
|
239
351
|
stderr: errors.empty? ? nil : errors
|
@@ -260,7 +372,7 @@ module RailsActiveMcp
|
|
260
372
|
code: code
|
261
373
|
}
|
262
374
|
rescue StandardError => e
|
263
|
-
execution_time = Time.now - start_time if
|
375
|
+
execution_time = Time.now - start_time if start_time.present?
|
264
376
|
|
265
377
|
{
|
266
378
|
success: false,
|
@@ -273,8 +385,8 @@ module RailsActiveMcp
|
|
273
385
|
end
|
274
386
|
|
275
387
|
def execute_query_with_timeout(query)
|
276
|
-
Timeout.timeout(@config.
|
277
|
-
if query.is_a?(ActiveRecord::Relation)
|
388
|
+
Timeout.timeout(@config.command_timeout) do
|
389
|
+
if defined?(::ActiveRecord::Relation) && query.is_a?(::ActiveRecord::Relation)
|
278
390
|
query.to_a
|
279
391
|
else
|
280
392
|
query
|
@@ -315,15 +427,15 @@ module RailsActiveMcp
|
|
315
427
|
|
316
428
|
# Add common console helpers (thread-safe)
|
317
429
|
def sql(query)
|
318
|
-
raise NoMethodError, 'ActiveRecord not available' unless defined?(ActiveRecord::Base)
|
430
|
+
raise NoMethodError, 'ActiveRecord not available' unless defined?(::ActiveRecord::Base)
|
319
431
|
|
320
|
-
ActiveRecord::Base.connection.select_all(query).to_a
|
432
|
+
::ActiveRecord::Base.connection.select_all(query).to_a
|
321
433
|
end
|
322
434
|
|
323
435
|
def schema(table_name)
|
324
|
-
raise NoMethodError, 'ActiveRecord not available' unless defined?(ActiveRecord::Base)
|
436
|
+
raise NoMethodError, 'ActiveRecord not available' unless defined?(::ActiveRecord::Base)
|
325
437
|
|
326
|
-
ActiveRecord::Base.connection.columns(table_name)
|
438
|
+
::ActiveRecord::Base.connection.columns(table_name)
|
327
439
|
end
|
328
440
|
end
|
329
441
|
|
@@ -336,40 +448,28 @@ module RailsActiveMcp
|
|
336
448
|
create_thread_safe_console_binding
|
337
449
|
end
|
338
450
|
|
339
|
-
def safe_query_method?(method)
|
340
|
-
safe_methods = %w[
|
341
|
-
find find_by find_each find_in_batches
|
342
|
-
where all first last take
|
343
|
-
count sum average maximum minimum size length
|
344
|
-
pluck ids exists? empty? any? many?
|
345
|
-
select distinct group order limit offset
|
346
|
-
includes joins left_joins preload eager_load
|
347
|
-
to_a to_sql explain inspect as_json to_json
|
348
|
-
attributes attribute_names column_names
|
349
|
-
model_name table_name primary_key
|
350
|
-
]
|
351
|
-
safe_methods.include?(method.to_s)
|
352
|
-
end
|
353
|
-
|
354
451
|
def count_method?(method)
|
355
452
|
%w[count sum average maximum minimum size length].include?(method.to_s)
|
356
453
|
end
|
357
454
|
|
358
455
|
def serialize_result(result)
|
359
456
|
case result
|
360
|
-
when ActiveRecord::Base
|
361
|
-
result.attributes.merge(_model_class: result.class.name)
|
362
457
|
when Array
|
363
458
|
limited_result = result.first(@config.max_results)
|
364
459
|
limited_result.map { |item| serialize_result(item) }
|
365
|
-
when ActiveRecord::Relation
|
366
|
-
serialize_result(result.to_a)
|
367
460
|
when Hash
|
368
461
|
result
|
369
462
|
when Numeric, String, TrueClass, FalseClass, NilClass
|
370
463
|
result
|
371
464
|
else
|
372
|
-
|
465
|
+
# Handle ActiveRecord objects if available
|
466
|
+
if defined?(::ActiveRecord::Base) && result.is_a?(::ActiveRecord::Base)
|
467
|
+
result.attributes.merge(_model_class: result.class.name)
|
468
|
+
elsif defined?(::ActiveRecord::Relation) && result.is_a?(::ActiveRecord::Relation)
|
469
|
+
serialize_result(result.to_a)
|
470
|
+
else
|
471
|
+
safe_inspect(result)
|
472
|
+
end
|
373
473
|
end
|
374
474
|
end
|
375
475
|
|
@@ -377,12 +477,15 @@ module RailsActiveMcp
|
|
377
477
|
case result
|
378
478
|
when Array
|
379
479
|
result.size
|
380
|
-
when ActiveRecord::Relation
|
381
|
-
result.count
|
382
480
|
when Numeric
|
383
481
|
1
|
384
482
|
else
|
385
|
-
|
483
|
+
# Handle ActiveRecord::Relation if available
|
484
|
+
if defined?(::ActiveRecord::Relation) && result.is_a?(::ActiveRecord::Relation)
|
485
|
+
result.count
|
486
|
+
else
|
487
|
+
1
|
488
|
+
end
|
386
489
|
end
|
387
490
|
end
|
388
491
|
|
@@ -430,9 +533,7 @@ module RailsActiveMcp
|
|
430
533
|
end
|
431
534
|
end
|
432
535
|
|
433
|
-
unless safety_analysis[:read_only]
|
434
|
-
recommendations << 'Consider using the safe_query tool for read-only operations'
|
435
|
-
end
|
536
|
+
recommendations << 'Consider using the safe_query tool for read-only operations' unless safety_analysis[:read_only]
|
436
537
|
|
437
538
|
recommendations
|
438
539
|
end
|
@@ -499,4 +600,5 @@ module RailsActiveMcp
|
|
499
600
|
RailsActiveMcp.logger.warn "Failed to reload in development: #{e.message}" if defined?(RailsActiveMcp.logger)
|
500
601
|
end
|
501
602
|
end
|
603
|
+
# rubocop:enable Metrics/ClassLength
|
502
604
|
end
|
@@ -25,9 +25,9 @@ module RailsActiveMcp
|
|
25
25
|
end
|
26
26
|
else
|
27
27
|
# Fallback to our own logger if Rails logger is not available
|
28
|
-
Logger.new(
|
28
|
+
Logger.new($stderr).tap do |logger|
|
29
29
|
logger.level = Rails.env.production? ? Logger::WARN : Logger::INFO
|
30
|
-
logger.formatter = proc do |severity, datetime,
|
30
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
31
31
|
"[#{datetime}] #{severity} -- RailsActiveMcp: #{msg}\n"
|
32
32
|
end
|
33
33
|
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module RailsActiveMcp
|
2
|
+
class GarbageCollectionUtils
|
3
|
+
# Probabilistic garbage collection for long-running processes
|
4
|
+
# Not sure if this is the best approach but it's a quick common pattern that works
|
5
|
+
def self.probalistic_clean!
|
6
|
+
return unless defined?(::ActiveRecord::Base)
|
7
|
+
|
8
|
+
# Clean up connections to prevent pool exhaustion
|
9
|
+
::ActiveRecord::Base.clear_active_connections!
|
10
|
+
GC.start if rand(100) < 5
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -54,7 +54,6 @@ module RailsActiveMcp
|
|
54
54
|
end
|
55
55
|
|
56
56
|
def safe?(code)
|
57
|
-
|
58
57
|
analysis = analyze(code)
|
59
58
|
analysis[:safe]
|
60
59
|
end
|
@@ -85,8 +84,21 @@ module RailsActiveMcp
|
|
85
84
|
critical_violations = violations.select { |v| v[:severity] == :critical }
|
86
85
|
high_violations = violations.select { |v| v[:severity] == :high }
|
87
86
|
|
88
|
-
|
89
|
-
|
87
|
+
# In safe mode: allow code with no critical/high violations
|
88
|
+
# For database operations, also require read_only patterns
|
89
|
+
# For simple Ruby code (no database patterns), allow if no dangerous violations
|
90
|
+
if @config.safe_mode
|
91
|
+
has_database_operations = code.match?(/\b(User|Model|ActiveRecord|\.where|\.find|\.create|\.update|\.delete)\b/)
|
92
|
+
safe = if has_database_operations
|
93
|
+
read_only && critical_violations.empty? && high_violations.empty?
|
94
|
+
else
|
95
|
+
# Simple Ruby code - just check for dangerous patterns
|
96
|
+
critical_violations.empty? && high_violations.empty?
|
97
|
+
end
|
98
|
+
else
|
99
|
+
# Not in safe mode - only block critical violations
|
100
|
+
safe = critical_violations.empty?
|
101
|
+
end
|
90
102
|
|
91
103
|
{
|
92
104
|
safe: safe,
|
@@ -136,10 +148,8 @@ module RailsActiveMcp
|
|
136
148
|
read_only ? 'Code appears safe and read-only' : 'Code appears safe'
|
137
149
|
else
|
138
150
|
severity_counts = violations.group_by { |v| v[:severity] }.transform_values(&:count)
|
139
|
-
parts =
|
140
|
-
|
141
|
-
severity_counts.each do |severity, count|
|
142
|
-
parts << "#{count} #{severity} violation#{'s' if count > 1}"
|
151
|
+
parts = severity_counts.map do |severity, count|
|
152
|
+
"#{count} #{severity} violation#{'s' if count > 1}"
|
143
153
|
end
|
144
154
|
|
145
155
|
"Found #{parts.join(', ')}"
|