rails-active-mcp 0.1.6 → 2.0.7

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -279
  3. data/changelog.md +69 -0
  4. data/claude_desktop_config.json +12 -0
  5. data/docs/DEBUGGING.md +40 -8
  6. data/docs/GENERATOR_TESTING.md +121 -0
  7. data/docs/README.md +130 -142
  8. data/exe/rails-active-mcp-server +176 -65
  9. data/lib/generators/rails_active_mcp/install/install_generator.rb +123 -3
  10. data/lib/generators/rails_active_mcp/install/templates/README.md +34 -128
  11. data/lib/generators/rails_active_mcp/install/templates/initializer.rb +37 -38
  12. data/lib/generators/rails_active_mcp/install/templates/mcp.ru +7 -3
  13. data/lib/rails_active_mcp/configuration.rb +37 -98
  14. data/lib/rails_active_mcp/console_executor.rb +202 -78
  15. data/lib/rails_active_mcp/engine.rb +36 -8
  16. data/lib/rails_active_mcp/sdk/server.rb +183 -0
  17. data/lib/rails_active_mcp/sdk/tools/console_execute_tool.rb +103 -0
  18. data/lib/rails_active_mcp/sdk/tools/dry_run_tool.rb +73 -0
  19. data/lib/rails_active_mcp/sdk/tools/model_info_tool.rb +106 -0
  20. data/lib/rails_active_mcp/sdk/tools/safe_query_tool.rb +77 -0
  21. data/lib/rails_active_mcp/version.rb +1 -1
  22. data/lib/rails_active_mcp.rb +10 -11
  23. data/rails_active_mcp.gemspec +8 -4
  24. metadata +43 -17
  25. data/lib/rails_active_mcp/mcp_server.rb +0 -374
  26. data/lib/rails_active_mcp/railtie.rb +0 -48
  27. data/lib/rails_active_mcp/stdio_server.rb +0 -467
  28. data/lib/rails_active_mcp/tools/console_execute_tool.rb +0 -61
  29. data/lib/rails_active_mcp/tools/dry_run_tool.rb +0 -41
  30. data/lib/rails_active_mcp/tools/model_info_tool.rb +0 -70
  31. data/lib/rails_active_mcp/tools/safe_query_tool.rb +0 -41
@@ -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
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module RailsActiveMcp
6
+ module Sdk
7
+ module Tools
8
+ class ModelInfoTool < MCP::Tool
9
+ description 'Get information about Rails models including schema and associations'
10
+
11
+ input_schema(
12
+ properties: {
13
+ model: {
14
+ type: 'string',
15
+ description: 'Model class name'
16
+ },
17
+ include_schema: {
18
+ type: 'boolean',
19
+ description: 'Include database schema information'
20
+ },
21
+ include_associations: {
22
+ type: 'boolean',
23
+ description: 'Include model associations'
24
+ },
25
+ include_validations: {
26
+ type: 'boolean',
27
+ description: 'Include model validations'
28
+ }
29
+ },
30
+ required: ['model']
31
+ )
32
+
33
+ annotations(
34
+ title: 'Rails Model Inspector',
35
+ destructive_hint: false,
36
+ read_only_hint: true,
37
+ idempotent_hint: true,
38
+ open_world_hint: false
39
+ )
40
+
41
+ def self.call(model:, server_context:, include_schema: true, include_associations: true,
42
+ include_validations: true)
43
+ config = RailsActiveMcp.config
44
+
45
+ begin
46
+ model_class = model.constantize
47
+
48
+ output = []
49
+ output << "Model: #{model}"
50
+ output << "Table: #{model_class.table_name}"
51
+ output << "Primary Key: #{model_class.primary_key}"
52
+
53
+ if include_schema
54
+ output << "\nSchema:"
55
+ model_class.columns.each do |column|
56
+ output << " #{column.name}: #{column.type} (#{column.sql_type})"
57
+ output << " - Null: #{column.null}"
58
+ output << " - Default: #{column.default}" if column.default
59
+ end
60
+ end
61
+
62
+ if include_associations
63
+ output << "\nAssociations:"
64
+ model_class.reflections.each do |name, reflection|
65
+ output << " #{name}: #{reflection.class.name.split('::').last} -> #{reflection.class_name}"
66
+ end
67
+ end
68
+
69
+ if include_validations
70
+ validations = {}
71
+ model_class.validators.each do |validator|
72
+ validator.attributes.each do |attribute|
73
+ validations[attribute] ||= []
74
+ validations[attribute] << validator.class.name.split('::').last
75
+ end
76
+ end
77
+
78
+ if validations.any?
79
+ output << "\nValidations:"
80
+ validations.each do |attr, validators|
81
+ output << " #{attr}: #{validators.join(', ')}"
82
+ end
83
+ end
84
+ end
85
+
86
+ MCP::Tool::Response.new([
87
+ { type: 'text', text: output.join("\n") }
88
+ ])
89
+ rescue NameError
90
+ error_response("Model '#{model}' not found")
91
+ rescue StandardError => e
92
+ error_response("Error analyzing model: #{e.message}")
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def self.error_response(message)
99
+ MCP::Tool::Response.new([
100
+ { type: 'text', text: message }
101
+ ])
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module RailsActiveMcp
6
+ module Sdk
7
+ module Tools
8
+ class SafeQueryTool < MCP::Tool
9
+ description 'Execute safe read-only database queries on Rails models'
10
+
11
+ input_schema(
12
+ properties: {
13
+ model: {
14
+ type: 'string',
15
+ description: 'Model class name (e.g., "User", "Product")'
16
+ },
17
+ method: {
18
+ type: 'string',
19
+ description: 'Query method (find, where, count, etc.)'
20
+ },
21
+ args: {
22
+ type: 'array',
23
+ description: 'Arguments for the query method'
24
+ },
25
+ limit: {
26
+ type: 'integer',
27
+ description: 'Limit results (default: 100)'
28
+ }
29
+ },
30
+ required: %w[model method]
31
+ )
32
+
33
+ annotations(
34
+ title: 'Safe Query Executor',
35
+ destructive_hint: false,
36
+ read_only_hint: true,
37
+ idempotent_hint: true,
38
+ open_world_hint: false
39
+ )
40
+
41
+ def self.call(model:, method:, server_context:, args: [], limit: 100)
42
+ config = RailsActiveMcp.config
43
+
44
+ executor = RailsActiveMcp::ConsoleExecutor.new(config)
45
+
46
+ result = executor.execute_safe_query(
47
+ model: model,
48
+ method: method,
49
+ args: args,
50
+ limit: limit
51
+ )
52
+
53
+ if result[:success]
54
+ output = []
55
+ output << "Query: #{model}.#{method}(#{args.join(', ')})"
56
+ output << "Count: #{result[:count]}" if result[:count]
57
+ output << "Result: #{result[:result].inspect}"
58
+
59
+ MCP::Tool::Response.new([
60
+ { type: 'text', text: output.join("\n") }
61
+ ])
62
+ else
63
+ error_response(result[:error])
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def self.error_response(message)
70
+ MCP::Tool::Response.new([
71
+ { type: 'text', text: message }
72
+ ])
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsActiveMcp
4
- VERSION = '0.1.6'
4
+ VERSION = '2.0.7'
5
5
  end
