rails-active-mcp 0.1.1
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/.idea/.gitignore +8 -0
- data/.idea/discord.xml +7 -0
- data/.idea/modules.xml +8 -0
- data/.idea/rails-active-mcp-gem.iml +111 -0
- data/.idea/vcs.xml +6 -0
- data/README.md +369 -0
- data/changelog.md +59 -0
- data/docs/README.md +185 -0
- data/exe/rails-active-mcp-server +24 -0
- data/lib/generators/rails_active_mcp/install/install_generator.rb +37 -0
- data/lib/generators/rails_active_mcp/install/templates/README.md +60 -0
- data/lib/generators/rails_active_mcp/install/templates/initializer.rb +39 -0
- data/lib/generators/rails_active_mcp/install/templates/mcp.ru +7 -0
- data/lib/rails_active_mcp/configuration.rb +95 -0
- data/lib/rails_active_mcp/console_executor.rb +378 -0
- data/lib/rails_active_mcp/engine.rb +32 -0
- data/lib/rails_active_mcp/mcp_server.rb +374 -0
- data/lib/rails_active_mcp/railtie.rb +48 -0
- data/lib/rails_active_mcp/safety_checker.rb +149 -0
- data/lib/rails_active_mcp/tasks.rake +154 -0
- data/lib/rails_active_mcp/tools/console_execute_tool.rb +61 -0
- data/lib/rails_active_mcp/tools/dry_run_tool.rb +41 -0
- data/lib/rails_active_mcp/tools/model_info_tool.rb +70 -0
- data/lib/rails_active_mcp/tools/safe_query_tool.rb +41 -0
- data/lib/rails_active_mcp/version.rb +5 -0
- data/lib/rails_active_mcp.rb +59 -0
- data/mcp.ru +5 -0
- data/rails_active_mcp.gemspec +49 -0
- metadata +241 -0
@@ -0,0 +1,378 @@
|
|
1
|
+
require 'timeout'
|
2
|
+
require 'stringio'
|
3
|
+
require 'concurrent-ruby'
|
4
|
+
require 'rails'
|
5
|
+
|
6
|
+
module RailsActiveMcp
|
7
|
+
class ConsoleExecutor
|
8
|
+
def initialize(config)
|
9
|
+
@config = config
|
10
|
+
@safety_checker = SafetyChecker.new(config)
|
11
|
+
end
|
12
|
+
|
13
|
+
def execute(code, timeout: nil, safe_mode: nil, capture_output: true)
|
14
|
+
timeout ||= @config.default_timeout
|
15
|
+
safe_mode = @config.safe_mode if safe_mode.nil?
|
16
|
+
|
17
|
+
# Pre-execution safety check
|
18
|
+
if safe_mode
|
19
|
+
safety_analysis = @safety_checker.analyze(code)
|
20
|
+
unless safety_analysis[:safe]
|
21
|
+
raise SafetyError, "Code failed safety check: #{safety_analysis[:summary]}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Log execution if enabled
|
26
|
+
log_execution(code) if @config.log_executions
|
27
|
+
|
28
|
+
# Execute with timeout and output capture
|
29
|
+
result = execute_with_timeout(code, timeout, capture_output)
|
30
|
+
|
31
|
+
# Post-execution processing
|
32
|
+
process_result(result)
|
33
|
+
end
|
34
|
+
|
35
|
+
def execute_safe_query(model:, method:, args: [], limit: nil)
|
36
|
+
limit ||= @config.max_results
|
37
|
+
|
38
|
+
# Validate model access
|
39
|
+
unless @config.model_allowed?(model)
|
40
|
+
raise SafetyError, "Access to model '#{model}' is not allowed"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Validate method safety
|
44
|
+
unless safe_query_method?(method)
|
45
|
+
raise SafetyError, "Method '#{method}' is not allowed for safe queries"
|
46
|
+
end
|
47
|
+
|
48
|
+
begin
|
49
|
+
model_class = model.to_s.constantize
|
50
|
+
|
51
|
+
# Build and execute query
|
52
|
+
query = if args.empty?
|
53
|
+
model_class.public_send(method)
|
54
|
+
else
|
55
|
+
model_class.public_send(method, *args)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Apply limit for enumerable results
|
59
|
+
if query.respond_to?(:limit) && !count_method?(method)
|
60
|
+
query = query.limit(limit)
|
61
|
+
end
|
62
|
+
|
63
|
+
result = execute_query_with_timeout(query)
|
64
|
+
|
65
|
+
{
|
66
|
+
success: true,
|
67
|
+
model: model,
|
68
|
+
method: method,
|
69
|
+
args: args,
|
70
|
+
result: serialize_result(result),
|
71
|
+
count: calculate_count(result),
|
72
|
+
executed_at: Time.now
|
73
|
+
}
|
74
|
+
rescue => e
|
75
|
+
log_error(e, { model: model, method: method, args: args })
|
76
|
+
{
|
77
|
+
success: false,
|
78
|
+
error: e.message,
|
79
|
+
error_class: e.class.name,
|
80
|
+
model: model,
|
81
|
+
method: method,
|
82
|
+
args: args
|
83
|
+
}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def dry_run(code)
|
88
|
+
# Analyze without executing
|
89
|
+
safety_analysis = @safety_checker.analyze(code)
|
90
|
+
|
91
|
+
{
|
92
|
+
code: code,
|
93
|
+
safety_analysis: safety_analysis,
|
94
|
+
would_execute: safety_analysis[:safe] || !@config.safe_mode,
|
95
|
+
estimated_risk: estimate_risk(safety_analysis),
|
96
|
+
recommendations: generate_recommendations(safety_analysis)
|
97
|
+
}
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def execute_with_timeout(code, timeout, capture_output)
|
103
|
+
Timeout.timeout(timeout) do
|
104
|
+
if capture_output
|
105
|
+
execute_with_captured_output(code)
|
106
|
+
else
|
107
|
+
execute_direct(code)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
rescue Timeout::Error
|
111
|
+
raise TimeoutError, "Execution timed out after #{timeout} seconds"
|
112
|
+
end
|
113
|
+
|
114
|
+
def execute_with_captured_output(code)
|
115
|
+
# Capture both stdout and the return value
|
116
|
+
old_stdout = $stdout
|
117
|
+
captured_output = StringIO.new
|
118
|
+
$stdout = captured_output
|
119
|
+
|
120
|
+
# Create execution context
|
121
|
+
binding_context = create_console_binding
|
122
|
+
|
123
|
+
# Execute code
|
124
|
+
start_time = Time.now
|
125
|
+
return_value = binding_context.eval(code)
|
126
|
+
execution_time = Time.now - start_time
|
127
|
+
|
128
|
+
output = captured_output.string
|
129
|
+
$stdout = old_stdout
|
130
|
+
|
131
|
+
{
|
132
|
+
success: true,
|
133
|
+
return_value: return_value,
|
134
|
+
output: output,
|
135
|
+
return_value_string: safe_inspect(return_value),
|
136
|
+
execution_time: execution_time,
|
137
|
+
code: code
|
138
|
+
}
|
139
|
+
rescue => e
|
140
|
+
$stdout = old_stdout if old_stdout
|
141
|
+
execution_time = Time.now - start_time if defined?(start_time)
|
142
|
+
|
143
|
+
{
|
144
|
+
success: false,
|
145
|
+
error: e.message,
|
146
|
+
error_class: e.class.name,
|
147
|
+
backtrace: e.backtrace&.first(10),
|
148
|
+
execution_time: execution_time,
|
149
|
+
code: code
|
150
|
+
}
|
151
|
+
end
|
152
|
+
|
153
|
+
def execute_direct(code)
|
154
|
+
binding_context = create_console_binding
|
155
|
+
start_time = Time.now
|
156
|
+
|
157
|
+
result = binding_context.eval(code)
|
158
|
+
execution_time = Time.now - start_time
|
159
|
+
|
160
|
+
{
|
161
|
+
success: true,
|
162
|
+
return_value: result,
|
163
|
+
execution_time: execution_time,
|
164
|
+
code: code
|
165
|
+
}
|
166
|
+
rescue => e
|
167
|
+
execution_time = Time.now - start_time if defined?(start_time)
|
168
|
+
|
169
|
+
{
|
170
|
+
success: false,
|
171
|
+
error: e.message,
|
172
|
+
error_class: e.class.name,
|
173
|
+
backtrace: e.backtrace&.first(10),
|
174
|
+
execution_time: execution_time,
|
175
|
+
code: code
|
176
|
+
}
|
177
|
+
end
|
178
|
+
|
179
|
+
def execute_query_with_timeout(query)
|
180
|
+
Timeout.timeout(@config.default_timeout) do
|
181
|
+
if query.is_a?(ActiveRecord::Relation)
|
182
|
+
query.to_a
|
183
|
+
else
|
184
|
+
query
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def create_console_binding
|
190
|
+
# Create a clean binding with Rails console helpers
|
191
|
+
console_context = Object.new
|
192
|
+
|
193
|
+
console_context.instance_eval do
|
194
|
+
# Add Rails helpers if available
|
195
|
+
if defined?(Rails) && Rails.application
|
196
|
+
extend Rails.application.routes.url_helpers if Rails.application.routes
|
197
|
+
|
198
|
+
def reload!
|
199
|
+
Rails.application.reloader.reload!
|
200
|
+
"Reloaded!"
|
201
|
+
end
|
202
|
+
|
203
|
+
def app
|
204
|
+
Rails.application
|
205
|
+
end
|
206
|
+
|
207
|
+
def helper
|
208
|
+
ApplicationController.helpers if defined?(ApplicationController)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
# Add common console helpers
|
213
|
+
def sql(query)
|
214
|
+
ActiveRecord::Base.connection.select_all(query).to_a
|
215
|
+
end
|
216
|
+
|
217
|
+
def schema(table_name)
|
218
|
+
ActiveRecord::Base.connection.columns(table_name)
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
console_context.instance_eval { binding }
|
223
|
+
end
|
224
|
+
|
225
|
+
def safe_query_method?(method)
|
226
|
+
safe_methods = %w[
|
227
|
+
find find_by find_each find_in_batches
|
228
|
+
where all first last take
|
229
|
+
count sum average maximum minimum size length
|
230
|
+
pluck ids exists? empty? any? many?
|
231
|
+
select distinct group order limit offset
|
232
|
+
includes joins left_joins preload eager_load
|
233
|
+
to_a to_sql explain inspect as_json to_json
|
234
|
+
attributes attribute_names column_names
|
235
|
+
model_name table_name primary_key
|
236
|
+
]
|
237
|
+
safe_methods.include?(method.to_s)
|
238
|
+
end
|
239
|
+
|
240
|
+
def count_method?(method)
|
241
|
+
%w[count sum average maximum minimum size length].include?(method.to_s)
|
242
|
+
end
|
243
|
+
|
244
|
+
def serialize_result(result)
|
245
|
+
case result
|
246
|
+
when ActiveRecord::Base
|
247
|
+
result.attributes.merge(_model_class: result.class.name)
|
248
|
+
when Array
|
249
|
+
limited_result = result.first(@config.max_results)
|
250
|
+
limited_result.map { |item| serialize_result(item) }
|
251
|
+
when ActiveRecord::Relation
|
252
|
+
serialize_result(result.to_a)
|
253
|
+
when Hash
|
254
|
+
result
|
255
|
+
when Numeric, String, TrueClass, FalseClass, NilClass
|
256
|
+
result
|
257
|
+
else
|
258
|
+
safe_inspect(result)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
def calculate_count(result)
|
263
|
+
case result
|
264
|
+
when Array
|
265
|
+
result.size
|
266
|
+
when ActiveRecord::Relation
|
267
|
+
result.count
|
268
|
+
when Numeric
|
269
|
+
1
|
270
|
+
else
|
271
|
+
1
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def safe_inspect(object)
|
276
|
+
object.inspect
|
277
|
+
rescue => e
|
278
|
+
"#<#{object.class}:0x#{object.object_id.to_s(16)} (inspect failed: #{e.message})>"
|
279
|
+
end
|
280
|
+
|
281
|
+
def process_result(result)
|
282
|
+
# Apply max results limit to output
|
283
|
+
if result[:success] && result[:return_value].is_a?(Array)
|
284
|
+
if result[:return_value].size > @config.max_results
|
285
|
+
result[:return_value] = result[:return_value].first(@config.max_results)
|
286
|
+
result[:truncated] = true
|
287
|
+
result[:note] = "Result truncated to #{@config.max_results} items"
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
result
|
292
|
+
end
|
293
|
+
|
294
|
+
def estimate_risk(safety_analysis)
|
295
|
+
return :low if safety_analysis[:safe]
|
296
|
+
|
297
|
+
critical_count = safety_analysis[:violations].count { |v| v[:severity] == :critical }
|
298
|
+
high_count = safety_analysis[:violations].count { |v| v[:severity] == :high }
|
299
|
+
|
300
|
+
if critical_count > 0
|
301
|
+
:critical
|
302
|
+
elsif high_count > 0
|
303
|
+
:high
|
304
|
+
else
|
305
|
+
:medium
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
def generate_recommendations(safety_analysis)
|
310
|
+
recommendations = []
|
311
|
+
|
312
|
+
if safety_analysis[:violations].any?
|
313
|
+
recommendations << "Consider using read-only alternatives"
|
314
|
+
recommendations << "Review the code for unintended side effects"
|
315
|
+
|
316
|
+
if safety_analysis[:violations].any? { |v| v[:severity] == :critical }
|
317
|
+
recommendations << "This code contains critical safety violations and should not be executed"
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
unless safety_analysis[:read_only]
|
322
|
+
recommendations << "Consider using the safe_query tool for read-only operations"
|
323
|
+
end
|
324
|
+
|
325
|
+
recommendations
|
326
|
+
end
|
327
|
+
|
328
|
+
def log_execution(code)
|
329
|
+
return unless @config.audit_file
|
330
|
+
|
331
|
+
log_entry = {
|
332
|
+
timestamp: Time.now.iso8601,
|
333
|
+
code: code,
|
334
|
+
user: current_user_info,
|
335
|
+
safety_check: @safety_checker.analyze(code)
|
336
|
+
}
|
337
|
+
|
338
|
+
File.open(@config.audit_file, 'a') do |f|
|
339
|
+
f.puts(JSON.generate(log_entry))
|
340
|
+
end
|
341
|
+
rescue => e
|
342
|
+
# Don't fail execution due to logging issues
|
343
|
+
Rails.logger.warn "Failed to log Rails Active MCP execution: #{e.message}" if defined?(Rails)
|
344
|
+
end
|
345
|
+
|
346
|
+
def log_error(error, context = {})
|
347
|
+
return unless @config.audit_file
|
348
|
+
|
349
|
+
log_entry = {
|
350
|
+
timestamp: Time.now.iso8601,
|
351
|
+
type: 'error',
|
352
|
+
error: error.message,
|
353
|
+
error_class: error.class.name,
|
354
|
+
context: context,
|
355
|
+
user: current_user_info
|
356
|
+
}
|
357
|
+
|
358
|
+
File.open(@config.audit_file, 'a') do |f|
|
359
|
+
f.puts(JSON.generate(log_entry))
|
360
|
+
end
|
361
|
+
rescue
|
362
|
+
# Silently fail logging
|
363
|
+
end
|
364
|
+
|
365
|
+
def current_user_info
|
366
|
+
# Try to extract user info from various sources
|
367
|
+
if defined?(Current) && Current.respond_to?(:user) && Current.user
|
368
|
+
{ id: Current.user.id, email: Current.user.email }
|
369
|
+
elsif defined?(Rails) && Rails.env.development?
|
370
|
+
{ environment: 'development' }
|
371
|
+
else
|
372
|
+
{ environment: Rails.env }
|
373
|
+
end
|
374
|
+
rescue
|
375
|
+
{ unknown: true }
|
376
|
+
end
|
377
|
+
end
|
378
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsActiveMcp
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace RailsActiveMcp
|
6
|
+
|
7
|
+
config.rails_active_mcp = ActiveSupport::OrderedOptions.new
|
8
|
+
|
9
|
+
initializer 'rails_active_mcp.configure' do |app|
|
10
|
+
# Load configuration from Rails config if present
|
11
|
+
if app.config.respond_to?(:rails_active_mcp)
|
12
|
+
RailsActiveMcp.configure do |config|
|
13
|
+
app.config.rails_active_mcp.each do |key, value|
|
14
|
+
config.public_send("#{key}=", value) if config.respond_to?("#{key}=")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Set default audit file location
|
20
|
+
RailsActiveMcp.config.audit_file ||= Rails.root.join('log', 'rails_active_mcp.log')
|
21
|
+
|
22
|
+
# Validate configuration
|
23
|
+
RailsActiveMcp.config.validate!
|
24
|
+
end
|
25
|
+
|
26
|
+
# Add our tools directory to the load path
|
27
|
+
config.autoload_paths << root.join('lib', 'rails_active_mcp', 'tools')
|
28
|
+
|
29
|
+
# Ensure our tools are eager loaded in production
|
30
|
+
config.eager_load_paths << root.join('lib', 'rails_active_mcp', 'tools')
|
31
|
+
end
|
32
|
+
end
|