rails-mcp-server 1.2.3 → 1.4.0
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/README.md +168 -166
- data/docs/AGENT.md +345 -0
- data/exe/rails-mcp-config +1411 -0
- data/exe/rails-mcp-server +23 -10
- data/exe/rails-mcp-setup-claude +1 -1
- data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
- data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
- data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
- data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
- data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
- data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
- data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
- data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
- data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
- data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
- data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
- data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
- data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
- data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
- data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +19 -53
- metadata +65 -18
- data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
- data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
- data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
- data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
- data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
- data/lib/rails-mcp-server/tools/get_file.rb +0 -55
- data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
- data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
- data/lib/rails-mcp-server/tools/list_files.rb +0 -54
- data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
- data/lib/rails-mcp-server/tools/project_info.rb +0 -86
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
module Analyzers
|
|
3
|
+
class ProjectInfo < BaseAnalyzer
|
|
4
|
+
def call(max_depth: 2, include_files: true, detail_level: "full")
|
|
5
|
+
unless current_project
|
|
6
|
+
message = "No active project. Please switch to a project first."
|
|
7
|
+
log(:warn, message)
|
|
8
|
+
return message
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
max_depth = [[max_depth.to_i, 1].max, 5].min
|
|
12
|
+
detail_level = "full" unless %w[minimal summary full].include?(detail_level)
|
|
13
|
+
|
|
14
|
+
gemfile_path = File.join(active_project_path, "Gemfile")
|
|
15
|
+
gemfile_content = File.exist?(gemfile_path) ? File.read(gemfile_path) : "Gemfile not found"
|
|
16
|
+
|
|
17
|
+
rails_version = gemfile_content.match(/gem ['"]rails['"],\s*['"](.+?)['"]/)&.captures&.first || "Unknown"
|
|
18
|
+
|
|
19
|
+
config_application_path = File.join(active_project_path, "config", "application.rb")
|
|
20
|
+
is_api_only = File.exist?(config_application_path) &&
|
|
21
|
+
File.read(config_application_path).include?("config.api_only = true")
|
|
22
|
+
|
|
23
|
+
log(:info, "Project info: Rails v#{rails_version}, API-only: #{is_api_only}")
|
|
24
|
+
|
|
25
|
+
case detail_level
|
|
26
|
+
when "minimal"
|
|
27
|
+
<<~INFO
|
|
28
|
+
Project: #{current_project}
|
|
29
|
+
Path: #{active_project_path}
|
|
30
|
+
Rails version: #{rails_version}
|
|
31
|
+
API only: #{is_api_only ? "Yes" : "No"}
|
|
32
|
+
INFO
|
|
33
|
+
|
|
34
|
+
when "summary"
|
|
35
|
+
key_dirs = get_key_directories
|
|
36
|
+
<<~INFO
|
|
37
|
+
Project: #{current_project}
|
|
38
|
+
Path: #{active_project_path}
|
|
39
|
+
Rails version: #{rails_version}
|
|
40
|
+
API only: #{is_api_only ? "Yes" : "No"}
|
|
41
|
+
|
|
42
|
+
Key directories:
|
|
43
|
+
#{key_dirs}
|
|
44
|
+
INFO
|
|
45
|
+
|
|
46
|
+
when "full"
|
|
47
|
+
<<~INFO
|
|
48
|
+
Current project: #{current_project}
|
|
49
|
+
Path: #{active_project_path}
|
|
50
|
+
Rails version: #{rails_version}
|
|
51
|
+
API only: #{is_api_only ? "Yes" : "No"}
|
|
52
|
+
|
|
53
|
+
Project structure:
|
|
54
|
+
#{get_directory_structure(active_project_path, max_depth: max_depth, include_files: include_files)}
|
|
55
|
+
INFO
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def get_key_directories
|
|
62
|
+
key_paths = %w[
|
|
63
|
+
app/models
|
|
64
|
+
app/controllers
|
|
65
|
+
app/views
|
|
66
|
+
app/jobs
|
|
67
|
+
app/services
|
|
68
|
+
app/graphql
|
|
69
|
+
config
|
|
70
|
+
db/migrate
|
|
71
|
+
spec
|
|
72
|
+
test
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
output = []
|
|
76
|
+
key_paths.each do |path|
|
|
77
|
+
full_path = File.join(active_project_path, path)
|
|
78
|
+
if File.directory?(full_path)
|
|
79
|
+
count = Dir.glob(File.join(full_path, "**", "*.rb")).size
|
|
80
|
+
output << " #{path}/ (#{count} Ruby files)"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
output.empty? ? " (no key directories found)" : output.join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def get_directory_structure(path, max_depth:, include_files:, current_depth: 0, prefix: "")
|
|
88
|
+
return "" if current_depth > max_depth || !File.directory?(path)
|
|
89
|
+
|
|
90
|
+
ignored_dirs = [
|
|
91
|
+
".git", "node_modules", "tmp", "log",
|
|
92
|
+
"storage", "coverage", "public/assets",
|
|
93
|
+
"public/packs", ".bundle", "vendor/bundle",
|
|
94
|
+
"vendor/cache", ".ruby-lsp"
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
output = +"" # Mutable string to avoid frozen string literal warning
|
|
98
|
+
directories = []
|
|
99
|
+
files = []
|
|
100
|
+
|
|
101
|
+
Dir.foreach(path) do |entry|
|
|
102
|
+
next if entry == "." || entry == ".."
|
|
103
|
+
next if ignored_dirs.include?(entry)
|
|
104
|
+
|
|
105
|
+
full_path = File.join(path, entry)
|
|
106
|
+
|
|
107
|
+
if File.directory?(full_path)
|
|
108
|
+
directories << entry
|
|
109
|
+
elsif include_files
|
|
110
|
+
files << entry
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
directories.sort.each do |dir|
|
|
115
|
+
output << "#{prefix}└── #{dir}/\n"
|
|
116
|
+
full_path = File.join(path, dir)
|
|
117
|
+
output << get_directory_structure(
|
|
118
|
+
full_path,
|
|
119
|
+
max_depth: max_depth,
|
|
120
|
+
include_files: include_files,
|
|
121
|
+
current_depth: current_depth + 1,
|
|
122
|
+
prefix: "#{prefix} "
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if include_files
|
|
127
|
+
files.sort.each do |file|
|
|
128
|
+
output << "#{prefix}└── #{file}\n"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
output
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
class ExecuteRuby < BaseTool
|
|
3
|
+
tool_name "execute_ruby"
|
|
4
|
+
|
|
5
|
+
description <<~DESC
|
|
6
|
+
Execute read-only Ruby code in the context of the Rails project. Use this for:
|
|
7
|
+
- Complex queries that would require multiple tool calls
|
|
8
|
+
- Filtering/transforming data before returning
|
|
9
|
+
- Custom exploration of the codebase
|
|
10
|
+
|
|
11
|
+
RESTRICTIONS:
|
|
12
|
+
- Cannot create, modify, or delete files
|
|
13
|
+
- Cannot read .env, credentials, key files, or .gitignore'd files
|
|
14
|
+
- Cannot access files outside the project directory
|
|
15
|
+
- Cannot execute shell commands or system calls
|
|
16
|
+
|
|
17
|
+
HELPER METHODS AVAILABLE:
|
|
18
|
+
- read_file(path) - safely read a file
|
|
19
|
+
- file_exists?(path) - check if file exists (false for sensitive files)
|
|
20
|
+
- list_files(pattern) - glob files safely, e.g., list_files('app/models/**/*.rb')
|
|
21
|
+
- project_root - returns the project root path
|
|
22
|
+
|
|
23
|
+
NOTE: Use `puts` to see output, e.g., puts read_file('Gemfile')
|
|
24
|
+
DESC
|
|
25
|
+
|
|
26
|
+
arguments do
|
|
27
|
+
required(:code).filled(:string).description("Ruby code to execute (read-only operations only)")
|
|
28
|
+
optional(:timeout).filled(:integer).description("Timeout in seconds. Default: 30, Max: 60")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Patterns that indicate dangerous operations
|
|
32
|
+
FORBIDDEN_PATTERNS = [
|
|
33
|
+
# File/IO writing
|
|
34
|
+
/File\.(write|open|new)\s*\([^)]*['"][wa+]/i,
|
|
35
|
+
/File\.(delete|unlink|rename|chmod|chown|truncate)/i,
|
|
36
|
+
/FileUtils\./i,
|
|
37
|
+
/IO\.(write|syswrite|popen|pipe)/i,
|
|
38
|
+
/\.(write|puts|print|syswrite)\s*[(\s]/,
|
|
39
|
+
|
|
40
|
+
# Directory modification
|
|
41
|
+
/Dir\.(mkdir|rmdir|delete|chdir)/i,
|
|
42
|
+
|
|
43
|
+
# System/shell execution
|
|
44
|
+
/system\s*[(\s]/,
|
|
45
|
+
/exec\s*[(\s]/,
|
|
46
|
+
/`[^`]+`/,
|
|
47
|
+
/%x[{(\[]/,
|
|
48
|
+
/Kernel\.(system|exec|spawn|`)/,
|
|
49
|
+
/Open3\./i,
|
|
50
|
+
/IO\.popen/i,
|
|
51
|
+
/Process\.(spawn|exec|fork)/i,
|
|
52
|
+
/Shellwords/i,
|
|
53
|
+
|
|
54
|
+
# Network access
|
|
55
|
+
/Net::(HTTP|FTP|SMTP)/i,
|
|
56
|
+
/URI\.(open|parse)/i,
|
|
57
|
+
/HTTParty/i,
|
|
58
|
+
/Faraday/i,
|
|
59
|
+
/RestClient/i,
|
|
60
|
+
/open-uri/i,
|
|
61
|
+
/Socket/i,
|
|
62
|
+
/TCPSocket/i,
|
|
63
|
+
/UDPSocket/i,
|
|
64
|
+
|
|
65
|
+
# Dangerous Ruby features
|
|
66
|
+
/eval\s*[(\s]/,
|
|
67
|
+
/instance_eval/i,
|
|
68
|
+
/class_eval/i,
|
|
69
|
+
/module_eval/i,
|
|
70
|
+
/define_method/i,
|
|
71
|
+
/send\s*[(\s]+[:'"]*(system|exec|`)/i,
|
|
72
|
+
/__send__/,
|
|
73
|
+
/ObjectSpace/i,
|
|
74
|
+
/Binding/i,
|
|
75
|
+
/set_trace_func/i,
|
|
76
|
+
|
|
77
|
+
# Environment/credentials access
|
|
78
|
+
/ENV\[/i,
|
|
79
|
+
/ENV\.fetch/i,
|
|
80
|
+
/Rails\.application\.credentials/i,
|
|
81
|
+
/Rails\.application\.secrets/i,
|
|
82
|
+
|
|
83
|
+
# Load/require that could execute arbitrary code
|
|
84
|
+
/load\s*[(\s]+[^)]*\$/i,
|
|
85
|
+
/require\s+[^'"]/i
|
|
86
|
+
].freeze
|
|
87
|
+
|
|
88
|
+
# Sensitive file patterns (in addition to .gitignore)
|
|
89
|
+
SENSITIVE_PATTERNS = [
|
|
90
|
+
/\.env(\..*)?$/i,
|
|
91
|
+
/\.key$/i,
|
|
92
|
+
/\.pem$/i,
|
|
93
|
+
/\.crt$/i,
|
|
94
|
+
/\.p12$/i,
|
|
95
|
+
/credentials\.yml/i,
|
|
96
|
+
/secrets\.yml/i,
|
|
97
|
+
/master\.key/i,
|
|
98
|
+
/config\/credentials/i,
|
|
99
|
+
/config\/secrets/i,
|
|
100
|
+
/\.secret$/i,
|
|
101
|
+
/password/i,
|
|
102
|
+
/\.ssh\//i,
|
|
103
|
+
/id_rsa/i,
|
|
104
|
+
/id_ed25519/i
|
|
105
|
+
].freeze
|
|
106
|
+
|
|
107
|
+
NO_OUTPUT_MESSAGE = <<~MSG
|
|
108
|
+
Code executed successfully (no output).
|
|
109
|
+
|
|
110
|
+
Hint: Use `puts` to see results, e.g.:
|
|
111
|
+
puts read_file('config/routes.rb')
|
|
112
|
+
puts User.count
|
|
113
|
+
puts Dir.glob('app/models/*.rb')
|
|
114
|
+
MSG
|
|
115
|
+
|
|
116
|
+
def call(code:, timeout: 30)
|
|
117
|
+
unless current_project
|
|
118
|
+
return "No active project. Please switch to a project first."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
timeout = [timeout.to_i, 60].min # Cap at 60 seconds
|
|
122
|
+
timeout = 10 if timeout < 1
|
|
123
|
+
|
|
124
|
+
# Step 1: Static analysis - reject dangerous code
|
|
125
|
+
validation_error = validate_code_safety(code)
|
|
126
|
+
return validation_error if validation_error
|
|
127
|
+
|
|
128
|
+
# Step 2: Build the sandboxed execution environment
|
|
129
|
+
sandbox_code = build_sandbox(code)
|
|
130
|
+
|
|
131
|
+
# Step 3: Execute with timeout
|
|
132
|
+
execute_sandboxed(sandbox_code, timeout)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def validate_code_safety(code)
|
|
138
|
+
FORBIDDEN_PATTERNS.each do |pattern|
|
|
139
|
+
if code.match?(pattern)
|
|
140
|
+
return "REJECTED: Code contains forbidden pattern (#{pattern.source.split("\\").first}...). " \
|
|
141
|
+
"This tool only allows read-only operations."
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_sandbox(user_code)
|
|
148
|
+
gitignore_patterns = parse_gitignore
|
|
149
|
+
all_patterns = SENSITIVE_PATTERNS.map(&:source) + gitignore_patterns
|
|
150
|
+
sensitive_patterns_ruby = all_patterns.map { |p| "Regexp.new(#{p.inspect}, Regexp::IGNORECASE)" }.join(",\n ")
|
|
151
|
+
|
|
152
|
+
<<~RUBY
|
|
153
|
+
# Sandbox wrapper for safe execution
|
|
154
|
+
module McpSandbox
|
|
155
|
+
PROJECT_ROOT = #{active_project_path.inspect}.freeze
|
|
156
|
+
|
|
157
|
+
SENSITIVE_PATTERNS = [
|
|
158
|
+
#{sensitive_patterns_ruby}
|
|
159
|
+
].freeze
|
|
160
|
+
|
|
161
|
+
class PathViolation < StandardError; end
|
|
162
|
+
class SensitiveFileViolation < StandardError; end
|
|
163
|
+
class WriteViolation < StandardError; end
|
|
164
|
+
|
|
165
|
+
module_function
|
|
166
|
+
|
|
167
|
+
def validate_path!(path)
|
|
168
|
+
expanded = File.expand_path(path, PROJECT_ROOT)
|
|
169
|
+
|
|
170
|
+
unless expanded.start_with?(PROJECT_ROOT + "/") || expanded == PROJECT_ROOT
|
|
171
|
+
raise PathViolation, "Access denied: path '\#{path}' is outside project directory"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
relative_path = expanded.sub(PROJECT_ROOT + "/", "")
|
|
175
|
+
|
|
176
|
+
SENSITIVE_PATTERNS.each do |pattern|
|
|
177
|
+
if relative_path.match?(pattern)
|
|
178
|
+
raise SensitiveFileViolation, "Access denied: '\#{relative_path}' matches sensitive file pattern"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
expanded
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def safe_read(path)
|
|
186
|
+
validated_path = validate_path!(path)
|
|
187
|
+
File.original_read(validated_path)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def safe_exist?(path)
|
|
191
|
+
validated_path = validate_path!(path)
|
|
192
|
+
File.original_exist?(validated_path)
|
|
193
|
+
rescue PathViolation, SensitiveFileViolation
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def safe_directory?(path)
|
|
198
|
+
validated_path = validate_path!(path)
|
|
199
|
+
File.original_directory?(validated_path)
|
|
200
|
+
rescue PathViolation, SensitiveFileViolation
|
|
201
|
+
false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def safe_file?(path)
|
|
205
|
+
validated_path = validate_path!(path)
|
|
206
|
+
File.original_file?(validated_path)
|
|
207
|
+
rescue PathViolation, SensitiveFileViolation
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def safe_glob(pattern, base: PROJECT_ROOT)
|
|
212
|
+
Dir.original_glob(File.join(base, pattern)).select do |path|
|
|
213
|
+
validate_path!(path)
|
|
214
|
+
true
|
|
215
|
+
rescue PathViolation, SensitiveFileViolation
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def safe_entries(path)
|
|
221
|
+
validated_path = validate_path!(path)
|
|
222
|
+
Dir.original_entries(validated_path).reject { |e| e.start_with?(".") }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Override File class methods
|
|
227
|
+
class File
|
|
228
|
+
class << self
|
|
229
|
+
alias_method :original_read, :read
|
|
230
|
+
alias_method :original_exist?, :exist?
|
|
231
|
+
alias_method :original_directory?, :directory?
|
|
232
|
+
alias_method :original_file?, :file?
|
|
233
|
+
|
|
234
|
+
def read(path, *args)
|
|
235
|
+
McpSandbox.safe_read(path)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def exist?(path)
|
|
239
|
+
McpSandbox.safe_exist?(path)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def directory?(path)
|
|
243
|
+
McpSandbox.safe_directory?(path)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def file?(path)
|
|
247
|
+
McpSandbox.safe_file?(path)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Block all write operations
|
|
251
|
+
[:write, :delete, :unlink, :rename, :chmod, :chown, :truncate].each do |method|
|
|
252
|
+
define_method(method) do |*args, &block|
|
|
253
|
+
raise McpSandbox::WriteViolation, "Write operations are not permitted: File.\#{method}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Handle open specially - allow read-only mode
|
|
258
|
+
def open(path, mode = "r", *args, &block)
|
|
259
|
+
if mode.to_s =~ /[wa+]/
|
|
260
|
+
raise McpSandbox::WriteViolation, "Write operations are not permitted: File.open with mode '\#{mode}'"
|
|
261
|
+
end
|
|
262
|
+
content = McpSandbox.safe_read(path)
|
|
263
|
+
if block_given?
|
|
264
|
+
yield StringIO.new(content)
|
|
265
|
+
else
|
|
266
|
+
StringIO.new(content)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Override Dir class methods
|
|
273
|
+
class Dir
|
|
274
|
+
class << self
|
|
275
|
+
alias_method :original_glob, :glob
|
|
276
|
+
alias_method :original_entries, :entries
|
|
277
|
+
|
|
278
|
+
def glob(pattern, *args)
|
|
279
|
+
McpSandbox.safe_glob(pattern)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def entries(path)
|
|
283
|
+
McpSandbox.safe_entries(path)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
[:mkdir, :rmdir, :delete, :chdir].each do |method|
|
|
287
|
+
define_method(method) do |*args|
|
|
288
|
+
raise McpSandbox::WriteViolation, "Directory modifications are not permitted: Dir.\#{method}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Block FileUtils entirely
|
|
295
|
+
if defined?(FileUtils)
|
|
296
|
+
module FileUtils
|
|
297
|
+
class << self
|
|
298
|
+
def method_missing(method, *args)
|
|
299
|
+
raise McpSandbox::WriteViolation, "FileUtils operations are not permitted"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Block system calls at Kernel level
|
|
306
|
+
module Kernel
|
|
307
|
+
def system(*args)
|
|
308
|
+
raise McpSandbox::WriteViolation, "System calls are not permitted"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def exec(*args)
|
|
312
|
+
raise McpSandbox::WriteViolation, "System calls are not permitted"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def spawn(*args)
|
|
316
|
+
raise McpSandbox::WriteViolation, "System calls are not permitted"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def `(cmd)
|
|
320
|
+
raise McpSandbox::WriteViolation, "Shell execution is not permitted"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Block backticks at Object level
|
|
325
|
+
class Object
|
|
326
|
+
def `(cmd)
|
|
327
|
+
raise McpSandbox::WriteViolation, "Shell execution is not permitted"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Provide convenient aliases for sandboxed operations
|
|
332
|
+
def read_file(path)
|
|
333
|
+
McpSandbox.safe_read(path)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def file_exists?(path)
|
|
337
|
+
McpSandbox.safe_exist?(path)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def list_files(pattern)
|
|
341
|
+
McpSandbox.safe_glob(pattern)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def project_root
|
|
345
|
+
McpSandbox::PROJECT_ROOT
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# ============ USER CODE BELOW ============
|
|
349
|
+
begin
|
|
350
|
+
#{user_code}
|
|
351
|
+
rescue McpSandbox::PathViolation => e
|
|
352
|
+
puts "PATH ERROR: \#{e.message}"
|
|
353
|
+
rescue McpSandbox::SensitiveFileViolation => e
|
|
354
|
+
puts "ACCESS DENIED: \#{e.message}"
|
|
355
|
+
rescue McpSandbox::WriteViolation => e
|
|
356
|
+
puts "WRITE ERROR: \#{e.message}"
|
|
357
|
+
rescue => e
|
|
358
|
+
puts "ERROR: \#{e.class} - \#{e.message}"
|
|
359
|
+
end
|
|
360
|
+
RUBY
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def parse_gitignore
|
|
364
|
+
gitignore_path = File.join(active_project_path, ".gitignore")
|
|
365
|
+
return [] unless File.exist?(gitignore_path)
|
|
366
|
+
|
|
367
|
+
File.readlines(gitignore_path)
|
|
368
|
+
.map(&:strip)
|
|
369
|
+
.reject { |line| line.empty? || line.start_with?("#") } # rubocop:disable Performance/ChainArrayAllocation
|
|
370
|
+
.map { |pattern| convert_gitignore_to_regex(pattern) } # rubocop:disable Performance/ChainArrayAllocation
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def convert_gitignore_to_regex(pattern)
|
|
374
|
+
# Convert gitignore glob pattern to regex
|
|
375
|
+
regex = Regexp.escape(pattern)
|
|
376
|
+
.gsub('\*\*', ".*") # ** matches everything
|
|
377
|
+
.gsub('\*', "[^/]*") # * matches within directory
|
|
378
|
+
.gsub('\?', ".") # ? matches single char
|
|
379
|
+
.gsub(/^\//, "^") # Leading / anchors to root
|
|
380
|
+
|
|
381
|
+
# If pattern doesn't start with /, it can match anywhere
|
|
382
|
+
regex = "(?:^|/)" + regex unless pattern.start_with?("/")
|
|
383
|
+
|
|
384
|
+
regex
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def execute_sandboxed(code, timeout)
|
|
388
|
+
require "tempfile"
|
|
389
|
+
require "timeout"
|
|
390
|
+
|
|
391
|
+
Tempfile.create(["mcp_sandbox", ".rb"]) do |f|
|
|
392
|
+
f.write(code)
|
|
393
|
+
f.flush
|
|
394
|
+
|
|
395
|
+
begin
|
|
396
|
+
Timeout.timeout(timeout) do
|
|
397
|
+
result = RailsMcpServer::RunProcess.execute_rails_command(
|
|
398
|
+
active_project_path,
|
|
399
|
+
"bin/rails runner #{f.path} 2>&1"
|
|
400
|
+
)
|
|
401
|
+
result.empty? ? NO_OUTPUT_MESSAGE : result
|
|
402
|
+
end
|
|
403
|
+
rescue Timeout::Error
|
|
404
|
+
"TIMEOUT: Execution exceeded #{timeout} seconds"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
class ExecuteTool < BaseTool
|
|
3
|
+
tool_name "execute_tool"
|
|
4
|
+
|
|
5
|
+
description <<~DESC
|
|
6
|
+
Execute a Rails MCP analyzer by name. Use search_tools first to discover available
|
|
7
|
+
analyzers and their parameters, then invoke them through this meta-tool.
|
|
8
|
+
|
|
9
|
+
This approach reduces context usage by not loading all tool definitions upfront.
|
|
10
|
+
DESC
|
|
11
|
+
|
|
12
|
+
arguments do
|
|
13
|
+
required(:tool_name).filled(:string).description(
|
|
14
|
+
"Name of the analyzer to execute (e.g., 'get_routes', 'analyze_models', 'get_schema')"
|
|
15
|
+
)
|
|
16
|
+
optional(:params).description(
|
|
17
|
+
"Hash of parameters to pass to the analyzer (e.g., { model_name: 'User', analysis_type: 'full' })"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Registry of available internal analyzers with their allowed parameters
|
|
22
|
+
INTERNAL_TOOLS = {
|
|
23
|
+
"project_info" => {
|
|
24
|
+
class_name: "Analyzers::ProjectInfo",
|
|
25
|
+
params: [:max_depth, :include_files, :detail_level]
|
|
26
|
+
},
|
|
27
|
+
"list_files" => {
|
|
28
|
+
class_name: "Analyzers::ListFiles",
|
|
29
|
+
params: [:directory, :pattern]
|
|
30
|
+
},
|
|
31
|
+
"get_file" => {
|
|
32
|
+
class_name: "Analyzers::GetFile",
|
|
33
|
+
params: [:path]
|
|
34
|
+
},
|
|
35
|
+
"get_routes" => {
|
|
36
|
+
class_name: "Analyzers::GetRoutes",
|
|
37
|
+
params: [:controller, :verb, :path_contains, :named_only, :detail_level]
|
|
38
|
+
},
|
|
39
|
+
"analyze_models" => {
|
|
40
|
+
class_name: "Analyzers::AnalyzeModels",
|
|
41
|
+
params: [:model_name, :model_names, :detail_level, :analysis_type]
|
|
42
|
+
},
|
|
43
|
+
"get_schema" => {
|
|
44
|
+
class_name: "Analyzers::GetSchema",
|
|
45
|
+
params: [:table_name, :table_names, :detail_level]
|
|
46
|
+
},
|
|
47
|
+
"analyze_controller_views" => {
|
|
48
|
+
class_name: "Analyzers::AnalyzeControllerViews",
|
|
49
|
+
params: [:controller_name, :detail_level, :analysis_type]
|
|
50
|
+
},
|
|
51
|
+
"analyze_environment_config" => {
|
|
52
|
+
class_name: "Analyzers::AnalyzeEnvironmentConfig",
|
|
53
|
+
params: []
|
|
54
|
+
},
|
|
55
|
+
"load_guide" => {
|
|
56
|
+
class_name: "Analyzers::LoadGuide",
|
|
57
|
+
params: [:guides, :guide]
|
|
58
|
+
}
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
def call(tool_name:, params: {})
|
|
62
|
+
tool_config = INTERNAL_TOOLS[tool_name]
|
|
63
|
+
|
|
64
|
+
unless tool_config
|
|
65
|
+
available = INTERNAL_TOOLS.keys.sort.join(", ")
|
|
66
|
+
return "Unknown tool '#{tool_name}'. Available: #{available}\n\nUse search_tools to discover tools and their parameters."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get the analyzer class from the Analyzers module
|
|
70
|
+
analyzer_class = tool_config[:class_name].split("::").reduce(RailsMcpServer) { |mod, name| mod.const_get(name) }
|
|
71
|
+
|
|
72
|
+
# Instantiate the analyzer
|
|
73
|
+
analyzer_instance = analyzer_class.new
|
|
74
|
+
|
|
75
|
+
# Handle params passed as JSON string (from some MCP clients like Inspector)
|
|
76
|
+
params ||= {}
|
|
77
|
+
params = JSON.parse(params, symbolize_names: true) if params.is_a?(String)
|
|
78
|
+
params = params.transform_keys(&:to_sym) if params.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
allowed_params = tool_config[:params]
|
|
81
|
+
filtered_params = params.slice(*allowed_params)
|
|
82
|
+
|
|
83
|
+
# Log any ignored params for debugging
|
|
84
|
+
ignored_params = params.keys - allowed_params
|
|
85
|
+
if ignored_params.any?
|
|
86
|
+
log(:debug, "Ignored unknown params for '#{tool_name}': #{ignored_params.join(", ")}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
log(:info, "Executing analyzer '#{tool_name}' with params: #{filtered_params.inspect}")
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
if filtered_params.empty?
|
|
93
|
+
analyzer_instance.call
|
|
94
|
+
else
|
|
95
|
+
analyzer_instance.call(**filtered_params)
|
|
96
|
+
end
|
|
97
|
+
rescue ArgumentError => e
|
|
98
|
+
"Error calling '#{tool_name}': #{e.message}\n\nUse search_tools(query: '#{tool_name}', detail_level: 'full') to see required parameters."
|
|
99
|
+
rescue => e
|
|
100
|
+
log(:error, "Error executing analyzer '#{tool_name}': #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
101
|
+
"Error executing '#{tool_name}': #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class << self
|
|
106
|
+
def available_tools
|
|
107
|
+
INTERNAL_TOOLS.keys.sort
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def tool_info(name)
|
|
111
|
+
INTERNAL_TOOLS[name]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|