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.
@@ -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
- class Error < StandardError; end
12
- end
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
- # Default paths
31
- projects_file = File.join(config_dir, "projects.yml")
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
- # Parse command-line arguments
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
- # Initialize logger
48
- $logger = Logger.new(log_file)
49
- $logger.level = Logger.const_get(log_level.to_s.upcase)
50
-
51
- # Set a nicer formatter
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
- levels = {debug: Logger::DEBUG, info: Logger::INFO, warn: Logger::WARN, error: Logger::ERROR, fatal: Logger::FATAL}
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
- # Load projects
73
- projects_file = File.expand_path(projects_file)
74
- projects = {}
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
- # Initialize state
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