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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +168 -166
  3. data/docs/AGENT.md +345 -0
  4. data/exe/rails-mcp-config +1411 -0
  5. data/exe/rails-mcp-server +23 -10
  6. data/exe/rails-mcp-setup-claude +1 -1
  7. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
  8. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
  10. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
  11. data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
  12. data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
  13. data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
  14. data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
  15. data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
  16. data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
  17. data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
  18. data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
  19. data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
  20. data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
  21. data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
  22. data/lib/rails-mcp-server/version.rb +1 -1
  23. data/lib/rails_mcp_server.rb +19 -53
  24. metadata +65 -18
  25. data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
  26. data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
  27. data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
  28. data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
  29. data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
  30. data/lib/rails-mcp-server/tools/get_file.rb +0 -55
  31. data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
  32. data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
  33. data/lib/rails-mcp-server/tools/list_files.rb +0 -54
  34. data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
  35. 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
@@ -1,3 +1,5 @@
1
+ require "fast_mcp"
2
+
1
3
  module RailsMcpServer
2
4
  class BaseTool < FastMcp::Tool
3
5
  extend Forwardable
@@ -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