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.
@@ -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