@@ -5,18 +5,17 @@ require_relative 'rails_active_mcp/version'
5
5
  require_relative 'rails_active_mcp/configuration'
6
6
  require_relative 'rails_active_mcp/safety_checker'
7
7
  require_relative 'rails_active_mcp/console_executor'
8
- require_relative 'rails_active_mcp/mcp_server'
8
+
9
+ # Load SDK server
10
+ require_relative 'rails_active_mcp/sdk/server'
9
11
 
10
12
  # Load Engine for Rails integration
11
13
  require_relative 'rails_active_mcp/engine' if defined?(Rails)
12
14
 
13
15
  module RailsActiveMcp
14
16
  class Error < StandardError; end
15
-
16
17
  class SafetyError < Error; end
17
-
18
18
  class ExecutionError < Error; end
19
-
20
19
  class TimeoutError < Error; end
21
20
 
22
21
  class << self
@@ -42,16 +41,16 @@ module RailsActiveMcp
42
41
  ConsoleExecutor.new(config).execute(code, **options)
43
42
  end
44
43
 
45
- # Access to MCP server instance
46
- def server
47
- @server ||= McpServer.new
48
- end
49
-
50
- # Add logger accessor
44
+ # Logger accessor - configured by engine or defaults to stderr
51
45
  attr_accessor :logger
52
46
 
53
47
  def logger
54
- @logger ||= defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger ? Rails.logger : Logger.new(STDOUT)
48
+ @logger ||= Logger.new($stderr).tap do |logger|
49
+ logger.level = Logger::INFO
50
+ logger.formatter = proc do |severity, datetime, progname, msg|
51
+ "[#{datetime}] #{severity} -- RailsActiveMcp: #{msg}\n"
52
+ end
53
+ end
55
54
  end
56
55
  end
57
56
  end
@@ -30,12 +30,16 @@ Gem::Specification.new do |spec|
30
30
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
31
  spec.require_paths = ['lib']
32
32
 
33
- # Dependencies
34
- spec.add_runtime_dependency 'concurrent-ruby', '~> 1.3.5'
35
- spec.add_runtime_dependency 'rails', '~> 7.0'
33
+ # Runtime dependencies - more flexible Rails version support
34
+ spec.add_runtime_dependency 'concurrent-ruby', '~> 1.3'
35
+ spec.add_runtime_dependency 'rails', '>= 6.1', '< 9.0'
36
36
 
37
+ # MCP SDK - Core protocol implementation
38
+ spec.add_runtime_dependency 'mcp', '~> 0.1.0'
39
+
40
+ # Core dependencies
37
41
  spec.add_dependency 'json', '~> 2.0'
38
- spec.add_dependency 'rack', '~> 3.0'
42
+ spec.add_dependency 'rack', '>= 2.0', '< 4.0'
39
43
  spec.add_dependency 'timeout', '~> 0.4'
40
44
  spec.add_dependency 'webrick', '~> 1.8'
41
45