rails-mcp-server 1.2.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +168 -166
  3. data/docs/AGENT.md +345 -0
  4. data/exe/rails-mcp-config +1411 -0
  5. data/exe/rails-mcp-server +23 -10
  6. data/exe/rails-mcp-setup-claude +1 -1
  7. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
  8. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
  10. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
  11. data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
  12. data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
  13. data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
  14. data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
  15. data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
  16. data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
  17. data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
  18. data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
  19. data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
  20. data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
  21. data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
  22. data/lib/rails-mcp-server/version.rb +1 -1
  23. data/lib/rails_mcp_server.rb +19 -53
  24. metadata +65 -18
  25. data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
  26. data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
  27. data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
  28. data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
  29. data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
  30. data/lib/rails-mcp-server/tools/get_file.rb +0 -55
  31. data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
  32. data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
  33. data/lib/rails-mcp-server/tools/list_files.rb +0 -54
  34. data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
  35. data/lib/rails-mcp-server/tools/project_info.rb +0 -86
@@ -0,0 +1,40 @@
1
+ module RailsMcpServer
2
+ module Analyzers
3
+ class GetFile < BaseAnalyzer
4
+ def call(path:)
5
+ unless current_project
6
+ message = "No active project. Please switch to a project first."
7
+ log(:warn, message)
8
+ return message
9
+ end
10
+
11
+ full_path = File.join(active_project_path, path)
12
+
13
+ unless File.exist?(full_path)
14
+ message = "File '#{path}' not found in the project."
15
+ log(:warn, message)
16
+ return message
17
+ end
18
+
19
+ if File.directory?(full_path)
20
+ message = "'#{path}' is a directory, not a file. Use list_files instead."
21
+ log(:warn, message)
22
+ return message
23
+ end
24
+
25
+ log(:info, "Reading file: #{path}")
26
+
27
+ content = File.read(full_path)
28
+ extension = File.extname(path).delete(".")
29
+
30
+ <<~FILE
31
+ File: #{path}
32
+
33
+ ```#{extension}
34
+ #{content}
35
+ ```
36
+ FILE
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,212 @@
1
+ module RailsMcpServer
2
+ module Analyzers
3
+ class GetRoutes < BaseAnalyzer
4
+ def call(controller: nil, verb: nil, path_contains: nil, named_only: false, detail_level: "full")
5
+ unless current_project
6
+ message = "No active project. Please switch to a project first."
7
+ log(:warn, message)
8
+ return message
9
+ end
10
+
11
+ detail_level = "full" unless %w[names summary full].include?(detail_level)
12
+
13
+ routes_data = fetch_routes_via_introspection
14
+
15
+ if routes_data[:error]
16
+ log(:warn, "Error fetching routes: #{routes_data[:error]}")
17
+ return "Error fetching routes: #{routes_data[:error]}"
18
+ end
19
+
20
+ routes = routes_data[:routes] || []
21
+
22
+ routes = filter_routes(routes, controller: controller, verb: verb, path_contains: path_contains, named_only: named_only)
23
+
24
+ if routes.empty?
25
+ message = "No routes found"
26
+ message += " matching filters" if controller || verb || path_contains || named_only
27
+ return message
28
+ end
29
+
30
+ log(:debug, "Found #{routes.size} matching routes")
31
+
32
+ format_routes(routes, detail_level)
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_routes_via_introspection
38
+ script = <<~RUBY
39
+ require 'json'
40
+
41
+ def extract_constraints(route)
42
+ constraints = {}
43
+
44
+ if route.constraints.is_a?(Hash)
45
+ route.constraints.each do |key, value|
46
+ constraints[key.to_s] = value.is_a?(Regexp) ? value.source : value.to_s
47
+ end
48
+ end
49
+
50
+ if route.respond_to?(:requirements)
51
+ route.requirements.each do |key, value|
52
+ next if [:controller, :action].include?(key)
53
+ constraints[key.to_s] = value.is_a?(Regexp) ? value.source : value.to_s
54
+ end
55
+ end
56
+
57
+ constraints
58
+ end
59
+
60
+ begin
61
+ Rails.application.eager_load! unless Rails.application.config.eager_load
62
+
63
+ routes = Rails.application.routes.routes.map do |route|
64
+ next if route.internal
65
+
66
+ path = route.path.spec.to_s
67
+ next if path == '(.):format'
68
+
69
+ path = path.gsub('(.:format)', '').gsub('(:format)', '')
70
+
71
+ verb = route.verb.to_s
72
+ verb = verb.empty? ? 'ANY' : verb
73
+ verb = verb.gsub(/[\\^$\\/]/, '') if verb.include?('^')
74
+
75
+ controller = route.defaults[:controller].to_s
76
+ action = route.defaults[:action].to_s
77
+
78
+ next if controller.empty?
79
+
80
+ {
81
+ name: route.name.to_s,
82
+ verb: verb,
83
+ path: path,
84
+ controller: controller,
85
+ action: action,
86
+ controller_action: controller.empty? ? '' : "\#{controller}#\#{action}",
87
+ constraints: extract_constraints(route),
88
+ defaults: route.defaults.except(:controller, :action).transform_keys(&:to_s),
89
+ required_parts: route.required_parts.map(&:to_s),
90
+ optional_parts: route.parts.map(&:to_s) - route.required_parts.map(&:to_s)
91
+ }
92
+ end.compact
93
+
94
+ by_controller = routes.group_by { |r| r[:controller] }
95
+
96
+ puts({
97
+ routes: routes,
98
+ total_count: routes.size,
99
+ controllers: by_controller.transform_values(&:size)
100
+ }.to_json)
101
+
102
+ rescue => e
103
+ puts({ error: e.message, backtrace: e.backtrace.first(5) }.to_json)
104
+ end
105
+ RUBY
106
+
107
+ json_output = execute_rails_runner(script)
108
+
109
+ begin
110
+ JSON.parse(json_output, symbolize_names: true)
111
+ rescue JSON::ParserError => e
112
+ {error: "Failed to parse routes: #{e.message}", raw: json_output}
113
+ end
114
+ end
115
+
116
+ def filter_routes(routes, controller:, verb:, path_contains:, named_only:)
117
+ filtered = routes
118
+
119
+ if controller && !controller.empty?
120
+ controller_pattern = controller.downcase
121
+ filtered = filtered.select do |r|
122
+ r[:controller].to_s.downcase.include?(controller_pattern)
123
+ end
124
+ end
125
+
126
+ if verb && !verb.empty?
127
+ verb_upper = verb.upcase
128
+ filtered = filtered.select do |r|
129
+ r[:verb].to_s.upcase.include?(verb_upper)
130
+ end
131
+ end
132
+
133
+ if path_contains && !path_contains.empty?
134
+ filtered = filtered.select do |r|
135
+ r[:path].to_s.include?(path_contains)
136
+ end
137
+ end
138
+
139
+ if named_only
140
+ filtered = filtered.select do |r|
141
+ r[:name] && !r[:name].empty?
142
+ end
143
+ end
144
+
145
+ filtered
146
+ end
147
+
148
+ def format_routes(routes, detail_level)
149
+ case detail_level
150
+ when "names"
151
+ paths = routes.map { |r| r[:path] }.uniq.sort # rubocop:disable Performance/ChainArrayAllocation
152
+ "Route paths (#{paths.size} unique):\n\n#{paths.join("\n")}"
153
+
154
+ when "summary"
155
+ output = +"Rails Routes (#{routes.size} routes):\n"
156
+
157
+ by_controller = routes.group_by { |r| r[:controller] }
158
+
159
+ by_controller.sort.each do |controller, controller_routes|
160
+ output << "#{controller}:\n"
161
+ controller_routes.each do |r|
162
+ verb_padded = r[:verb].to_s.ljust(7)
163
+ name_str = r[:name].to_s.empty? ? "" : " (#{r[:name]})"
164
+ output << " #{verb_padded} #{r[:path].ljust(40)} #{r[:action]}#{name_str}\n"
165
+ end
166
+ output << "\n"
167
+ end
168
+
169
+ output
170
+
171
+ when "full"
172
+ output = +"Rails Routes (#{routes.size} routes):\n"
173
+ output << "=" * 70 << "\n"
174
+
175
+ routes.each do |r|
176
+ output << "\n"
177
+ output << "#{r[:verb]} #{r[:path]}\n"
178
+ output << " Name: #{r[:name]}\n" unless r[:name].to_s.empty?
179
+ output << " Controller: #{r[:controller_action]}\n"
180
+
181
+ if r[:constraints]&.any?
182
+ output << " Constraints: #{r[:constraints].map { |k, v| "#{k}: #{v}" }.join(", ")}\n"
183
+ end
184
+
185
+ if r[:defaults]&.any?
186
+ output << " Defaults: #{r[:defaults].map { |k, v| "#{k}: #{v}" }.join(", ")}\n"
187
+ end
188
+
189
+ if r[:required_parts]&.any?
190
+ output << " Required params: #{r[:required_parts].join(", ")}\n"
191
+ end
192
+
193
+ if r[:optional_parts]&.any?
194
+ output << " Optional params: #{r[:optional_parts].join(", ")}\n"
195
+ end
196
+ end
197
+
198
+ output << "\n"
199
+ output << "=" * 70 << "\n"
200
+ output << "Summary by controller:\n"
201
+
202
+ by_controller = routes.group_by { |r| r[:controller] }
203
+ by_controller.sort.each do |controller, controller_routes|
204
+ output << " #{controller}: #{controller_routes.size} routes\n"
205
+ end
206
+
207
+ output
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,216 @@
1
+ module RailsMcpServer
2
+ module Analyzers
3
+ class GetSchema < BaseAnalyzer
4
+ def call(table_name: nil, table_names: nil, detail_level: "full")
5
+ unless current_project
6
+ message = "No active project. Please switch to a project first."
7
+ log(:warn, message)
8
+ return message
9
+ end
10
+
11
+ detail_level = "full" unless %w[tables summary full].include?(detail_level)
12
+
13
+ if table_names && table_names.is_a?(Array) && table_names.any?
14
+ return batch_table_info(table_names)
15
+ end
16
+
17
+ if table_name
18
+ log(:info, "Getting schema for table: #{table_name}")
19
+ return single_table_info(table_name)
20
+ end
21
+
22
+ case detail_level
23
+ when "tables"
24
+ tables_list_only
25
+ when "summary"
26
+ tables_with_summary
27
+ when "full"
28
+ full_schema
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def tables_list_only
35
+ tables = get_table_names
36
+ return "Could not retrieve table list." if tables.empty?
37
+
38
+ "Database tables (#{tables.size}):\n\n#{tables.join("\n")}"
39
+ end
40
+
41
+ def tables_with_summary
42
+ tables = get_table_names
43
+ return "Could not retrieve table list." if tables.empty?
44
+
45
+ output = ["Database schema summary (#{tables.size} tables):\n"]
46
+
47
+ tables.each do |table|
48
+ column_count = get_column_count(table)
49
+ output << " #{table} (#{column_count} columns)"
50
+ end
51
+
52
+ output.join("\n")
53
+ end
54
+
55
+ def full_schema
56
+ log(:info, "Getting full schema")
57
+
58
+ schema_file = File.join(active_project_path, "db", "schema.rb")
59
+ unless File.exist?(schema_file)
60
+ log(:info, "Schema file not found, attempting to generate it")
61
+ RailsMcpServer::RunProcess.execute_rails_command(active_project_path, "db:schema:dump")
62
+ end
63
+
64
+ if File.exist?(schema_file)
65
+ schema_content = File.read(schema_file)
66
+ tables = get_table_names
67
+
68
+ <<~SCHEMA
69
+ Database Schema (#{tables.size} tables)
70
+
71
+ Tables:
72
+ #{tables.join("\n")}
73
+
74
+ Schema Definition:
75
+ ```ruby
76
+ #{schema_content}
77
+ ```
78
+ SCHEMA
79
+ else
80
+ tables = get_table_names
81
+ if tables.empty?
82
+ "Could not retrieve schema information. Try running 'rails db:schema:dump' in your project first."
83
+ else
84
+ <<~SCHEMA
85
+ Database Schema
86
+
87
+ Tables:
88
+ #{tables.join("\n")}
89
+
90
+ Note: Full schema definition is not available. Run 'rails db:schema:dump' to generate the schema.rb file.
91
+ SCHEMA
92
+ end
93
+ end
94
+ end
95
+
96
+ def single_table_info(table_name)
97
+ schema_output = execute_rails_runner(<<~RUBY)
98
+ require 'active_record'
99
+ puts ActiveRecord::Base.connection.columns('#{table_name}').map{|c| [c.name, c.type, c.null, c.default].inspect}.join('\\n')
100
+ RUBY
101
+
102
+ if schema_output.strip.empty?
103
+ message = "Table '#{table_name}' not found or has no columns."
104
+ log(:warn, message)
105
+ return message
106
+ end
107
+
108
+ columns = schema_output.strip.split("\\n").map do |column_info|
109
+ eval(column_info) # rubocop:disable Security/Eval
110
+ end
111
+
112
+ formatted_columns = columns.map do |name, type, nullable, default|
113
+ " #{name} (#{type})#{nullable ? ", nullable" : ""}#{default ? ", default: #{default}" : ""}"
114
+ end
115
+
116
+ output = <<~SCHEMA
117
+ Table: #{table_name} (#{columns.size} columns)
118
+
119
+ Columns:
120
+ #{formatted_columns.join("\n")}
121
+ SCHEMA
122
+
123
+ fk_output = get_foreign_keys(table_name)
124
+ output += fk_output if fk_output
125
+
126
+ idx_output = get_indexes(table_name)
127
+ output += idx_output if idx_output
128
+
129
+ output
130
+ end
131
+
132
+ def batch_table_info(table_names)
133
+ output = ["Schema for #{table_names.size} tables:\n"]
134
+
135
+ table_names.each do |table|
136
+ output << "=" * 50
137
+ output << single_table_info(table)
138
+ output << ""
139
+ end
140
+
141
+ output.join("\n")
142
+ end
143
+
144
+ def get_table_names
145
+ tables_output = execute_rails_runner(<<~RUBY)
146
+ require 'active_record'
147
+ puts ActiveRecord::Base.connection.tables.sort.join('\\n')
148
+ RUBY
149
+ tables_output.strip.split("\n").reject(&:empty?)
150
+ end
151
+
152
+ def get_column_count(table_name)
153
+ count_output = execute_rails_runner(<<~RUBY)
154
+ require 'active_record'
155
+ puts ActiveRecord::Base.connection.columns('#{table_name}').size
156
+ RUBY
157
+ count_output.strip.to_i
158
+ rescue
159
+ "?"
160
+ end
161
+
162
+ def get_foreign_keys(table_name)
163
+ fk_output = execute_rails_runner(<<~RUBY)
164
+ require 'active_record'
165
+ puts ActiveRecord::Base.connection.foreign_keys('#{table_name}').map{|fk| [fk.from_table, fk.to_table, fk.column, fk.primary_key].inspect}.join('\\n')
166
+ RUBY
167
+
168
+ return nil if fk_output.strip.empty?
169
+
170
+ foreign_keys = fk_output.strip.split("\n").map do |fk_info|
171
+ eval(fk_info) # rubocop:disable Security/Eval
172
+ end
173
+
174
+ formatted_fks = foreign_keys.map do |from_table, to_table, column, primary_key|
175
+ " #{column} -> #{to_table}.#{primary_key}"
176
+ end
177
+
178
+ <<~FK
179
+
180
+ Foreign Keys:
181
+ #{formatted_fks.join("\n")}
182
+ FK
183
+ rescue => e
184
+ log(:warn, "Error fetching foreign keys: #{e.message}")
185
+ nil
186
+ end
187
+
188
+ def get_indexes(table_name)
189
+ idx_output = execute_rails_runner(<<~RUBY)
190
+ require 'active_record'
191
+ puts ActiveRecord::Base.connection.indexes('#{table_name}').map{|i| [i.name, i.columns, i.unique].inspect}.join('\\n')
192
+ RUBY
193
+
194
+ return nil if idx_output.strip.empty?
195
+
196
+ indexes = idx_output.strip.split("\n").map do |idx_info|
197
+ eval(idx_info) # rubocop:disable Security/Eval
198
+ end
199
+
200
+ formatted_indexes = indexes.map do |name, columns, unique|
201
+ cols = columns.is_a?(Array) ? columns.join(", ") : columns
202
+ " #{name} (#{cols})#{unique ? " UNIQUE" : ""}"
203
+ end
204
+
205
+ <<~IDX
206
+
207
+ Indexes:
208
+ #{formatted_indexes.join("\n")}
209
+ IDX
210
+ rescue => e
211
+ log(:warn, "Error fetching indexes: #{e.message}")
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,43 @@
1
+ module RailsMcpServer
2
+ module Analyzers
3
+ class ListFiles < BaseAnalyzer
4
+ def call(directory: "", pattern: "*.rb")
5
+ unless current_project
6
+ message = "No active project. Please switch to a project first."
7
+ log(:warn, message)
8
+ return message
9
+ end
10
+
11
+ full_path = File.join(active_project_path, directory)
12
+ unless File.directory?(full_path)
13
+ message = "Directory '#{directory}' not found in the project."
14
+ log(:warn, message)
15
+ return message
16
+ end
17
+
18
+ # Check if this is a git repository
19
+ is_git_repo = system("cd #{active_project_path} && git rev-parse --is-inside-work-tree > /dev/null 2>&1")
20
+
21
+ if is_git_repo
22
+ log(:debug, "Project is a git repository, using git ls-files")
23
+
24
+ relative_dir = directory.empty? ? "" : "#{directory}/"
25
+ git_cmd = "cd #{active_project_path} && git ls-files --cached --others --exclude-standard #{relative_dir}#{pattern}"
26
+
27
+ files = `#{git_cmd}`.split("\n").map(&:strip).sort
28
+ else
29
+ log(:debug, "Project is not a git repository or git not available, using Dir.glob")
30
+
31
+ files = Dir.glob(File.join(full_path, pattern))
32
+ .map { |f| f.sub("#{active_project_path}/", "") }
33
+ .reject { |file| file.start_with?(".git/", ".ruby-lsp/", "node_modules/", "storage/", "public/assets/", "public/packs/", ".bundle/", "vendor/bundle/", "vendor/cache/", "tmp/", "log/") }
34
+ .sort
35
+ end
36
+
37
+ log(:debug, "Found #{files.size} files matching pattern")
38
+
39
+ "Files in #{directory.empty? ? "project root" : directory} matching '#{pattern}':\n\n#{files.join("\n")}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,84 @@
1
+ module RailsMcpServer
2
+ module Analyzers
3
+ class LoadGuide < BaseAnalyzer
4
+ GUIDE_LIBRARIES = %w[rails turbo stimulus kamal custom].freeze
5
+
6
+ def call(guides:, guide: nil)
7
+ unless GUIDE_LIBRARIES.include?(guides.to_s.downcase)
8
+ return "Unknown guide library '#{guides}'. Available: #{GUIDE_LIBRARIES.join(", ")}"
9
+ end
10
+
11
+ guides_path = get_guides_path(guides.to_s.downcase)
12
+
13
+ unless guides_path && File.directory?(guides_path)
14
+ return "Guides for '#{guides}' not found. Run: rails-mcp-server-download-resources"
15
+ end
16
+
17
+ if guide
18
+ load_specific_guide(guides_path, guide)
19
+ else
20
+ list_available_guides(guides, guides_path)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def get_guides_path(library)
27
+ base_path = File.join(RailsMcpServer.config_dir, "resources", library)
28
+ File.directory?(base_path) ? base_path : nil
29
+ end
30
+
31
+ def list_available_guides(library, guides_path)
32
+ guide_files = Dir.glob(File.join(guides_path, "**", "*.md"))
33
+ .map { |f| f.sub("#{guides_path}/", "").sub(/\.md$/, "") }
34
+ .sort # rubocop:disable Performance/ChainArrayAllocation
35
+
36
+ if guide_files.empty?
37
+ return "No guides found for '#{library}'."
38
+ end
39
+
40
+ output = ["Available #{library.capitalize} Guides (#{guide_files.size}):", ""]
41
+ guide_files.each { |g| output << " #{g}" }
42
+ output << ""
43
+ output << "Load a guide with: load_guide(guides: '#{library}', guide: '<guide_name>')"
44
+ output.join("\n")
45
+ end
46
+
47
+ def load_specific_guide(guides_path, guide_name)
48
+ # Try exact match first
49
+ guide_file = File.join(guides_path, "#{guide_name}.md")
50
+
51
+ # Try finding in subdirectories
52
+ unless File.exist?(guide_file)
53
+ matches = Dir.glob(File.join(guides_path, "**", "#{guide_name}.md"))
54
+ guide_file = matches.first if matches.any?
55
+ end
56
+
57
+ # Try partial match
58
+ unless guide_file && File.exist?(guide_file)
59
+ all_guides = Dir.glob(File.join(guides_path, "**", "*.md"))
60
+ matches = all_guides.select { |f| File.basename(f, ".md").include?(guide_name) }
61
+ guide_file = matches.first if matches.size == 1
62
+
63
+ if matches.size > 1
64
+ names = matches.map { |f| File.basename(f, ".md") }
65
+ return "Multiple guides match '#{guide_name}': #{names.join(", ")}"
66
+ end
67
+ end
68
+
69
+ unless guide_file && File.exist?(guide_file)
70
+ return "Guide '#{guide_name}' not found."
71
+ end
72
+
73
+ content = File.read(guide_file)
74
+ guide_title = File.basename(guide_file, ".md")
75
+
76
+ <<~GUIDE
77
+ # #{guide_title}
78
+
79
+ #{content}
80
+ GUIDE
81
+ end
82
+ end
83
+ end
84
+ end