rails-mcp-server 1.0.1 → 1.1.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.
@@ -0,0 +1,239 @@
1
+ module RailsMcpServer
2
+ class AnalyzeControllerViews < BaseTool
3
+ tool_name "analyze_controller_views"
4
+
5
+ description "Analyze the relationships between controllers, their actions, and corresponding views to understand the application's UI flow."
6
+
7
+ arguments do
8
+ optional(:controller_name).filled(:string).description("Name of a specific controller to analyze (e.g., 'UsersController' or 'users'). If omitted, all controllers will be analyzed.")
9
+ end
10
+
11
+ def call(controller_name: nil)
12
+ unless current_project
13
+ message = "No active project. Please switch to a project first."
14
+ log(:warn, message)
15
+
16
+ return message
17
+ end
18
+
19
+ # Find all controllers
20
+ controllers_dir = File.join(active_project_path, "app", "controllers")
21
+ unless File.directory?(controllers_dir)
22
+ message = "Controllers directory not found at app/controllers."
23
+ log(:warn, message)
24
+
25
+ return message
26
+ end
27
+
28
+ # Get all controller files
29
+ controller_files = Dir.glob(File.join(controllers_dir, "**", "*_controller.rb"))
30
+
31
+ if controller_files.empty?
32
+ message = "No controllers found in the project."
33
+ log(:warn, message)
34
+
35
+ return message
36
+ end
37
+
38
+ # If a specific controller was requested, filter the files
39
+ if controller_name
40
+ # Normalize controller name (allow both 'users' and 'UsersController')
41
+ controller_name = "#{controller_name.sub(/_?controller$/i, "").downcase}_controller.rb"
42
+ controller_files = controller_files.select { |f| File.basename(f).downcase == controller_name }
43
+
44
+ if controller_files.empty?
45
+ message = "Controller '#{controller_name}' not found."
46
+ log(:warn, message)
47
+
48
+ return message
49
+ end
50
+ end
51
+
52
+ # Parse controllers to extract actions
53
+ controllers_data = {}
54
+
55
+ controller_files.each do |file_path|
56
+ file_content = File.read(file_path)
57
+ controller_class = File.basename(file_path, ".rb").gsub(/_controller$/i, "").then { |s| camelize(s) } + "Controller"
58
+
59
+ # Extract controller actions (methods that are not private/protected)
60
+ actions = []
61
+ action_matches = file_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten
62
+
63
+ # Find where private/protected begins
64
+ private_index = file_content =~ /^\s*(private|protected)/
65
+
66
+ if private_index
67
+ # Get the actions defined before private/protected
68
+ private_content = file_content[private_index..-1]
69
+ private_methods = private_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten
70
+ actions = action_matches - private_methods
71
+ else
72
+ actions = action_matches
73
+ end
74
+
75
+ # Remove Rails controller lifecycle methods
76
+ lifecycle_methods = %w[initialize action_name controller_name params response]
77
+ actions -= lifecycle_methods
78
+
79
+ # Get routes mapped to this controller
80
+ routes_output = RailsMcpServer::RunProcess.execute_rails_command(
81
+ active_project_path,
82
+ "bin/rails routes -c #{controller_class}"
83
+ )
84
+
85
+ routes = {}
86
+ if routes_output && !routes_output.empty?
87
+ routes_output.split("\n").each do |line|
88
+ next if line.include?("(erb):") || line.include?("Prefix") || line.strip.empty?
89
+ parts = line.strip.split(/\s+/)
90
+ if parts.size >= 4
91
+ # Get action name from the rails routes output
92
+ action = parts[1].to_s.strip.downcase
93
+ if actions.include?(action)
94
+ verb = parts[0].to_s.strip
95
+ path = parts[2].to_s.strip
96
+ routes[action] = {verb: verb, path: path}
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ # Find views for each action
103
+ views_dir = File.join(active_project_path, "app", "views", File.basename(file_path, "_controller.rb"))
104
+ views = {}
105
+
106
+ if File.directory?(views_dir)
107
+ actions.each do |action|
108
+ # Look for view templates with various extensions
109
+ view_files = Dir.glob(File.join(views_dir, "#{action}.*"))
110
+ if view_files.any?
111
+ views[action] = {
112
+ templates: view_files.map { |f| f.sub("#{active_project_path}/", "") },
113
+ partials: []
114
+ }
115
+
116
+ # Look for partials used in this template
117
+ view_files.each do |view_file|
118
+ if File.file?(view_file)
119
+ view_content = File.read(view_file)
120
+ # Find render calls with partials
121
+ partial_matches = view_content.scan(/render\s+(?:partial:|:partial\s+=>\s+|:partial\s*=>|partial:)\s*["']([^"']+)["']/).flatten
122
+ views[action][:partials] += partial_matches if partial_matches.any?
123
+
124
+ # Find instance variables used in the view
125
+ instance_vars = view_content.scan(/@([a-zA-Z0-9_]+)/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
126
+ views[action][:instance_variables] = instance_vars if instance_vars.any?
127
+
128
+ # Look for Stimulus controllers
129
+ stimulus_controllers = view_content.scan(/data-controller="([^"]+)"/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
130
+ views[action][:stimulus_controllers] = stimulus_controllers if stimulus_controllers.any?
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # Extract instance variables set in the controller action
138
+ instance_vars_in_controller = {}
139
+ actions.each do |action|
140
+ # Find the action method in the controller
141
+ action_match = file_content.match(/def\s+#{action}\b(.*?)(?:(?:def|private|protected|public)\b|\z)/m)
142
+ if action_match && action_match[1]
143
+ action_body = action_match[1]
144
+ # Find instance variable assignments
145
+ vars = action_body.scan(/@([a-zA-Z0-9_]+)\s*=/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
146
+ instance_vars_in_controller[action] = vars if vars.any?
147
+ end
148
+ end
149
+
150
+ controllers_data[controller_class] = {
151
+ file: file_path.sub("#{active_project_path}/", ""),
152
+ actions: actions,
153
+ routes: routes,
154
+ views: views,
155
+ instance_variables: instance_vars_in_controller
156
+ }
157
+ rescue => e
158
+ log(:error, "Error parsing controller #{file_path}: #{e.message}")
159
+ end
160
+
161
+ # Format the output
162
+ output = []
163
+
164
+ controllers_data.each do |controller, data|
165
+ output << "Controller: #{controller}"
166
+ output << " File: #{data[:file]}"
167
+ output << " Actions: #{data[:actions].size}"
168
+
169
+ data[:actions].each do |action|
170
+ output << " Action: #{action}"
171
+
172
+ # Show route if available
173
+ if data[:routes] && data[:routes][action]
174
+ route = data[:routes][action]
175
+ output << " Route: [#{route[:verb]}] #{route[:path]}"
176
+ else
177
+ output << " Route: Not mapped to a route"
178
+ end
179
+
180
+ # Show view templates if available
181
+ if data[:views] && data[:views][action]
182
+ view_data = data[:views][action]
183
+
184
+ output << " View Templates:"
185
+ view_data[:templates].each do |template|
186
+ output << " - #{template}"
187
+ end
188
+
189
+ # Show partials
190
+ if view_data[:partials]&.any?
191
+ output << " Partials Used:"
192
+ view_data[:partials].uniq.each do |partial|
193
+ output << " - #{partial}"
194
+ end
195
+ end
196
+
197
+ # Show Stimulus controllers
198
+ if view_data[:stimulus_controllers]&.any?
199
+ output << " Stimulus Controllers:"
200
+ view_data[:stimulus_controllers].each do |controller|
201
+ output << " - #{controller}"
202
+ end
203
+ end
204
+
205
+ # Show instance variables used in views
206
+ if view_data[:instance_variables]&.any?
207
+ output << " Instance Variables Used in View:"
208
+ view_data[:instance_variables].sort.each do |var|
209
+ output << " - @#{var}"
210
+ end
211
+ end
212
+ else
213
+ output << " View: No view template found"
214
+ end
215
+
216
+ # Show instance variables set in controller
217
+ if data[:instance_variables] && data[:instance_variables][action]
218
+ output << " Instance Variables Set in Controller:"
219
+ data[:instance_variables][action].sort.each do |var|
220
+ output << " - @#{var}"
221
+ end
222
+ end
223
+
224
+ output << ""
225
+ end
226
+
227
+ output << "-------------------------"
228
+ end
229
+
230
+ output.join("\n")
231
+ end
232
+
233
+ private
234
+
235
+ def camelize(string)
236
+ string.split("_").map(&:capitalize).join
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,427 @@
1
+ module RailsMcpServer
2
+ class AnalyzeEnvironmentConfig < BaseTool
3
+ tool_name "analyze_environment_config"
4
+
5
+ description "Analyze environment configurations to identify inconsistencies, security issues, and missing variables across environments."
6
+
7
+ def call
8
+ unless current_project
9
+ message = "No active project. Please switch to a project first."
10
+ log(:warn, message)
11
+
12
+ return message
13
+ end
14
+
15
+ # Check for required directories and files
16
+ env_dir = File.join(active_project_path, "config", "environments")
17
+ unless File.directory?(env_dir)
18
+ message = "Environment configuration directory not found at config/environments."
19
+ log(:warn, message)
20
+
21
+ return message
22
+ end
23
+
24
+ # Initialize data structures
25
+ env_files = {}
26
+ env_settings = {}
27
+
28
+ # 1. Parse environment files
29
+ Dir.glob(File.join(env_dir, "*.rb")).each do |file|
30
+ env_name = File.basename(file, ".rb")
31
+ env_files[env_name] = file
32
+ env_content = File.read(file)
33
+
34
+ # Extract settings from environment files
35
+ env_settings[env_name] = extract_env_settings(env_content)
36
+ end
37
+
38
+ # 2. Find ENV variable usage across the codebase
39
+ env_vars_in_code = find_env_vars_in_codebase(active_project_path)
40
+
41
+ # 3. Check for .env files and their variables
42
+ dotenv_files = {}
43
+ dotenv_vars = {}
44
+
45
+ # Common .env file patterns
46
+ dotenv_patterns = [
47
+ ".env",
48
+ ".env.development",
49
+ ".env.test",
50
+ ".env.production",
51
+ ".env.local",
52
+ ".env.development.local",
53
+ ".env.test.local",
54
+ ".env.production.local"
55
+ ]
56
+
57
+ dotenv_patterns.each do |pattern|
58
+ file_path = File.join(active_project_path, pattern)
59
+ if File.exist?(file_path)
60
+ dotenv_files[pattern] = file_path
61
+ dotenv_vars[pattern] = parse_dotenv_file(file_path)
62
+ end
63
+ end
64
+
65
+ # 4. Check credentials files
66
+ credentials_files = {}
67
+ credentials_key_file = File.join(active_project_path, "config", "master.key")
68
+ credentials_file = File.join(active_project_path, "config", "credentials.yml.enc")
69
+
70
+ if File.exist?(credentials_file)
71
+ credentials_files["credentials.yml.enc"] = credentials_file
72
+ end
73
+
74
+ # Environment-specific credentials files
75
+ Dir.glob(File.join(active_project_path, "config", "credentials", "*.yml.enc")).each do |file|
76
+ env_name = File.basename(file, ".yml.enc")
77
+ credentials_files["credentials/#{env_name}.yml.enc"] = file
78
+ end
79
+
80
+ # 5. Check database configuration
81
+ database_config_file = File.join(active_project_path, "config", "database.yml")
82
+ database_config = {}
83
+
84
+ if File.exist?(database_config_file)
85
+ database_config = parse_database_config(database_config_file)
86
+ end
87
+
88
+ # 6. Generate findings
89
+
90
+ # 6.1. Compare environment settings
91
+ env_diff = compare_environment_settings(env_settings)
92
+
93
+ # 6.2. Find missing ENV variables
94
+ missing_env_vars = find_missing_env_vars(env_vars_in_code, dotenv_vars)
95
+
96
+ # 6.3. Check for potential security issues
97
+ security_findings = check_security_configuration(env_settings, database_config)
98
+
99
+ # Format the output
100
+ output = []
101
+
102
+ # Environment files summary
103
+ output << "Environment Configuration Analysis"
104
+ output << "=================================="
105
+ output << ""
106
+ output << "Environment Files:"
107
+ env_files.each do |env, file|
108
+ output << " - #{env}: #{file.sub("#{active_project_path}/", "")}"
109
+ end
110
+ output << ""
111
+
112
+ # Environment variables summary
113
+ output << "Environment Variables Usage:"
114
+ output << " Total unique ENV variables found in codebase: #{env_vars_in_code.keys.size}"
115
+ output << ""
116
+
117
+ # Missing ENV variables
118
+ if missing_env_vars.any?
119
+ output << "Missing ENV Variables:"
120
+ missing_env_vars.each do |env_var, environments|
121
+ output << " - #{env_var}: Used in codebase but missing in #{environments.join(", ")}"
122
+ end
123
+ else
124
+ output << "All ENV variables appear to be defined in at least one .env file."
125
+ end
126
+ output << ""
127
+
128
+ # Environment differences
129
+ if env_diff[:unique_settings].any?
130
+ output << "Environment-Specific Settings:"
131
+ env_diff[:unique_settings].each do |env, settings|
132
+ output << " #{env}:"
133
+ settings.each do |setting|
134
+ output << " - #{setting}"
135
+ end
136
+ end
137
+ output << ""
138
+ end
139
+
140
+ if env_diff[:different_values].any?
141
+ output << "Settings with Different Values Across Environments:"
142
+ env_diff[:different_values].each do |setting, values|
143
+ output << " #{setting}:"
144
+ values.each do |env, value|
145
+ output << " - #{env}: #{value}"
146
+ end
147
+ end
148
+ output << ""
149
+ end
150
+
151
+ # Credentials files
152
+ output << "Credentials Management:"
153
+ if credentials_files.any?
154
+ output << " Encrypted credentials files found:"
155
+ credentials_files.each do |name, file|
156
+ output << " - #{name}"
157
+ end
158
+
159
+ output << if File.exist?(credentials_key_file)
160
+ " Master key file exists (config/master.key)"
161
+ else
162
+ " Warning: No master.key file found. Credentials are likely managed through RAILS_MASTER_KEY environment variable."
163
+ end
164
+ else
165
+ output << " No encrypted credentials files found. The application may be using ENV variables exclusively."
166
+ end
167
+ output << ""
168
+
169
+ # Database configuration
170
+ output << "Database Configuration:"
171
+ if database_config.any?
172
+ database_config.each do |env, config|
173
+ output << " #{env}:"
174
+ # Show connection details without exposing passwords
175
+ if config["adapter"]
176
+ output << " - Adapter: #{config["adapter"]}"
177
+ end
178
+ if config["host"] && config["host"] != "localhost" && config["host"] != "127.0.0.1"
179
+ output << " - Host: #{config["host"]}"
180
+ end
181
+ if config["database"]
182
+ output << " - Database: #{config["database"]}"
183
+ end
184
+
185
+ # Check for credentials in database.yml
186
+ if config["username"] && !config["username"].include?("ENV")
187
+ output << " - Warning: Database username hardcoded in database.yml"
188
+ end
189
+ if config["password"] && !config["password"].include?("ENV")
190
+ output << " - Warning: Database password hardcoded in database.yml"
191
+ end
192
+ end
193
+ else
194
+ output << " Could not parse database configuration."
195
+ end
196
+ output << ""
197
+
198
+ # Security findings
199
+ if security_findings.any?
200
+ output << "Security Configuration Findings:"
201
+ security_findings.each do |finding|
202
+ output << " - #{finding}"
203
+ end
204
+ output << ""
205
+ end
206
+
207
+ output.join("\n")
208
+ end
209
+
210
+ private
211
+
212
+ # Helper method to extract settings from environment files
213
+ def extract_env_settings(content)
214
+ settings = {}
215
+
216
+ # Match configuration settings
217
+ content.scan(/config\.([a-zA-Z0-9_.]+)\s*=\s*([^#\n]+)/) do |match|
218
+ key = match[0].strip
219
+ value = match[1].strip
220
+
221
+ # Clean up the value
222
+ value = value.chomp(";").strip
223
+
224
+ settings[key] = value
225
+ end
226
+
227
+ settings
228
+ end
229
+
230
+ # Helper method to find ENV variable usage in the codebase
231
+ def find_env_vars_in_codebase(project_path)
232
+ env_vars = {}
233
+
234
+ # Define directories to search
235
+ search_dirs = [
236
+ File.join(project_path, "app"),
237
+ File.join(project_path, "config"),
238
+ File.join(project_path, "lib")
239
+ ]
240
+
241
+ # Define file patterns to search
242
+ file_patterns = ["*.rb", "*.yml", "*.erb", "*.js"]
243
+
244
+ search_dirs.each do |dir|
245
+ if File.directory?(dir)
246
+ file_patterns.each do |pattern|
247
+ Dir.glob(File.join(dir, "**", pattern)).each do |file|
248
+ content = File.read(file)
249
+
250
+ # Extract ENV variables
251
+ content.scan(/ENV\s*\[\s*['"]([^'"]+)['"]\s*\]/).each do |match|
252
+ env_var = match[0]
253
+ env_vars[env_var] ||= []
254
+ env_vars[env_var] << file.sub("#{project_path}/", "")
255
+ end
256
+
257
+ # Also match ENV['VAR'] pattern
258
+ content.scan(/ENV\s*\.\s*\[\s*['"]([^'"]+)['"]\s*\]/).each do |match|
259
+ env_var = match[0]
260
+ env_vars[env_var] ||= []
261
+ env_vars[env_var] << file.sub("#{project_path}/", "")
262
+ end
263
+
264
+ # Also match ENV.fetch('VAR') pattern
265
+ content.scan(/ENV\s*\.\s*fetch\s*\(\s*['"]([^'"]+)['"]\s*/).each do |match|
266
+ env_var = match[0]
267
+ env_vars[env_var] ||= []
268
+ env_vars[env_var] << file.sub("#{project_path}/", "")
269
+ end
270
+ rescue => e
271
+ log(:error, "Error reading file #{file}: #{e.message}")
272
+ end
273
+ end
274
+ end
275
+ end
276
+
277
+ env_vars
278
+ end
279
+
280
+ # Helper method to parse .env files
281
+ def parse_dotenv_file(file_path)
282
+ vars = {}
283
+
284
+ begin
285
+ File.readlines(file_path).each do |line| # rubocop:disable Performance/IoReadlines
286
+ # Skip comments and empty lines
287
+ next if line.strip.empty? || line.strip.start_with?("#")
288
+
289
+ # Parse KEY=value pattern
290
+ if line =~ /\A([A-Za-z0-9_]+)=(.*)\z/
291
+ key = $1
292
+ # Store just the existence of the variable, not its value
293
+ vars[key] = true
294
+ end
295
+ end
296
+ rescue => e
297
+ log(:error, "Error parsing .env file #{file_path}: #{e.message}")
298
+ end
299
+
300
+ vars
301
+ end
302
+
303
+ # Helper method to parse database.yml
304
+ def parse_database_config(file_path)
305
+ config = {}
306
+
307
+ begin
308
+ # Simple YAML parsing - not handling ERB
309
+ yaml_content = File.read(file_path)
310
+ yaml_data = YAML.safe_load(yaml_content) || {}
311
+
312
+ # Extract environment configurations
313
+ %w[development test production staging].each do |env|
314
+ config[env] = yaml_data[env] if yaml_data[env]
315
+ end
316
+ rescue => e
317
+ log(:error, "Error parsing database.yml: #{e.message}")
318
+ end
319
+
320
+ config
321
+ end
322
+
323
+ # Helper method to compare environment settings
324
+ def compare_environment_settings(env_settings)
325
+ result = {
326
+ unique_settings: {},
327
+ different_values: {}
328
+ }
329
+
330
+ # Get all settings across all environments
331
+ all_settings = env_settings.values.map(&:keys).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
332
+
333
+ # Find settings unique to certain environments
334
+ env_settings.each do |env, settings|
335
+ unique = settings.keys - (all_settings - settings.keys)
336
+ result[:unique_settings][env] = unique if unique.any?
337
+ end
338
+
339
+ # Find settings with different values across environments
340
+ all_settings.each do |setting|
341
+ values = {}
342
+
343
+ env_settings.each do |env, settings|
344
+ values[env] = settings[setting] if settings[setting]
345
+ end
346
+
347
+ # Only include if there are different values
348
+ if values.values.uniq.size > 1
349
+ result[:different_values][setting] = values
350
+ end
351
+ end
352
+
353
+ result
354
+ end
355
+
356
+ # Helper method to find missing ENV variables
357
+ def find_missing_env_vars(env_vars_in_code, dotenv_vars)
358
+ missing_vars = {}
359
+
360
+ # Check each ENV variable used in code
361
+ env_vars_in_code.each do |var, files|
362
+ # Environments where the variable is missing
363
+ missing_in = []
364
+
365
+ # Check in each .env file
366
+ if dotenv_vars.empty?
367
+ missing_in << "all environments (no .env files found)"
368
+ else
369
+ dotenv_vars.each do |env_file, vars|
370
+ env_name = env_file.gsub(/^\.env\.?|\.local$/, "")
371
+ env_name = "development" if env_name.empty?
372
+
373
+ if !vars.key?(var)
374
+ missing_in << env_name
375
+ end
376
+ end
377
+ end
378
+
379
+ missing_vars[var] = missing_in if missing_in.any?
380
+ end
381
+
382
+ missing_vars
383
+ end
384
+
385
+ # Helper method to check for security issues
386
+ def check_security_configuration(env_settings, database_config)
387
+ findings = []
388
+
389
+ # Check for common security settings
390
+ env_settings.each do |env, settings|
391
+ # Check for secure cookies in production
392
+ if env == "production"
393
+ if settings["cookies.secure"] == "false"
394
+ findings << "Production has cookies.secure = false"
395
+ end
396
+
397
+ if settings["session_store.secure"] == "false"
398
+ findings << "Production has session_store.secure = false"
399
+ end
400
+
401
+ # Force SSL
402
+ if settings["force_ssl"] == "false"
403
+ findings << "Production has force_ssl = false"
404
+ end
405
+ end
406
+
407
+ # Check for CSRF protection
408
+ if settings["action_controller.default_protect_from_forgery"] == "false"
409
+ findings << "#{env} has CSRF protection disabled"
410
+ end
411
+ end
412
+
413
+ # Check for hardcoded credentials in database.yml
414
+ database_config.each do |env, config|
415
+ if config["username"] && !config["username"].include?("ENV")
416
+ findings << "Database username hardcoded in database.yml for #{env}"
417
+ end
418
+
419
+ if config["password"] && !config["password"].include?("ENV")
420
+ findings << "Database password hardcoded in database.yml for #{env}"
421
+ end
422
+ end
423
+
424
+ findings
425
+ end
426
+ end
427
+ end