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.
- checksums.yaml +4 -4
- data/README.md +91 -169
- data/exe/rails-mcp-server +89 -17
- data/exe/rails-mcp-setup-claude +2 -1
- data/lib/rails-mcp-server/config.rb +72 -0
- data/lib/rails-mcp-server/tools/analyze_controller_views.rb +239 -0
- data/lib/rails-mcp-server/tools/analyze_environment_config.rb +427 -0
- data/lib/rails-mcp-server/tools/analyze_models.rb +116 -0
- data/lib/rails-mcp-server/tools/base_tool.rb +9 -0
- data/lib/rails-mcp-server/tools/get_file.rb +55 -0
- data/lib/rails-mcp-server/tools/get_model.rb +116 -0
- data/lib/rails-mcp-server/tools/get_routes.rb +24 -0
- data/lib/rails-mcp-server/tools/get_schema.rb +141 -0
- data/lib/rails-mcp-server/tools/list_files.rb +54 -0
- data/lib/rails-mcp-server/tools/project_info.rb +86 -0
- data/lib/rails-mcp-server/tools/switch_project.rb +25 -0
- data/lib/rails-mcp-server/utilities/run_process.rb +29 -0
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +31 -864
- metadata +44 -3
data/lib/rails_mcp_server.rb
CHANGED
@@ -1,93 +1,46 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
require "mcp"
|
4
|
-
require "yaml"
|
5
1
|
require "logger"
|
6
|
-
require "json"
|
7
2
|
require "fileutils"
|
3
|
+
require "forwardable"
|
4
|
+
require "open3"
|
8
5
|
require_relative "rails-mcp-server/version"
|
6
|
+
require_relative "rails-mcp-server/config"
|
7
|
+
require_relative "rails-mcp-server/utilities/run_process"
|
8
|
+
require_relative "rails-mcp-server/tools/base_tool"
|
9
|
+
require_relative "rails-mcp-server/tools/project_info"
|
10
|
+
require_relative "rails-mcp-server/tools/list_files"
|
11
|
+
require_relative "rails-mcp-server/tools/get_file"
|
12
|
+
require_relative "rails-mcp-server/tools/get_routes"
|
13
|
+
require_relative "rails-mcp-server/tools/analyze_models"
|
14
|
+
require_relative "rails-mcp-server/tools/get_schema"
|
15
|
+
require_relative "rails-mcp-server/tools/analyze_controller_views"
|
16
|
+
require_relative "rails-mcp-server/tools/analyze_environment_config"
|
17
|
+
require_relative "rails-mcp-server/tools/switch_project"
|
9
18
|
|
10
19
|
module RailsMcpServer
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
# rubocop:disable Style/GlobalVars
|
15
|
-
# Initialize configuration
|
16
|
-
def get_config_dir
|
17
|
-
# Use XDG_CONFIG_HOME if set, otherwise use ~/.config
|
18
|
-
xdg_config_home = ENV["XDG_CONFIG_HOME"]
|
19
|
-
if xdg_config_home && !xdg_config_home.empty?
|
20
|
-
File.join(xdg_config_home, "rails-mcp")
|
21
|
-
else
|
22
|
-
File.join(Dir.home, ".config", "rails-mcp")
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
# Create config directory if it doesn't exist
|
27
|
-
config_dir = get_config_dir
|
28
|
-
FileUtils.mkdir_p(File.join(config_dir, "log"))
|
20
|
+
@levels = {debug: Logger::DEBUG, info: Logger::INFO, error: Logger::ERROR}
|
21
|
+
@config = Config.setup
|
29
22
|
|
30
|
-
|
31
|
-
|
32
|
-
log_file = File.join(config_dir, "log", "rails_mcp_server.log")
|
33
|
-
log_level = :info
|
23
|
+
class << self
|
24
|
+
extend Forwardable
|
34
25
|
|
35
|
-
|
36
|
-
i = 0
|
37
|
-
while i < ARGV.length
|
38
|
-
case ARGV[i]
|
39
|
-
when "--log-level"
|
40
|
-
log_level = ARGV[i + 1].to_sym
|
41
|
-
i += 2
|
42
|
-
else
|
43
|
-
i += 1
|
44
|
-
end
|
45
|
-
end
|
26
|
+
attr_reader :config
|
46
27
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
$logger.formatter = proc do |severity, datetime, progname, msg|
|
53
|
-
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg}\n"
|
54
|
-
end
|
28
|
+
def_delegators :@config, :log_level, :log_level=
|
29
|
+
def_delegators :@config, :logger, :logger=
|
30
|
+
def_delegators :@config, :projects
|
31
|
+
def_delegators :@config, :current_project, :current_project=
|
32
|
+
def_delegators :@config, :active_project_path, :active_project_path=
|
55
33
|
|
56
|
-
def log(level, message)
|
57
|
-
|
58
|
-
log_level = levels[level] || Logger::INFO
|
59
|
-
$logger.add(log_level, message)
|
60
|
-
end
|
61
|
-
|
62
|
-
log(:info, "Starting Rails MCP Server...")
|
63
|
-
log(:info, "Using config directory: #{config_dir}")
|
64
|
-
|
65
|
-
# Create empty projects file if it doesn't exist
|
66
|
-
unless File.exist?(projects_file)
|
67
|
-
log(:info, "Creating empty projects file: #{projects_file}")
|
68
|
-
FileUtils.mkdir_p(File.dirname(projects_file))
|
69
|
-
File.write(projects_file, "# Rails MCP Projects\n# Format: project_name: /path/to/project\n")
|
70
|
-
end
|
34
|
+
def log(level, message)
|
35
|
+
log_level = @levels[level] || Logger::INFO
|
71
36
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
if File.exist?(projects_file)
|
77
|
-
log(:info, "Loading projects from: #{projects_file}")
|
78
|
-
projects = YAML.load_file(projects_file) || {}
|
79
|
-
log(:info, "Loaded #{projects.size} projects: #{projects.keys.join(", ")}")
|
80
|
-
else
|
81
|
-
log(:warn, "Projects file not found: #{projects_file}")
|
37
|
+
@config.logger.add(log_level, message)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
class Error < StandardError; end
|
82
41
|
end
|
83
42
|
|
84
|
-
#
|
85
|
-
$active_project = nil
|
86
|
-
$active_project_path = nil
|
87
|
-
|
88
|
-
# Define MCP server using the mcp-rb DSL
|
89
|
-
name "rails-mcp-server"
|
90
|
-
version RailsMcpServer::VERSION
|
43
|
+
# rubocop:disable Style/GlobalVars
|
91
44
|
|
92
45
|
# Utility functions for Rails operations
|
93
46
|
def get_directory_structure(path, max_depth: 3, current_depth: 0, prefix: "")
|
@@ -379,790 +332,4 @@ def check_security_configuration(env_settings, database_config)
|
|
379
332
|
|
380
333
|
findings
|
381
334
|
end
|
382
|
-
|
383
|
-
# Define tools using the mcp-rb DSL
|
384
|
-
tool "switch_project" do
|
385
|
-
description "Change the active Rails project to interact with a different codebase. Must be called before using other tools. Available projects are defined in the projects.yml configuration file."
|
386
|
-
|
387
|
-
argument :project_name, String, required: true,
|
388
|
-
description: "Name of the project as defined in the projects.yml file (case-sensitive)"
|
389
|
-
|
390
|
-
call do |args|
|
391
|
-
project_name = args[:project_name]
|
392
|
-
|
393
|
-
if projects.key?(project_name)
|
394
|
-
$active_project = project_name
|
395
|
-
$active_project_path = File.expand_path(projects[project_name])
|
396
|
-
log(:info, "Switched to project: #{project_name} at path: #{$active_project_path}")
|
397
|
-
"Switched to project: #{project_name} at path: #{$active_project_path}"
|
398
|
-
else
|
399
|
-
log(:warn, "Project not found: #{project_name}")
|
400
|
-
raise "Project '#{project_name}' not found. Available projects: #{projects.keys.join(", ")}"
|
401
|
-
end
|
402
|
-
end
|
403
|
-
end
|
404
|
-
|
405
|
-
tool "get_project_info" do
|
406
|
-
description "Retrieve comprehensive information about the current Rails project, including Rails version, directory structure, API-only status, and overall project organization. Useful for initial project exploration and understanding the codebase structure."
|
407
|
-
|
408
|
-
call do |args|
|
409
|
-
unless $active_project
|
410
|
-
raise "No active project. Please switch to a project first."
|
411
|
-
end
|
412
|
-
|
413
|
-
# Get additional project information
|
414
|
-
gemfile_path = File.join($active_project_path, "Gemfile")
|
415
|
-
gemfile_content = File.exist?(gemfile_path) ? File.read(gemfile_path) : "Gemfile not found"
|
416
|
-
|
417
|
-
# Get Rails version
|
418
|
-
rails_version = gemfile_content.match(/gem ['"]rails['"],\s*['"](.+?)['"]/)&.captures&.first || "Unknown"
|
419
|
-
|
420
|
-
# Check if it's an API-only app
|
421
|
-
config_application_path = File.join($active_project_path, "config", "application.rb")
|
422
|
-
is_api_only = File.exist?(config_application_path) &&
|
423
|
-
File.read(config_application_path).include?("config.api_only = true")
|
424
|
-
|
425
|
-
log(:info, "Project info: Rails v#{rails_version}, API-only: #{is_api_only}")
|
426
|
-
|
427
|
-
<<~INFO
|
428
|
-
Current project: #{$active_project}
|
429
|
-
Path: #{$active_project_path}
|
430
|
-
Rails version: #{rails_version}
|
431
|
-
API only: #{is_api_only ? "Yes" : "No"}
|
432
|
-
|
433
|
-
Project structure:
|
434
|
-
#{get_directory_structure($active_project_path, max_depth: 2)}
|
435
|
-
INFO
|
436
|
-
end
|
437
|
-
end
|
438
|
-
|
439
|
-
tool "list_files" do
|
440
|
-
description "List files in the Rails project matching specific criteria. Use this to explore project directories or locate specific file types. If no parameters are provided, lists files in the project root."
|
441
|
-
|
442
|
-
argument :directory, String, required: false,
|
443
|
-
description: "Directory path relative to the project root (e.g., 'app/models', 'config'). Leave empty to list files at the root."
|
444
|
-
|
445
|
-
argument :pattern, String, required: false,
|
446
|
-
description: "File pattern using glob syntax (e.g., '*.rb' for Ruby files, '*.erb' for ERB templates, '*_controller.rb' for controllers)"
|
447
|
-
|
448
|
-
call do |args|
|
449
|
-
unless $active_project
|
450
|
-
raise "No active project. Please switch to a project first."
|
451
|
-
end
|
452
|
-
|
453
|
-
directory = args[:directory] || ""
|
454
|
-
pattern = args[:pattern] || "*"
|
455
|
-
|
456
|
-
full_path = File.join($active_project_path, directory)
|
457
|
-
|
458
|
-
unless File.directory?(full_path)
|
459
|
-
raise "Directory '#{directory}' not found in the project."
|
460
|
-
end
|
461
|
-
|
462
|
-
# Check if this is a git repository
|
463
|
-
is_git_repo = system("cd #{$active_project_path} && git rev-parse --is-inside-work-tree > /dev/null 2>&1")
|
464
|
-
|
465
|
-
if is_git_repo
|
466
|
-
log(:debug, "Project is a git repository, using git ls-files")
|
467
|
-
|
468
|
-
# Use git ls-files for tracked files
|
469
|
-
relative_dir = directory.empty? ? "" : "#{directory}/"
|
470
|
-
git_cmd = "cd #{$active_project_path} && git ls-files --cached --others --exclude-standard #{relative_dir}#{pattern}"
|
471
|
-
|
472
|
-
files = `#{git_cmd}`.split("\n").map(&:strip).sort # rubocop:disable Performance/ChainArrayAllocation
|
473
|
-
else
|
474
|
-
log(:debug, "Project is not a git repository or git not available, using Dir.glob")
|
475
|
-
|
476
|
-
# Use Dir.glob as fallback
|
477
|
-
files = Dir.glob(File.join(full_path, pattern))
|
478
|
-
.map { |f| f.sub("#{$active_project_path}/", "") }
|
479
|
-
.reject { |file| file.start_with?(".git/", "node_modules/") } # Explicitly filter .git and node_modules directories # rubocop:disable Performance/ChainArrayAllocation
|
480
|
-
.sort # rubocop:disable Performance/ChainArrayAllocation
|
481
|
-
end
|
482
|
-
|
483
|
-
log(:debug, "Found #{files.size} files matching pattern (respecting .gitignore and ignoring node_modules)")
|
484
|
-
|
485
|
-
"Files in #{directory.empty? ? "project root" : directory} matching '#{pattern}':\n\n#{files.join("\n")}"
|
486
|
-
end
|
487
|
-
end
|
488
|
-
|
489
|
-
tool "get_file" do
|
490
|
-
description "Retrieve the complete content of a specific file with syntax highlighting. Use this to examine implementation details, configurations, or any text file in the project."
|
491
|
-
|
492
|
-
argument :path, String, required: true,
|
493
|
-
description: "File path relative to the project root (e.g., 'app/models/user.rb', 'config/routes.rb'). Use list_files first if you're not sure about the exact path."
|
494
|
-
|
495
|
-
call do |args|
|
496
|
-
unless $active_project
|
497
|
-
raise "No active project. Please switch to a project first."
|
498
|
-
end
|
499
|
-
|
500
|
-
path = args[:path]
|
501
|
-
full_path = File.join($active_project_path, path)
|
502
|
-
|
503
|
-
unless File.exist?(full_path)
|
504
|
-
raise "File '#{path}' not found in the project."
|
505
|
-
end
|
506
|
-
|
507
|
-
content = File.read(full_path)
|
508
|
-
log(:debug, "Read file: #{path} (#{content.size} bytes)")
|
509
|
-
|
510
|
-
"File: #{path}\n\n```#{get_file_extension(path)}\n#{content}\n```"
|
511
|
-
end
|
512
|
-
end
|
513
|
-
|
514
|
-
tool "get_routes" do
|
515
|
-
description "Retrieve all HTTP routes defined in the Rails application with their associated controllers and actions. Equivalent to running 'rails routes' command. This helps understand the API endpoints or page URLs available in the application."
|
516
|
-
|
517
|
-
call do |args|
|
518
|
-
unless $active_project
|
519
|
-
raise "No active project. Please switch to a project first."
|
520
|
-
end
|
521
|
-
|
522
|
-
# Execute the Rails routes command
|
523
|
-
routes_output = execute_rails_command($active_project_path, "routes")
|
524
|
-
log(:debug, "Routes command completed, output size: #{routes_output.size} bytes")
|
525
|
-
|
526
|
-
"Rails Routes:\n\n```\n#{routes_output}\n```"
|
527
|
-
end
|
528
|
-
end
|
529
|
-
|
530
|
-
tool "get_models" do
|
531
|
-
description "Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code."
|
532
|
-
|
533
|
-
argument :model_name, String, required: false,
|
534
|
-
description: "Class name of a specific model to get detailed information for (e.g., 'User', 'Product'). Use CamelCase, not snake_case. If omitted, returns a list of all models."
|
535
|
-
|
536
|
-
call do |args|
|
537
|
-
unless $active_project
|
538
|
-
raise "No active project. Please switch to a project first."
|
539
|
-
end
|
540
|
-
|
541
|
-
model_name = args[:model_name]
|
542
|
-
|
543
|
-
if model_name
|
544
|
-
log(:info, "Getting info for specific model: #{model_name}")
|
545
|
-
|
546
|
-
# Check if the model file exists
|
547
|
-
model_file = File.join($active_project_path, "app", "models", "#{underscore(model_name)}.rb")
|
548
|
-
unless File.exist?(model_file)
|
549
|
-
log(:warn, "Model file not found: #{model_name}")
|
550
|
-
raise "Model '#{model_name}' not found."
|
551
|
-
end
|
552
|
-
|
553
|
-
log(:debug, "Reading model file: #{model_file}")
|
554
|
-
|
555
|
-
# Get the model file content
|
556
|
-
model_content = File.read(model_file)
|
557
|
-
|
558
|
-
# Try to get schema information
|
559
|
-
log(:debug, "Executing Rails runner to get schema information")
|
560
|
-
schema_info = execute_rails_command(
|
561
|
-
$active_project_path,
|
562
|
-
"runner \"puts #{model_name}.column_names\""
|
563
|
-
)
|
564
|
-
|
565
|
-
# Try to get associations
|
566
|
-
associations = []
|
567
|
-
if model_content.include?("has_many")
|
568
|
-
has_many = model_content.scan(/has_many\s+:(\w+)/).flatten
|
569
|
-
associations << "Has many: #{has_many.join(", ")}" unless has_many.empty?
|
570
|
-
end
|
571
|
-
|
572
|
-
if model_content.include?("belongs_to")
|
573
|
-
belongs_to = model_content.scan(/belongs_to\s+:(\w+)/).flatten
|
574
|
-
associations << "Belongs to: #{belongs_to.join(", ")}" unless belongs_to.empty?
|
575
|
-
end
|
576
|
-
|
577
|
-
if model_content.include?("has_one")
|
578
|
-
has_one = model_content.scan(/has_one\s+:(\w+)/).flatten
|
579
|
-
associations << "Has one: #{has_one.join(", ")}" unless has_one.empty?
|
580
|
-
end
|
581
|
-
|
582
|
-
log(:debug, "Found #{associations.size} associations for model: #{model_name}")
|
583
|
-
|
584
|
-
# Format the output
|
585
|
-
<<~INFO
|
586
|
-
Model: #{model_name}
|
587
|
-
|
588
|
-
Schema:
|
589
|
-
#{schema_info}
|
590
|
-
|
591
|
-
Associations:
|
592
|
-
#{associations.empty? ? "None found" : associations.join("\n")}
|
593
|
-
|
594
|
-
Model Definition:
|
595
|
-
```ruby
|
596
|
-
#{model_content}
|
597
|
-
```
|
598
|
-
INFO
|
599
|
-
else
|
600
|
-
log(:info, "Listing all models")
|
601
|
-
|
602
|
-
# List all models
|
603
|
-
models_dir = File.join($active_project_path, "app", "models")
|
604
|
-
unless File.directory?(models_dir)
|
605
|
-
raise "Models directory not found."
|
606
|
-
end
|
607
|
-
|
608
|
-
# Get all .rb files in the models directory and its subdirectories
|
609
|
-
model_files = Dir.glob(File.join(models_dir, "**", "*.rb"))
|
610
|
-
.map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") }
|
611
|
-
.sort # rubocop:disable Performance/ChainArrayAllocation
|
612
|
-
|
613
|
-
log(:debug, "Found #{model_files.size} model files")
|
614
|
-
|
615
|
-
"Models in the project:\n\n#{model_files.join("\n")}"
|
616
|
-
end
|
617
|
-
end
|
618
|
-
end
|
619
|
-
|
620
|
-
tool "get_schema" do
|
621
|
-
description "Retrieve database schema information for the Rails application. Without parameters, returns all tables and the complete schema.rb. With a table name, returns detailed column information including data types, constraints, and foreign keys for that specific table."
|
622
|
-
|
623
|
-
argument :table_name, String, required: false,
|
624
|
-
description: "Database table name to get detailed schema information for (e.g., 'users', 'products'). Use snake_case, plural form. If omitted, returns complete database schema."
|
625
|
-
|
626
|
-
call do |args|
|
627
|
-
unless $active_project
|
628
|
-
raise "No active project. Please switch to a project first."
|
629
|
-
end
|
630
|
-
|
631
|
-
table_name = args[:table_name]
|
632
|
-
|
633
|
-
if table_name
|
634
|
-
log(:info, "Getting schema for table: #{table_name}")
|
635
|
-
|
636
|
-
# Execute the Rails schema command for a specific table
|
637
|
-
schema_output = execute_rails_command(
|
638
|
-
$active_project_path,
|
639
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.columns('#{table_name}').map{|c| [c.name, c.type, c.null, c.default].inspect}.join('\n')\""
|
640
|
-
)
|
641
|
-
|
642
|
-
if schema_output.strip.empty?
|
643
|
-
raise "Table '#{table_name}' not found or has no columns."
|
644
|
-
end
|
645
|
-
|
646
|
-
# Parse the column information
|
647
|
-
columns = schema_output.strip.split("\n").map do |column_info|
|
648
|
-
eval(column_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval
|
649
|
-
end
|
650
|
-
|
651
|
-
# Format the output
|
652
|
-
formatted_columns = columns.map do |name, type, nullable, default|
|
653
|
-
"#{name} (#{type})#{nullable ? ", nullable" : ""}#{default ? ", default: #{default}" : ""}"
|
654
|
-
end
|
655
|
-
|
656
|
-
output = <<~SCHEMA
|
657
|
-
Table: #{table_name}
|
658
|
-
|
659
|
-
Columns:
|
660
|
-
#{formatted_columns.join("\n")}
|
661
|
-
SCHEMA
|
662
|
-
|
663
|
-
# Try to get foreign keys
|
664
|
-
begin
|
665
|
-
fk_output = execute_rails_command(
|
666
|
-
$active_project_path,
|
667
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.foreign_keys('#{table_name}').map{|fk| [fk.from_table, fk.to_table, fk.column, fk.primary_key].inspect}.join('\n')\""
|
668
|
-
)
|
669
|
-
|
670
|
-
unless fk_output.strip.empty?
|
671
|
-
foreign_keys = fk_output.strip.split("\n").map do |fk_info|
|
672
|
-
eval(fk_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval
|
673
|
-
end
|
674
|
-
|
675
|
-
formatted_fks = foreign_keys.map do |from_table, to_table, column, primary_key|
|
676
|
-
"#{column} -> #{to_table}.#{primary_key}"
|
677
|
-
end
|
678
|
-
|
679
|
-
output += <<~FK
|
680
|
-
|
681
|
-
Foreign Keys:
|
682
|
-
#{formatted_fks.join("\n")}
|
683
|
-
FK
|
684
|
-
end
|
685
|
-
rescue => e
|
686
|
-
log(:warn, "Error fetching foreign keys: #{e.message}")
|
687
|
-
end
|
688
|
-
|
689
|
-
output
|
690
|
-
else
|
691
|
-
log(:info, "Getting full schema")
|
692
|
-
|
693
|
-
# Execute the Rails schema:dump command
|
694
|
-
# First, check if we need to create the schema file
|
695
|
-
schema_file = File.join($active_project_path, "db", "schema.rb")
|
696
|
-
unless File.exist?(schema_file)
|
697
|
-
log(:info, "Schema file not found, attempting to generate it")
|
698
|
-
execute_rails_command($active_project_path, "db:schema:dump")
|
699
|
-
end
|
700
|
-
|
701
|
-
if File.exist?(schema_file)
|
702
|
-
# Read the schema file
|
703
|
-
schema_content = File.read(schema_file)
|
704
|
-
|
705
|
-
# Try to get table list
|
706
|
-
tables_output = execute_rails_command(
|
707
|
-
$active_project_path,
|
708
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\""
|
709
|
-
)
|
710
|
-
|
711
|
-
tables = tables_output.strip.split("\n")
|
712
|
-
|
713
|
-
<<~SCHEMA
|
714
|
-
Database Schema
|
715
|
-
|
716
|
-
Tables:
|
717
|
-
#{tables.join("\n")}
|
718
|
-
|
719
|
-
Schema Definition:
|
720
|
-
```ruby
|
721
|
-
#{schema_content}
|
722
|
-
```
|
723
|
-
SCHEMA
|
724
|
-
|
725
|
-
else
|
726
|
-
# If we can't get the schema file, try to get the table list
|
727
|
-
tables_output = execute_rails_command(
|
728
|
-
$active_project_path,
|
729
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\""
|
730
|
-
)
|
731
|
-
|
732
|
-
if tables_output.strip.empty?
|
733
|
-
raise "Could not retrieve schema information. Try running 'rails db:schema:dump' in your project first."
|
734
|
-
end
|
735
|
-
|
736
|
-
tables = tables_output.strip.split("\n")
|
737
|
-
|
738
|
-
<<~SCHEMA
|
739
|
-
Database Schema
|
740
|
-
|
741
|
-
Tables:
|
742
|
-
#{tables.join("\n")}
|
743
|
-
|
744
|
-
Note: Full schema definition is not available. Run 'rails db:schema:dump' to generate the schema.rb file.
|
745
|
-
SCHEMA
|
746
|
-
end
|
747
|
-
end
|
748
|
-
end
|
749
|
-
end
|
750
|
-
|
751
|
-
tool "analyze_controller_views" do
|
752
|
-
description "Analyze the relationships between controllers, their actions, and corresponding views to understand the application's UI flow."
|
753
|
-
|
754
|
-
argument :controller_name, String, required: false,
|
755
|
-
description: "Name of a specific controller to analyze (e.g., 'UsersController' or 'users'). If omitted, all controllers will be analyzed."
|
756
|
-
|
757
|
-
call do |args|
|
758
|
-
unless $active_project
|
759
|
-
raise "No active project. Please switch to a project first."
|
760
|
-
end
|
761
|
-
|
762
|
-
controller_name = args[:controller_name]
|
763
|
-
|
764
|
-
# Find all controllers
|
765
|
-
controllers_dir = File.join($active_project_path, "app", "controllers")
|
766
|
-
unless File.directory?(controllers_dir)
|
767
|
-
raise "Controllers directory not found at app/controllers."
|
768
|
-
end
|
769
|
-
|
770
|
-
# Get all controller files
|
771
|
-
controller_files = Dir.glob(File.join(controllers_dir, "**", "*_controller.rb"))
|
772
|
-
|
773
|
-
if controller_files.empty?
|
774
|
-
raise "No controllers found in the project."
|
775
|
-
end
|
776
|
-
|
777
|
-
# If a specific controller was requested, filter the files
|
778
|
-
if controller_name
|
779
|
-
# Normalize controller name (allow both 'users' and 'UsersController')
|
780
|
-
controller_name = "#{controller_name.sub(/_?controller$/i, "").downcase}_controller.rb"
|
781
|
-
controller_files = controller_files.select { |f| File.basename(f).downcase == controller_name }
|
782
|
-
|
783
|
-
if controller_files.empty?
|
784
|
-
raise "Controller '#{args[:controller_name]}' not found."
|
785
|
-
end
|
786
|
-
end
|
787
|
-
|
788
|
-
# Parse controllers to extract actions
|
789
|
-
controllers_data = {}
|
790
|
-
|
791
|
-
controller_files.each do |file_path|
|
792
|
-
file_content = File.read(file_path)
|
793
|
-
controller_class = File.basename(file_path, ".rb").gsub(/_controller$/i, "").camelize + "Controller"
|
794
|
-
|
795
|
-
# Extract controller actions (methods that are not private/protected)
|
796
|
-
actions = []
|
797
|
-
action_matches = file_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten
|
798
|
-
|
799
|
-
# Find where private/protected begins
|
800
|
-
private_index = file_content =~ /^\s*(private|protected)/
|
801
|
-
|
802
|
-
if private_index
|
803
|
-
# Get the actions defined before private/protected
|
804
|
-
private_content = file_content[private_index..-1]
|
805
|
-
private_methods = private_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten
|
806
|
-
actions = action_matches - private_methods
|
807
|
-
else
|
808
|
-
actions = action_matches
|
809
|
-
end
|
810
|
-
|
811
|
-
# Remove Rails controller lifecycle methods
|
812
|
-
lifecycle_methods = %w[initialize action_name controller_name params response]
|
813
|
-
actions -= lifecycle_methods
|
814
|
-
|
815
|
-
# Get routes mapped to this controller
|
816
|
-
routes_cmd = "cd #{$active_project_path} && bin/rails routes -c #{controller_class}"
|
817
|
-
routes_output = `#{routes_cmd}`.strip
|
818
|
-
|
819
|
-
routes = {}
|
820
|
-
if routes_output && !routes_output.empty?
|
821
|
-
routes_output.split("\n").each do |line|
|
822
|
-
next if line.include?("(erb):") || line.include?("Prefix") || line.strip.empty?
|
823
|
-
parts = line.strip.split(/\s+/)
|
824
|
-
if parts.size >= 4
|
825
|
-
# Get action name from the rails routes output
|
826
|
-
action = parts[1].to_s.strip
|
827
|
-
if actions.include?(action)
|
828
|
-
verb = parts[0].to_s.strip
|
829
|
-
path = parts[2].to_s.strip
|
830
|
-
routes[action] = {verb: verb, path: path}
|
831
|
-
end
|
832
|
-
end
|
833
|
-
end
|
834
|
-
end
|
835
|
-
|
836
|
-
# Find views for each action
|
837
|
-
views_dir = File.join($active_project_path, "app", "views", File.basename(file_path, "_controller.rb"))
|
838
|
-
views = {}
|
839
|
-
|
840
|
-
if File.directory?(views_dir)
|
841
|
-
actions.each do |action|
|
842
|
-
# Look for view templates with various extensions
|
843
|
-
view_files = Dir.glob(File.join(views_dir, "#{action}.*"))
|
844
|
-
if view_files.any?
|
845
|
-
views[action] = {
|
846
|
-
templates: view_files.map { |f| f.sub("#{$active_project_path}/", "") },
|
847
|
-
partials: []
|
848
|
-
}
|
849
|
-
|
850
|
-
# Look for partials used in this template
|
851
|
-
view_files.each do |view_file|
|
852
|
-
if File.file?(view_file)
|
853
|
-
view_content = File.read(view_file)
|
854
|
-
# Find render calls with partials
|
855
|
-
partial_matches = view_content.scan(/render\s+(?:partial:|:partial\s+=>\s+|:partial\s*=>|partial:)\s*["']([^"']+)["']/).flatten
|
856
|
-
views[action][:partials] += partial_matches if partial_matches.any?
|
857
|
-
|
858
|
-
# Find instance variables used in the view
|
859
|
-
instance_vars = view_content.scan(/@([a-zA-Z0-9_]+)/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
|
860
|
-
views[action][:instance_variables] = instance_vars if instance_vars.any?
|
861
|
-
|
862
|
-
# Look for Stimulus controllers
|
863
|
-
stimulus_controllers = view_content.scan(/data-controller="([^"]+)"/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
|
864
|
-
views[action][:stimulus_controllers] = stimulus_controllers if stimulus_controllers.any?
|
865
|
-
end
|
866
|
-
end
|
867
|
-
end
|
868
|
-
end
|
869
|
-
end
|
870
|
-
|
871
|
-
# Extract instance variables set in the controller action
|
872
|
-
instance_vars_in_controller = {}
|
873
|
-
actions.each do |action|
|
874
|
-
# Find the action method in the controller
|
875
|
-
action_match = file_content.match(/def\s+#{action}\b(.*?)(?:(?:def|private|protected|public)\b|\z)/m)
|
876
|
-
if action_match && action_match[1]
|
877
|
-
action_body = action_match[1]
|
878
|
-
# Find instance variable assignments
|
879
|
-
vars = action_body.scan(/@([a-zA-Z0-9_]+)\s*=/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
|
880
|
-
instance_vars_in_controller[action] = vars if vars.any?
|
881
|
-
end
|
882
|
-
end
|
883
|
-
|
884
|
-
controllers_data[controller_class] = {
|
885
|
-
file: file_path.sub("#{$active_project_path}/", ""),
|
886
|
-
actions: actions,
|
887
|
-
routes: routes,
|
888
|
-
views: views,
|
889
|
-
instance_variables: instance_vars_in_controller
|
890
|
-
}
|
891
|
-
rescue => e
|
892
|
-
log(:error, "Error parsing controller #{file_path}: #{e.message}")
|
893
|
-
end
|
894
|
-
|
895
|
-
# Format the output
|
896
|
-
output = []
|
897
|
-
|
898
|
-
controllers_data.each do |controller, data|
|
899
|
-
output << "Controller: #{controller}"
|
900
|
-
output << " File: #{data[:file]}"
|
901
|
-
output << " Actions: #{data[:actions].size}"
|
902
|
-
|
903
|
-
data[:actions].each do |action|
|
904
|
-
output << " Action: #{action}"
|
905
|
-
|
906
|
-
# Show route if available
|
907
|
-
if data[:routes] && data[:routes][action]
|
908
|
-
route = data[:routes][action]
|
909
|
-
output << " Route: [#{route[:verb]}] #{route[:path]}"
|
910
|
-
else
|
911
|
-
output << " Route: Not mapped to a route"
|
912
|
-
end
|
913
|
-
|
914
|
-
# Show view templates if available
|
915
|
-
if data[:views] && data[:views][action]
|
916
|
-
view_data = data[:views][action]
|
917
|
-
|
918
|
-
output << " View Templates:"
|
919
|
-
view_data[:templates].each do |template|
|
920
|
-
output << " - #{template}"
|
921
|
-
end
|
922
|
-
|
923
|
-
# Show partials
|
924
|
-
if view_data[:partials]&.any?
|
925
|
-
output << " Partials Used:"
|
926
|
-
view_data[:partials].uniq.each do |partial|
|
927
|
-
output << " - #{partial}"
|
928
|
-
end
|
929
|
-
end
|
930
|
-
|
931
|
-
# Show Stimulus controllers
|
932
|
-
if view_data[:stimulus_controllers]&.any?
|
933
|
-
output << " Stimulus Controllers:"
|
934
|
-
view_data[:stimulus_controllers].each do |controller|
|
935
|
-
output << " - #{controller}"
|
936
|
-
end
|
937
|
-
end
|
938
|
-
|
939
|
-
# Show instance variables used in views
|
940
|
-
if view_data[:instance_variables]&.any?
|
941
|
-
output << " Instance Variables Used in View:"
|
942
|
-
view_data[:instance_variables].sort.each do |var|
|
943
|
-
output << " - @#{var}"
|
944
|
-
end
|
945
|
-
end
|
946
|
-
else
|
947
|
-
output << " View: No view template found"
|
948
|
-
end
|
949
|
-
|
950
|
-
# Show instance variables set in controller
|
951
|
-
if data[:instance_variables] && data[:instance_variables][action]
|
952
|
-
output << " Instance Variables Set in Controller:"
|
953
|
-
data[:instance_variables][action].sort.each do |var|
|
954
|
-
output << " - @#{var}"
|
955
|
-
end
|
956
|
-
end
|
957
|
-
|
958
|
-
output << ""
|
959
|
-
end
|
960
|
-
|
961
|
-
output << "-------------------------"
|
962
|
-
end
|
963
|
-
|
964
|
-
output.join("\n")
|
965
|
-
end
|
966
|
-
end
|
967
|
-
|
968
|
-
tool "analyze_environment_config" do
|
969
|
-
description "Analyze environment configurations to identify inconsistencies, security issues, and missing variables across environments."
|
970
|
-
|
971
|
-
call do |args|
|
972
|
-
unless $active_project
|
973
|
-
raise "No active project. Please switch to a project first."
|
974
|
-
end
|
975
|
-
|
976
|
-
# Check for required directories and files
|
977
|
-
env_dir = File.join($active_project_path, "config", "environments")
|
978
|
-
unless File.directory?(env_dir)
|
979
|
-
raise "Environment configuration directory not found at config/environments."
|
980
|
-
end
|
981
|
-
|
982
|
-
# Initialize data structures
|
983
|
-
env_files = {}
|
984
|
-
env_settings = {}
|
985
|
-
|
986
|
-
# 1. Parse environment files
|
987
|
-
Dir.glob(File.join(env_dir, "*.rb")).each do |file|
|
988
|
-
env_name = File.basename(file, ".rb")
|
989
|
-
env_files[env_name] = file
|
990
|
-
env_content = File.read(file)
|
991
|
-
|
992
|
-
# Extract settings from environment files
|
993
|
-
env_settings[env_name] = extract_env_settings(env_content)
|
994
|
-
end
|
995
|
-
|
996
|
-
# 2. Find ENV variable usage across the codebase
|
997
|
-
env_vars_in_code = find_env_vars_in_codebase($active_project_path)
|
998
|
-
|
999
|
-
# 3. Check for .env files and their variables
|
1000
|
-
dotenv_files = {}
|
1001
|
-
dotenv_vars = {}
|
1002
|
-
|
1003
|
-
# Common .env file patterns
|
1004
|
-
dotenv_patterns = [
|
1005
|
-
".env",
|
1006
|
-
".env.development",
|
1007
|
-
".env.test",
|
1008
|
-
".env.production",
|
1009
|
-
".env.local",
|
1010
|
-
".env.development.local",
|
1011
|
-
".env.test.local",
|
1012
|
-
".env.production.local"
|
1013
|
-
]
|
1014
|
-
|
1015
|
-
dotenv_patterns.each do |pattern|
|
1016
|
-
file_path = File.join($active_project_path, pattern)
|
1017
|
-
if File.exist?(file_path)
|
1018
|
-
dotenv_files[pattern] = file_path
|
1019
|
-
dotenv_vars[pattern] = parse_dotenv_file(file_path)
|
1020
|
-
end
|
1021
|
-
end
|
1022
|
-
|
1023
|
-
# 4. Check credentials files
|
1024
|
-
credentials_files = {}
|
1025
|
-
credentials_key_file = File.join($active_project_path, "config", "master.key")
|
1026
|
-
credentials_file = File.join($active_project_path, "config", "credentials.yml.enc")
|
1027
|
-
|
1028
|
-
if File.exist?(credentials_file)
|
1029
|
-
credentials_files["credentials.yml.enc"] = credentials_file
|
1030
|
-
end
|
1031
|
-
|
1032
|
-
# Environment-specific credentials files
|
1033
|
-
Dir.glob(File.join($active_project_path, "config", "credentials", "*.yml.enc")).each do |file|
|
1034
|
-
env_name = File.basename(file, ".yml.enc")
|
1035
|
-
credentials_files["credentials/#{env_name}.yml.enc"] = file
|
1036
|
-
end
|
1037
|
-
|
1038
|
-
# 5. Check database configuration
|
1039
|
-
database_config_file = File.join($active_project_path, "config", "database.yml")
|
1040
|
-
database_config = {}
|
1041
|
-
|
1042
|
-
if File.exist?(database_config_file)
|
1043
|
-
database_config = parse_database_config(database_config_file)
|
1044
|
-
end
|
1045
|
-
|
1046
|
-
# 6. Generate findings
|
1047
|
-
|
1048
|
-
# 6.1. Compare environment settings
|
1049
|
-
env_diff = compare_environment_settings(env_settings)
|
1050
|
-
|
1051
|
-
# 6.2. Find missing ENV variables
|
1052
|
-
missing_env_vars = find_missing_env_vars(env_vars_in_code, dotenv_vars)
|
1053
|
-
|
1054
|
-
# 6.3. Check for potential security issues
|
1055
|
-
security_findings = check_security_configuration(env_settings, database_config)
|
1056
|
-
|
1057
|
-
# Format the output
|
1058
|
-
output = []
|
1059
|
-
|
1060
|
-
# Environment files summary
|
1061
|
-
output << "Environment Configuration Analysis"
|
1062
|
-
output << "=================================="
|
1063
|
-
output << ""
|
1064
|
-
output << "Environment Files:"
|
1065
|
-
env_files.each do |env, file|
|
1066
|
-
output << " - #{env}: #{file.sub("#{$active_project_path}/", "")}"
|
1067
|
-
end
|
1068
|
-
output << ""
|
1069
|
-
|
1070
|
-
# Environment variables summary
|
1071
|
-
output << "Environment Variables Usage:"
|
1072
|
-
output << " Total unique ENV variables found in codebase: #{env_vars_in_code.keys.size}"
|
1073
|
-
output << ""
|
1074
|
-
|
1075
|
-
# Missing ENV variables
|
1076
|
-
if missing_env_vars.any?
|
1077
|
-
output << "Missing ENV Variables:"
|
1078
|
-
missing_env_vars.each do |env_var, environments|
|
1079
|
-
output << " - #{env_var}: Used in codebase but missing in #{environments.join(", ")}"
|
1080
|
-
end
|
1081
|
-
else
|
1082
|
-
output << "All ENV variables appear to be defined in at least one .env file."
|
1083
|
-
end
|
1084
|
-
output << ""
|
1085
|
-
|
1086
|
-
# Environment differences
|
1087
|
-
if env_diff[:unique_settings].any?
|
1088
|
-
output << "Environment-Specific Settings:"
|
1089
|
-
env_diff[:unique_settings].each do |env, settings|
|
1090
|
-
output << " #{env}:"
|
1091
|
-
settings.each do |setting|
|
1092
|
-
output << " - #{setting}"
|
1093
|
-
end
|
1094
|
-
end
|
1095
|
-
output << ""
|
1096
|
-
end
|
1097
|
-
|
1098
|
-
if env_diff[:different_values].any?
|
1099
|
-
output << "Settings with Different Values Across Environments:"
|
1100
|
-
env_diff[:different_values].each do |setting, values|
|
1101
|
-
output << " #{setting}:"
|
1102
|
-
values.each do |env, value|
|
1103
|
-
output << " - #{env}: #{value}"
|
1104
|
-
end
|
1105
|
-
end
|
1106
|
-
output << ""
|
1107
|
-
end
|
1108
|
-
|
1109
|
-
# Credentials files
|
1110
|
-
output << "Credentials Management:"
|
1111
|
-
if credentials_files.any?
|
1112
|
-
output << " Encrypted credentials files found:"
|
1113
|
-
credentials_files.each do |name, file|
|
1114
|
-
output << " - #{name}"
|
1115
|
-
end
|
1116
|
-
|
1117
|
-
output << if File.exist?(credentials_key_file)
|
1118
|
-
" Master key file exists (config/master.key)"
|
1119
|
-
else
|
1120
|
-
" Warning: No master.key file found. Credentials are likely managed through RAILS_MASTER_KEY environment variable."
|
1121
|
-
end
|
1122
|
-
else
|
1123
|
-
output << " No encrypted credentials files found. The application may be using ENV variables exclusively."
|
1124
|
-
end
|
1125
|
-
output << ""
|
1126
|
-
|
1127
|
-
# Database configuration
|
1128
|
-
output << "Database Configuration:"
|
1129
|
-
if database_config.any?
|
1130
|
-
database_config.each do |env, config|
|
1131
|
-
output << " #{env}:"
|
1132
|
-
# Show connection details without exposing passwords
|
1133
|
-
if config["adapter"]
|
1134
|
-
output << " - Adapter: #{config["adapter"]}"
|
1135
|
-
end
|
1136
|
-
if config["host"] && config["host"] != "localhost" && config["host"] != "127.0.0.1"
|
1137
|
-
output << " - Host: #{config["host"]}"
|
1138
|
-
end
|
1139
|
-
if config["database"]
|
1140
|
-
output << " - Database: #{config["database"]}"
|
1141
|
-
end
|
1142
|
-
|
1143
|
-
# Check for credentials in database.yml
|
1144
|
-
if config["username"] && !config["username"].include?("ENV")
|
1145
|
-
output << " - Warning: Database username hardcoded in database.yml"
|
1146
|
-
end
|
1147
|
-
if config["password"] && !config["password"].include?("ENV")
|
1148
|
-
output << " - Warning: Database password hardcoded in database.yml"
|
1149
|
-
end
|
1150
|
-
end
|
1151
|
-
else
|
1152
|
-
output << " Could not parse database configuration."
|
1153
|
-
end
|
1154
|
-
output << ""
|
1155
|
-
|
1156
|
-
# Security findings
|
1157
|
-
if security_findings.any?
|
1158
|
-
output << "Security Configuration Findings:"
|
1159
|
-
security_findings.each do |finding|
|
1160
|
-
output << " - #{finding}"
|
1161
|
-
end
|
1162
|
-
output << ""
|
1163
|
-
end
|
1164
|
-
|
1165
|
-
output.join("\n")
|
1166
|
-
end
|
1167
|
-
end
|
1168
335
|
# rubocop:enable Style/GlobalVars
|