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,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rack'
5
+
6
+ module RailsActiveMcp
7
+ class McpServer
8
+ JSONRPC_VERSION = '2.0'
9
+ MCP_VERSION = '2025-06-18'
10
+
11
+ def initialize(app = nil)
12
+ @app = app
13
+ @tools = {}
14
+ @resources = {}
15
+ register_default_tools
16
+ end
17
+
18
+ def call(env)
19
+ request = Rack::Request.new(env)
20
+
21
+ return [405, {}, ['Method Not Allowed']] unless request.post?
22
+ return [400, {}, ['Invalid Content-Type']] unless json_request?(request)
23
+
24
+ begin
25
+ body = request.body.read
26
+ data = JSON.parse(body)
27
+ response = handle_jsonrpc_request(data)
28
+
29
+ [200, {'Content-Type' => 'application/json'}, [response.to_json]]
30
+ rescue JSON::ParserError
31
+ error_response(400, 'Invalid JSON')
32
+ rescue => e
33
+ Rails.logger.error "MCP Server Error: #{e.message}" if defined?(Rails)
34
+ error_response(500, 'Internal Server Error')
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def json_request?(request)
41
+ request.content_type&.include?('application/json')
42
+ end
43
+
44
+ def handle_jsonrpc_request(data)
45
+ case data['method']
46
+ when 'initialize'
47
+ handle_initialize(data)
48
+ when 'tools/list'
49
+ handle_tools_list(data)
50
+ when 'tools/call'
51
+ handle_tools_call(data)
52
+ when 'resources/list'
53
+ handle_resources_list(data)
54
+ when 'resources/read'
55
+ handle_resources_read(data)
56
+ else
57
+ jsonrpc_error(data['id'], -32601, 'Method not found')
58
+ end
59
+ end
60
+
61
+ def handle_initialize(data)
62
+ {
63
+ jsonrpc: JSONRPC_VERSION,
64
+ id: data['id'],
65
+ result: {
66
+ protocolVersion: MCP_VERSION,
67
+ capabilities: {
68
+ tools: {
69
+ list: true,
70
+ call: true
71
+ },
72
+ resources: {
73
+ read: true,
74
+ list: true
75
+ }
76
+ },
77
+ serverInfo: {
78
+ name: 'rails-active-mcp',
79
+ version: RailsActiveMcp::VERSION
80
+ }
81
+ }
82
+ }
83
+ end
84
+
85
+ def handle_tools_list(data)
86
+ tools_array = @tools.values.map do |tool|
87
+ tool_def = {
88
+ name: tool[:name],
89
+ description: tool[:description],
90
+ inputSchema: tool[:input_schema]
91
+ }
92
+
93
+ # Add annotations if present
94
+ if tool[:annotations] && !tool[:annotations].empty?
95
+ tool_def[:annotations] = tool[:annotations]
96
+ end
97
+
98
+ tool_def
99
+ end
100
+
101
+ {
102
+ jsonrpc: JSONRPC_VERSION,
103
+ id: data['id'],
104
+ result: { tools: tools_array }
105
+ }
106
+ end
107
+
108
+ def handle_tools_call(data)
109
+ tool_name = data.dig('params', 'name')
110
+ arguments = data.dig('params', 'arguments') || {}
111
+
112
+ tool = @tools[tool_name]
113
+ return jsonrpc_error(data['id'], -32602, "Tool '#{tool_name}' not found") unless tool
114
+
115
+ begin
116
+ result = tool[:handler].call(arguments)
117
+ {
118
+ jsonrpc: JSONRPC_VERSION,
119
+ id: data['id'],
120
+ result: { content: [{ type: 'text', text: result.to_s }] }
121
+ }
122
+ rescue => e
123
+ jsonrpc_error(data['id'], -32603, "Tool execution failed: #{e.message}")
124
+ end
125
+ end
126
+
127
+ def handle_resources_list(data)
128
+ {
129
+ jsonrpc: JSONRPC_VERSION,
130
+ id: data['id'],
131
+ result: { resources: [] }
132
+ }
133
+ end
134
+
135
+ def handle_resources_read(data)
136
+ {
137
+ jsonrpc: JSONRPC_VERSION,
138
+ id: data['id'],
139
+ result: { contents: [] }
140
+ }
141
+ end
142
+
143
+ def register_tool(name, description, input_schema, annotations = {}, &handler)
144
+ @tools[name] = {
145
+ name: name,
146
+ description: description,
147
+ input_schema: input_schema,
148
+ annotations: annotations,
149
+ handler: handler
150
+ }
151
+ end
152
+
153
+ def register_default_tools
154
+ register_tool(
155
+ 'rails_console_execute',
156
+ 'Execute Ruby code in Rails console context',
157
+ {
158
+ type: 'object',
159
+ properties: {
160
+ code: { type: 'string', description: 'Ruby code to execute' },
161
+ timeout: { type: 'number', description: 'Timeout in seconds', default: 30 },
162
+ safe_mode: { type: 'boolean', description: 'Enable safety checks', default: true },
163
+ capture_output: { type: 'boolean', description: 'Capture console output', default: true }
164
+ },
165
+ required: ['code']
166
+ },
167
+ # Add MCP tool annotations
168
+ {
169
+ title: 'Rails Console Executor',
170
+ readOnlyHint: false,
171
+ destructiveHint: true,
172
+ idempotentHint: false,
173
+ openWorldHint: false
174
+ }
175
+ ) do |args|
176
+ execute_console_code(args)
177
+ end
178
+
179
+ register_tool(
180
+ 'rails_model_info',
181
+ 'Get information about Rails models',
182
+ {
183
+ type: 'object',
184
+ properties: {
185
+ model_name: { type: 'string', description: 'Name of the model to inspect' }
186
+ },
187
+ required: ['model_name']
188
+ },
189
+ # Safe read-only tool
190
+ {
191
+ title: 'Rails Model Inspector',
192
+ readOnlyHint: true,
193
+ destructiveHint: false,
194
+ idempotentHint: true,
195
+ openWorldHint: false
196
+ }
197
+ ) do |args|
198
+ get_model_info(args['model_name'])
199
+ end
200
+
201
+ register_tool(
202
+ 'rails_safe_query',
203
+ 'Execute safe read-only database queries',
204
+ {
205
+ type: 'object',
206
+ properties: {
207
+ query: { type: 'string', description: 'Safe query to execute' },
208
+ model: { type: 'string', description: 'Model class name' }
209
+ },
210
+ required: ['query', 'model']
211
+ },
212
+ # Safe read-only query tool
213
+ {
214
+ title: 'Rails Safe Query Executor',
215
+ readOnlyHint: true,
216
+ destructiveHint: false,
217
+ idempotentHint: true,
218
+ openWorldHint: false
219
+ }
220
+ ) do |args|
221
+ execute_safe_query(args)
222
+ end
223
+
224
+ register_tool(
225
+ 'rails_dry_run',
226
+ 'Analyze Ruby code safety without execution',
227
+ {
228
+ type: 'object',
229
+ properties: {
230
+ code: { type: 'string', description: 'Ruby code to analyze' }
231
+ },
232
+ required: ['code']
233
+ },
234
+ {
235
+ title: 'Rails Code Safety Analyzer',
236
+ readOnlyHint: true,
237
+ destructiveHint: false,
238
+ idempotentHint: true,
239
+ openWorldHint: false
240
+ }
241
+ ) do |args|
242
+ dry_run_analysis(args['code'])
243
+ end
244
+ end
245
+
246
+ def execute_console_code(args)
247
+ return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
248
+
249
+ executor = RailsActiveMcp::ConsoleExecutor.new(RailsActiveMcp.config)
250
+
251
+ begin
252
+ result = executor.execute(
253
+ args['code'],
254
+ timeout: args['timeout'] || 30,
255
+ safe_mode: args['safe_mode'] != false,
256
+ capture_output: args['capture_output'] != false
257
+ )
258
+
259
+ if result[:success]
260
+ format_success_result(result)
261
+ else
262
+ "Error: #{result[:error]} (#{result[:error_class]})"
263
+ end
264
+ rescue RailsActiveMcp::SafetyError => e
265
+ "Safety check failed: #{e.message}"
266
+ rescue RailsActiveMcp::TimeoutError => e
267
+ "Execution timed out: #{e.message}"
268
+ rescue => e
269
+ "Execution failed: #{e.message}"
270
+ end
271
+ end
272
+
273
+ def get_model_info(model_name)
274
+ return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
275
+
276
+ begin
277
+ model_class = model_name.constantize
278
+ return "#{model_name} is not an ActiveRecord model" unless model_class < ActiveRecord::Base
279
+
280
+ info = []
281
+ info << "Model: #{model_class.name}"
282
+ info << "Table: #{model_class.table_name}"
283
+ info << "Columns: #{model_class.column_names.join(', ')}"
284
+ info << "Associations: #{model_class.reflect_on_all_associations.map(&:name).join(', ')}"
285
+ info.join("\n")
286
+ rescue NameError
287
+ "Model '#{model_name}' not found"
288
+ rescue => e
289
+ "Error getting model info: #{e.message}"
290
+ end
291
+ end
292
+
293
+ def execute_safe_query(args)
294
+ return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
295
+
296
+ begin
297
+ model_class = args['model'].constantize
298
+ return "#{args['model']} is not an ActiveRecord model" unless model_class < ActiveRecord::Base
299
+
300
+ # Only allow safe read-only methods
301
+ safe_methods = %w[find find_by where select count sum average maximum minimum first last pluck ids exists? empty? any? many? include?]
302
+ query_method = args['query'].split('.').first
303
+
304
+ return "Unsafe query method: #{query_method}" unless safe_methods.include?(query_method)
305
+
306
+ result = model_class.instance_eval(args['query'])
307
+ result.to_s
308
+ rescue NameError
309
+ "Model '#{args['model']}' not found"
310
+ rescue => e
311
+ "Error executing query: #{e.message}"
312
+ end
313
+ end
314
+
315
+ def dry_run_analysis(code)
316
+ return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
317
+
318
+ executor = RailsActiveMcp::ConsoleExecutor.new(RailsActiveMcp.config)
319
+
320
+ begin
321
+ analysis = executor.dry_run(code)
322
+
323
+ output = []
324
+ output << "Code: #{analysis[:code]}"
325
+ output << "Safe: #{analysis[:safety_analysis][:safe] ? 'Yes' : 'No'}"
326
+ output << "Read-only: #{analysis[:safety_analysis][:read_only] ? 'Yes' : 'No'}"
327
+ output << "Risk level: #{analysis[:estimated_risk]}"
328
+ output << "Would execute: #{analysis[:would_execute] ? 'Yes' : 'No'}"
329
+ output << "Summary: #{analysis[:safety_analysis][:summary]}"
330
+
331
+ if analysis[:safety_analysis][:violations].any?
332
+ output << "\nViolations:"
333
+ analysis[:safety_analysis][:violations].each do |violation|
334
+ output << " - #{violation[:description]} (#{violation[:severity]})"
335
+ end
336
+ end
337
+
338
+ if analysis[:recommendations].any?
339
+ output << "\nRecommendations:"
340
+ analysis[:recommendations].each do |rec|
341
+ output << " - #{rec}"
342
+ end
343
+ end
344
+
345
+ output.join("\n")
346
+ rescue => e
347
+ "Analysis failed: #{e.message}"
348
+ end
349
+ end
350
+
351
+ def format_success_result(result)
352
+ output = []
353
+ output << "Code: #{result[:code]}"
354
+ output << "Result: #{result[:return_value_string] || result[:return_value]}"
355
+ output << "Output: #{result[:output]}" if result[:output].present?
356
+ output << "Execution time: #{result[:execution_time]}s" if result[:execution_time]
357
+ output << "Note: #{result[:note]}" if result[:note]
358
+ output.join("\n")
359
+ end
360
+
361
+ def jsonrpc_error(id, code, message)
362
+ {
363
+ jsonrpc: JSONRPC_VERSION,
364
+ id: id,
365
+ error: { code: code, message: message }
366
+ }
367
+ end
368
+
369
+ def error_response(status, message)
370
+ [status, {'Content-Type' => 'application/json'},
371
+ [{ error: message }.to_json]]
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsActiveMcp
4
+ class Railtie < ::Rails::Railtie
5
+ railtie_name :rails_active_mcp
6
+
7
+ # Ensure configuration is available very early
8
+ config.before_initialize do
9
+ RailsActiveMcp.configure unless RailsActiveMcp.configuration
10
+ end
11
+
12
+ # Add rake tasks
13
+ rake_tasks do
14
+ load 'rails_active_mcp/tasks.rake'
15
+ end
16
+
17
+ # Add generators
18
+ generators do
19
+ # Generators are auto-discovered from lib/generators following Rails conventions
20
+ end
21
+
22
+ # Console hook for easier access
23
+ console do
24
+ # Add convenience methods to console
25
+ Rails::ConsoleMethods.include(RailsActiveMcp::ConsoleMethods) if defined?(Rails::ConsoleMethods)
26
+ end
27
+
28
+ # Configure logging
29
+ initializer 'rails_active_mcp.logger' do
30
+ RailsActiveMcp.logger = Rails.logger if defined?(Rails.logger)
31
+ end
32
+ end
33
+
34
+ # Console convenience methods
35
+ module ConsoleMethods
36
+ def mcp_execute(code, **options)
37
+ RailsActiveMcp.execute(code, **options)
38
+ end
39
+
40
+ def mcp_safe?(code)
41
+ RailsActiveMcp.safe?(code)
42
+ end
43
+
44
+ def mcp_config
45
+ RailsActiveMcp.config
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsActiveMcp
4
+ class SafetyChecker
5
+ DANGEROUS_PATTERNS = [
6
+ { pattern: /\.delete_all\b/, description: 'Mass deletion of records', severity: :high },
7
+ { pattern: /\.destroy_all\b/, description: 'Mass destruction of records', severity: :high },
8
+ { pattern: /\.drop\b/, description: 'Database table dropping', severity: :critical },
9
+ { pattern: /system\s*\(/, description: 'System command execution', severity: :critical },
10
+ { pattern: /Kernel\.system\s*\(/, description: 'Kernel system command execution', severity: :critical },
11
+ { pattern: /exec\s*\(/, description: 'Process execution', severity: :critical },
12
+ { pattern: /`[^`]*`/, description: 'Shell command execution', severity: :critical },
13
+ { pattern: /File\.delete/, description: 'File deletion', severity: :high },
14
+ { pattern: /FileUtils\./, description: 'File system operations', severity: :high },
15
+ { pattern: /Dir\.delete/, description: 'Directory deletion', severity: :high },
16
+ { pattern: /ActiveRecord::Base\.connection\.execute/, description: 'Raw SQL execution', severity: :medium },
17
+ { pattern: /\.update_all\(/, description: 'Mass update without callbacks', severity: :medium },
18
+ { pattern: /eval\s*\(/, description: 'Dynamic code evaluation', severity: :high },
19
+ { pattern: /send\s*\(/, description: 'Dynamic method calling', severity: :medium },
20
+ { pattern: /const_set/, description: 'Dynamic constant definition', severity: :medium },
21
+ { pattern: /remove_const/, description: 'Constant removal', severity: :high },
22
+ { pattern: /undef_method/, description: 'Method removal', severity: :high },
23
+ { pattern: /alias_method/, description: 'Method aliasing', severity: :medium },
24
+ { pattern: /load\s*\(/, description: 'Code loading', severity: :medium },
25
+ { pattern: /require\s*\(/, description: 'Library requiring', severity: :low },
26
+ { pattern: /exit/, description: 'Process termination', severity: :high },
27
+ { pattern: /abort/, description: 'Process abortion', severity: :high },
28
+ { pattern: /fork/, description: 'Process forking', severity: :high },
29
+ { pattern: /Thread\.new/, description: 'Thread creation', severity: :medium },
30
+ { pattern: /\$LOAD_PATH/, description: 'Load path manipulation', severity: :medium },
31
+ { pattern: /ENV\[/, description: 'Environment variable access', severity: :low },
32
+ { pattern: /Rails\.env\s*=/, description: 'Environment changing', severity: :high },
33
+ { pattern: /Rails\.application\.secrets/, description: 'Secrets access', severity: :medium }
34
+ ].freeze
35
+
36
+ READ_ONLY_PATTERNS = [
37
+ /\.(find|find_by|find_each|find_in_batches)\b/,
38
+ /\.(where|all|first|last|take)\b/,
39
+ /\.(count|sum|average|maximum|minimum|size|length)\b/,
40
+ /\.(pluck|ids|exists\?|empty\?|any\?|many\?)\b/,
41
+ /\.(select|distinct|group|order|limit|offset)\b/,
42
+ /\.(includes|joins|left_joins|preload|eager_load)\b/,
43
+ /\.(to_a|to_sql|explain|inspect|as_json|to_json)\b/,
44
+ /\.(attributes|attribute_names|column_names)\b/,
45
+ /\.model_name\b/,
46
+ /\.table_name\b/,
47
+ /\.primary_key\b/,
48
+ /\.connection\.schema_cache/,
49
+ /Rails\.(env|root|application\.class|version)/
50
+ ].freeze
51
+
52
+ def initialize(config)
53
+ @config = config
54
+ end
55
+
56
+ def safe?(code)
57
+
58
+ analysis = analyze(code)
59
+ analysis[:safe]
60
+ end
61
+
62
+ def analyze(code)
63
+ violations = []
64
+
65
+ # Check against dangerous patterns
66
+ dangerous_patterns.each do |pattern_info|
67
+ violations << pattern_info if code.match?(pattern_info[:pattern])
68
+ end
69
+
70
+ # Check custom patterns
71
+ @config.custom_safety_patterns.each do |custom_pattern|
72
+ next unless code.match?(custom_pattern[:pattern])
73
+
74
+ violations << {
75
+ pattern: custom_pattern[:pattern],
76
+ description: custom_pattern[:description] || 'Custom safety rule',
77
+ severity: :custom
78
+ }
79
+ end
80
+
81
+ # Determine if code is read-only
82
+ read_only = read_only?(code)
83
+
84
+ # Calculate safety
85
+ critical_violations = violations.select { |v| v[:severity] == :critical }
86
+ high_violations = violations.select { |v| v[:severity] == :high }
87
+
88
+ safe = (@config.safe_mode && read_only && critical_violations.empty? && high_violations.empty?) ||
89
+ (!@config.safe_mode && critical_violations.empty?)
90
+
91
+ {
92
+ safe: safe,
93
+ read_only: read_only,
94
+ violations: violations,
95
+ summary: generate_summary(violations, read_only)
96
+ }
97
+ end
98
+
99
+ def read_only?(code)
100
+ # Must contain at least one read-only pattern
101
+ has_read_only = READ_ONLY_PATTERNS.any? { |pattern| code.match?(pattern) }
102
+
103
+ # Must not contain any obvious mutation patterns
104
+ mutation_patterns = [
105
+ /\.(save|create|update|delete|destroy)\b/,
106
+ /\.(save!|create!|update!|delete!|destroy!)\b/,
107
+ /\.reload\b/,
108
+ /\.transaction\b/,
109
+ /=\s*[^=]/ # Assignment (basic check)
110
+ ]
111
+
112
+ has_mutations = mutation_patterns.any? { |pattern| code.match?(pattern) }
113
+
114
+ has_read_only && !has_mutations
115
+ end
116
+
117
+ private
118
+
119
+ def dangerous_patterns
120
+ base_patterns = DANGEROUS_PATTERNS.dup
121
+
122
+ # Add custom patterns from config
123
+ @config.custom_safety_patterns.each do |custom|
124
+ base_patterns << {
125
+ pattern: custom[:pattern],
126
+ description: custom[:description] || 'Custom rule',
127
+ severity: :custom
128
+ }
129
+ end
130
+
131
+ base_patterns
132
+ end
133
+
134
+ def generate_summary(violations, read_only)
135
+ if violations.empty?
136
+ read_only ? 'Code appears safe and read-only' : 'Code appears safe'
137
+ else
138
+ 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}"
143
+ end
144
+
145
+ "Found #{parts.join(', ')}"
146
+ end
147
+ end
148
+ end
149
+ end