rails-mcp-server 1.4.1 → 1.5.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +82 -4
  4. data/docs/{AGENT.md → AGENT.md} +84 -5
  5. data/docs/COPILOT_AGENT.md +261 -0
  6. data/docs/RESOURCES.md +2 -2
  7. data/exe/rails-mcp-config +19 -1
  8. data/exe/rails-mcp-server +5 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +12 -5
  10. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +1 -1
  11. data/lib/rails-mcp-server/analyzers/analyze_models.rb +17 -8
  12. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +16 -6
  13. data/lib/rails-mcp-server/analyzers/get_file.rb +16 -4
  14. data/lib/rails-mcp-server/analyzers/get_routes.rb +3 -2
  15. data/lib/rails-mcp-server/analyzers/get_schema.rb +101 -43
  16. data/lib/rails-mcp-server/analyzers/list_files.rb +56 -14
  17. data/lib/rails-mcp-server/analyzers/load_guide.rb +25 -7
  18. data/lib/rails-mcp-server/config.rb +87 -2
  19. data/lib/rails-mcp-server/helpers/resource_base.rb +48 -9
  20. data/lib/rails-mcp-server/helpers/resource_downloader.rb +2 -2
  21. data/lib/rails-mcp-server/resources/guide_content_formatter.rb +1 -1
  22. data/lib/rails-mcp-server/resources/guide_error_handler.rb +2 -2
  23. data/lib/rails-mcp-server/resources/guide_loader_template.rb +1 -1
  24. data/lib/rails-mcp-server/resources/guide_manifest_operations.rb +1 -1
  25. data/lib/rails-mcp-server/tools/execute_tool.rb +9 -3
  26. data/lib/rails-mcp-server/tools/get_model.rb +18 -13
  27. data/lib/rails-mcp-server/tools/search_tools.rb +4 -4
  28. data/lib/rails-mcp-server/tools/switch_project.rb +10 -1
  29. data/lib/rails-mcp-server/utilities/path_validator.rb +100 -0
  30. data/lib/rails-mcp-server/utilities/run_process.rb +25 -15
  31. data/lib/rails-mcp-server/version.rb +1 -1
  32. data/lib/rails_mcp_server.rb +1 -0
  33. metadata +33 -3
@@ -55,7 +55,7 @@ module RailsMcpServer
55
55
  def get_actions_via_introspection(controller_class)
56
56
  script = "require 'json'; puts (#{controller_class}.action_methods.to_a.sort rescue []).to_json"
57
57
  begin
58
- JSON.parse(execute_rails_runner(script))
58
+ JSON.parse(extract_json(execute_rails_runner(script)))
59
59
  rescue
60
60
  []
61
61
  end
@@ -90,7 +90,7 @@ module RailsMcpServer
90
90
  end
91
91
  RUBY
92
92
  begin
93
- JSON.parse(execute_rails_runner(script), symbolize_names: true)
93
+ JSON.parse(extract_json(execute_rails_runner(script)), symbolize_names: true)
94
94
  rescue
95
95
  {actions: [], routes: []}
96
96
  end
@@ -119,7 +119,14 @@ module RailsMcpServer
119
119
  result = {
120
120
  actions: c.action_methods.to_a.sort,
121
121
  parent: c.superclass.name,
122
- callbacks: c._process_action_callbacks.map { |cb| { kind: cb.kind.to_s, filter: cb.filter.to_s, only: Array(cb.options[:only]).map(&:to_s), except: Array(cb.options[:except]).map(&:to_s) } },
122
+ callbacks: c._process_action_callbacks.map { |cb|
123
+ h = { kind: cb.kind.to_s, filter: cb.filter.to_s }
124
+ if cb.respond_to?(:options)
125
+ h[:only] = Array(cb.options[:only]).map(&:to_s)
126
+ h[:except] = Array(cb.options[:except]).map(&:to_s)
127
+ end
128
+ h
129
+ },
123
130
  routes: Rails.application.routes.routes.select { |r| r.defaults[:controller] == '#{controller_class.sub("Controller", "").underscore}' }
124
131
  .map { |r| { name: r.name.to_s, verb: r.verb.to_s.gsub(/[\\^$\\/]/, ''), path: r.path.spec.to_s.gsub('(.:format)', ''), action: r.defaults[:action].to_s } }
125
132
  }
@@ -129,7 +136,7 @@ module RailsMcpServer
129
136
  end
130
137
  RUBY
131
138
  data = begin
132
- JSON.parse(execute_rails_runner(script), symbolize_names: true)
139
+ JSON.parse(extract_json(execute_rails_runner(script)), symbolize_names: true)
133
140
  rescue
134
141
  nil
135
142
  end
@@ -198,7 +205,7 @@ module RailsMcpServer
198
205
  end
199
206
  RUBY
200
207
  data = begin
201
- JSON.parse(execute_rails_runner(script), symbolize_names: true)
208
+ JSON.parse(extract_json(execute_rails_runner(script)), symbolize_names: true)
202
209
  rescue
203
210
  nil
204
211
  end
@@ -40,7 +40,7 @@ module RailsMcpServer
40
40
  end
41
41
 
42
42
  # Find inconsistencies
43
- all_keys = configs.values.flat_map(&:keys).uniq.sort
43
+ all_keys = configs.values.flat_map(&:keys).uniq.sort # rubocop:disable Performance/ChainArrayAllocation
44
44
  inconsistencies = []
45
45
 
46
46
  all_keys.each do |key|
@@ -28,8 +28,8 @@ module RailsMcpServer
28
28
 
29
29
  model_files = Dir.glob(File.join(models_dir, "**", "*.rb"))
30
30
  .map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") }
31
- .reject { |f| f.include?("concern") || f.include?("application_record") }
32
- .sort
31
+ .reject { |f| f.include?("concern") || f.include?("application_record") } # rubocop:disable Performance/ChainArrayAllocation
32
+ .sort # rubocop:disable Performance/ChainArrayAllocation
33
33
 
34
34
  case detail_level
35
35
  when "names"
@@ -54,7 +54,16 @@ module RailsMcpServer
54
54
 
55
55
  def single_model_info(model_name, detail_level, analysis_type)
56
56
  model_file = find_model_file(model_name)
57
- return "Model '#{model_name}' not found." unless model_file && File.exist?(model_file)
57
+ unless model_file && File.exist?(model_file)
58
+ return <<~ERROR
59
+ Model '#{model_name}' not found.
60
+
61
+ Tips:
62
+ - Use CamelCase: 'User', 'BlogPost', 'OrderItem'
63
+ - Use singular form: 'User' not 'Users'
64
+ - Run analyze_models without params to list all models
65
+ ERROR
66
+ end
58
67
 
59
68
  case detail_level
60
69
  when "names"
@@ -94,9 +103,9 @@ module RailsMcpServer
94
103
 
95
104
  def introspection_analysis(model_name)
96
105
  script = build_introspection_script(model_name)
97
- json_output = execute_rails_runner(script)
106
+ raw_output = execute_rails_runner(script)
98
107
  data = begin
99
- JSON.parse(json_output)
108
+ JSON.parse(extract_json(raw_output))
100
109
  rescue
101
110
  nil
102
111
  end
@@ -161,9 +170,9 @@ module RailsMcpServer
161
170
 
162
171
  def static_analysis(model_file)
163
172
  script = build_static_analysis_script(model_file)
164
- json_output = execute_rails_runner(script)
173
+ raw_output = execute_rails_runner(script)
165
174
  data = begin
166
- JSON.parse(json_output)
175
+ JSON.parse(extract_json(raw_output))
167
176
  rescue
168
177
  nil
169
178
  end
@@ -232,7 +241,7 @@ module RailsMcpServer
232
241
  def get_associations_via_introspection(model_name)
233
242
  script = "require 'json'; puts (#{model_name}.reflect_on_all_associations.map { |a| { name: a.name.to_s, type: a.macro.to_s } } rescue []).to_json"
234
243
  begin
235
- JSON.parse(execute_rails_runner(script)).map { |a| a.transform_keys(&:to_sym) }
244
+ JSON.parse(extract_json(execute_rails_runner(script))).map { |a| a.transform_keys(&:to_sym) }
236
245
  rescue
237
246
  []
238
247
  end
@@ -1,3 +1,5 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
1
3
  module RailsMcpServer
2
4
  module Analyzers
3
5
  class BaseAnalyzer
@@ -27,15 +29,23 @@ module RailsMcpServer
27
29
  end
28
30
 
29
31
  def camelize(string)
30
- string.split("_").map(&:capitalize).join
32
+ string.to_s.camelize
31
33
  end
32
34
 
33
35
  def underscore(string)
34
- string.gsub("::", "/")
35
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
36
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
37
- .tr("-", "_")
38
- .downcase
36
+ string.to_s.underscore
37
+ end
38
+
39
+ def extract_json(output)
40
+ return output if output.nil? || output.empty?
41
+
42
+ start_idx = output.index("{")
43
+ return output unless start_idx
44
+
45
+ end_idx = output.rindex("}")
46
+ return output unless end_idx && end_idx > start_idx
47
+
48
+ output[start_idx..end_idx]
39
49
  end
40
50
  end
41
51
  end
@@ -8,15 +8,27 @@ module RailsMcpServer
8
8
  return message
9
9
  end
10
10
 
11
- full_path = File.join(active_project_path, path)
11
+ validated_path = PathValidator.validate_path(path, active_project_path)
12
12
 
13
- unless File.exist?(full_path)
13
+ if validated_path.nil?
14
+ message, log_msg = if !PathValidator.safe_path?(path, active_project_path)
15
+ ["Access denied: path '#{path}' is outside the project directory.",
16
+ "Path traversal attempt blocked: #{path}"]
17
+ else
18
+ ["Access denied: '#{path}' matches a sensitive file pattern.",
19
+ "Sensitive file access blocked: #{path}"]
20
+ end
21
+ log(:warn, log_msg)
22
+ return message
23
+ end
24
+
25
+ unless File.exist?(validated_path)
14
26
  message = "File '#{path}' not found in the project."
15
27
  log(:warn, message)
16
28
  return message
17
29
  end
18
30
 
19
- if File.directory?(full_path)
31
+ if File.directory?(validated_path)
20
32
  message = "'#{path}' is a directory, not a file. Use list_files instead."
21
33
  log(:warn, message)
22
34
  return message
@@ -24,7 +36,7 @@ module RailsMcpServer
24
36
 
25
37
  log(:info, "Reading file: #{path}")
26
38
 
27
- content = File.read(full_path)
39
+ content = File.read(validated_path)
28
40
  extension = File.extname(path).delete(".")
29
41
 
30
42
  <<~FILE
@@ -104,12 +104,13 @@ module RailsMcpServer
104
104
  end
105
105
  RUBY
106
106
 
107
- json_output = execute_rails_runner(script)
107
+ raw_output = execute_rails_runner(script)
108
+ json_output = extract_json(raw_output)
108
109
 
109
110
  begin
110
111
  JSON.parse(json_output, symbolize_names: true)
111
112
  rescue JSON::ParserError => e
112
- {error: "Failed to parse routes: #{e.message}", raw: json_output}
113
+ {error: "Failed to parse routes: #{e.message}", raw: raw_output}
113
114
  end
114
115
  end
115
116
 
@@ -1,6 +1,9 @@
1
1
  module RailsMcpServer
2
2
  module Analyzers
3
3
  class GetSchema < BaseAnalyzer
4
+ # Tab character for parsing output
5
+ FIELD_SEPARATOR = "\t"
6
+
4
7
  def call(table_name: nil, table_names: nil, detail_level: "full")
5
8
  unless current_project
6
9
  message = "No active project. Please switch to a project first."
@@ -11,10 +14,17 @@ module RailsMcpServer
11
14
  detail_level = "full" unless %w[tables summary full].include?(detail_level)
12
15
 
13
16
  if table_names&.is_a?(Array) && table_names.any?
17
+ invalid_tables = table_names.reject { |t| PathValidator.valid_table_name?(t) }
18
+ if invalid_tables.any?
19
+ return "Invalid table names: #{invalid_tables.join(", ")}. Table names must be alphanumeric with underscores."
20
+ end
14
21
  return batch_table_info(table_names)
15
22
  end
16
23
 
17
24
  if table_name
25
+ unless PathValidator.valid_table_name?(table_name)
26
+ return "Invalid table name: '#{table_name}'. Table names must be alphanumeric with underscores."
27
+ end
18
28
  log(:info, "Getting schema for table: #{table_name}")
19
29
  return single_table_info(table_name)
20
30
  end
@@ -56,7 +66,9 @@ module RailsMcpServer
56
66
  log(:info, "Getting full schema")
57
67
 
58
68
  schema_file = File.join(active_project_path, "db", "schema.rb")
59
- unless File.exist?(schema_file)
69
+ structure_file = File.join(active_project_path, "db", "structure.sql")
70
+
71
+ unless File.exist?(schema_file) || File.exist?(structure_file)
60
72
  log(:info, "Schema file not found, attempting to generate it")
61
73
  RailsMcpServer::RunProcess.execute_rails_command(active_project_path, "db:schema:dump")
62
74
  end
@@ -76,6 +88,17 @@ module RailsMcpServer
76
88
  #{schema_content}
77
89
  ```
78
90
  SCHEMA
91
+ elsif File.exist?(structure_file)
92
+ tables = get_table_names
93
+
94
+ <<~SCHEMA
95
+ Database Schema (#{tables.size} tables)
96
+
97
+ Tables:
98
+ #{tables.join("\n")}
99
+
100
+ Note: Project uses structure.sql format. Use get_schema with a specific table_name for details.
101
+ SCHEMA
79
102
  else
80
103
  tables = get_table_names
81
104
  if tables.empty?
@@ -94,23 +117,24 @@ module RailsMcpServer
94
117
  end
95
118
 
96
119
  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
120
+ columns = get_columns(table_name)
101
121
 
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
122
+ if columns.empty?
123
+ log(:warn, "Table '#{table_name}' not found or has no columns.")
124
+ return <<~ERROR
125
+ Table '#{table_name}' not found or has no columns.
107
126
 
108
- columns = schema_output.strip.split("\\n").map do |column_info|
109
- eval(column_info) # rubocop:disable Security/Eval
127
+ Tips:
128
+ - Use snake_case plural: 'users', 'blog_posts', 'order_items'
129
+ - Run get_schema with detail_level: "tables" to list all tables
130
+ ERROR
110
131
  end
111
132
 
112
- formatted_columns = columns.map do |name, type, nullable, default|
113
- " #{name} (#{type})#{", nullable" if nullable}#{", default: #{default}" if default}"
133
+ formatted_columns = columns.map do |col|
134
+ line = " #{col[:name]} (#{col[:type]})"
135
+ line += ", nullable" if col[:null]
136
+ line += ", default: #{col[:default]}" if col[:default]
137
+ line
114
138
  end
115
139
 
116
140
  output = <<~SCHEMA
@@ -142,38 +166,63 @@ module RailsMcpServer
142
166
  end
143
167
 
144
168
  def get_table_names
145
- tables_output = execute_rails_runner(<<~RUBY)
146
- require 'active_record'
147
- puts ActiveRecord::Base.connection.tables.sort.join('\\n')
169
+ output = execute_rails_runner(<<~RUBY)
170
+ puts ActiveRecord::Base.connection.tables.sort.join("\\n")
148
171
  RUBY
149
- tables_output.strip.split("\n").reject(&:empty?)
172
+
173
+ parse_lines(output)
150
174
  end
151
175
 
152
176
  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
177
+ return "?" unless PathValidator.valid_table_name?(table_name)
178
+
179
+ output = execute_rails_runner(<<~RUBY)
180
+ puts ActiveRecord::Base.connection.columns(#{table_name.inspect}).size
156
181
  RUBY
157
- count_output.strip.to_i
182
+
183
+ output.strip.to_i
158
184
  rescue
159
185
  "?"
160
186
  end
161
187
 
188
+ def get_columns(table_name)
189
+ output = execute_rails_runner(<<~RUBY)
190
+ ActiveRecord::Base.connection.columns(#{table_name.inspect}).each do |c|
191
+ puts [c.name, c.type, c.null, c.default].join("\\t")
192
+ end
193
+ RUBY
194
+
195
+ parse_lines(output).map do |line|
196
+ parts = line.split(FIELD_SEPARATOR)
197
+ next if parts.size < 3
198
+
199
+ {
200
+ name: parts[0],
201
+ type: parts[1],
202
+ null: parts[2] == "true",
203
+ default: (parts[3] == "") ? nil : parts[3]
204
+ }
205
+ end.compact
206
+ end
207
+
162
208
  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')
209
+ output = execute_rails_runner(<<~RUBY)
210
+ ActiveRecord::Base.connection.foreign_keys(#{table_name.inspect}).each do |fk|
211
+ puts [fk.from_table, fk.to_table, fk.column, fk.primary_key].join("\\t")
212
+ end
166
213
  RUBY
167
214
 
168
- return nil if fk_output.strip.empty?
215
+ lines = parse_lines(output)
216
+ return nil if lines.empty?
169
217
 
170
- foreign_keys = fk_output.strip.split("\n").map do |fk_info|
171
- eval(fk_info) # rubocop:disable Security/Eval
172
- end
218
+ formatted_fks = lines.map do |line|
219
+ parts = line.split(FIELD_SEPARATOR)
220
+ next if parts.size < 4
173
221
 
174
- formatted_fks = foreign_keys.map do |from_table, to_table, column, primary_key|
175
- " #{column} -> #{to_table}.#{primary_key}"
176
- end
222
+ " #{parts[2]} -> #{parts[1]}.#{parts[3]}"
223
+ end.compact
224
+
225
+ return nil if formatted_fks.empty?
177
226
 
178
227
  <<~FK
179
228
 
@@ -186,21 +235,26 @@ module RailsMcpServer
186
235
  end
187
236
 
188
237
  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')
238
+ output = execute_rails_runner(<<~RUBY)
239
+ ActiveRecord::Base.connection.indexes(#{table_name.inspect}).each do |i|
240
+ cols = i.columns.is_a?(Array) ? i.columns.join(",") : i.columns
241
+ puts [i.name, cols, i.unique].join("\\t")
242
+ end
192
243
  RUBY
193
244
 
194
- return nil if idx_output.strip.empty?
245
+ lines = parse_lines(output)
246
+ return nil if lines.empty?
195
247
 
196
- indexes = idx_output.strip.split("\n").map do |idx_info|
197
- eval(idx_info) # rubocop:disable Security/Eval
198
- end
248
+ formatted_indexes = lines.map do |line|
249
+ parts = line.split(FIELD_SEPARATOR)
250
+ next if parts.size < 3
199
251
 
200
- formatted_indexes = indexes.map do |name, columns, unique|
201
- cols = columns.is_a?(Array) ? columns.join(", ") : columns
202
- " #{name} (#{cols})#{" UNIQUE" if unique}"
203
- end
252
+ cols = parts[1].tr(",", ", ")
253
+ unique_marker = (parts[2] == "true") ? " UNIQUE" : ""
254
+ " #{parts[0]} (#{cols})#{unique_marker}"
255
+ end.compact
256
+
257
+ return nil if formatted_indexes.empty?
204
258
 
205
259
  <<~IDX
206
260
 
@@ -211,6 +265,10 @@ module RailsMcpServer
211
265
  log(:warn, "Error fetching indexes: #{e.message}")
212
266
  nil
213
267
  end
268
+
269
+ def parse_lines(output)
270
+ output.to_s.lines.map(&:chomp).reject(&:empty?)
271
+ end
214
272
  end
215
273
  end
216
274
  end
@@ -8,35 +8,77 @@ module RailsMcpServer
8
8
  return message
9
9
  end
10
10
 
11
- full_path = File.join(active_project_path, directory)
11
+ if directory.empty?
12
+ full_path = active_project_path
13
+ else
14
+ validated_dir = PathValidator.validate_path(directory, active_project_path)
15
+ if validated_dir.nil?
16
+ message = "Access denied: directory '#{directory}' is outside the project or is sensitive."
17
+ log(:warn, "Directory access blocked: #{directory}")
18
+ return message
19
+ end
20
+ full_path = validated_dir
21
+ end
22
+
12
23
  unless File.directory?(full_path)
13
24
  message = "Directory '#{directory}' not found in the project."
14
25
  log(:warn, message)
15
26
  return message
16
27
  end
17
28
 
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")
29
+ sanitized_pattern = pattern.gsub(/[^a-zA-Z0-9*?.\/_-]/, "")
30
+ if sanitized_pattern != pattern
31
+ log(:warn, "Pattern sanitized from '#{pattern}' to '#{sanitized_pattern}'")
32
+ end
33
+
34
+ files = collect_files(full_path, sanitized_pattern, directory)
35
+ files = PathValidator.filter_sensitive_files(files, active_project_path)
36
+
37
+ log(:debug, "Found #{files.size} files matching pattern (after filtering)")
38
+
39
+ "Files in #{directory.empty? ? "project root" : directory} matching '#{sanitized_pattern}':\n\n#{files.join("\n")}"
40
+ end
41
+
42
+ private
43
+
44
+ def collect_files(full_path, pattern, directory)
45
+ is_git_repo = git_repository?
20
46
 
21
47
  if is_git_repo
22
48
  log(:debug, "Project is a git repository, using git ls-files")
49
+ collect_files_git(directory, pattern)
50
+ else
51
+ log(:debug, "Project is not a git repository, using Dir.glob")
52
+ collect_files_glob(full_path, pattern)
53
+ end
54
+ end
23
55
 
24
- relative_dir = directory.empty? ? "" : "#{directory}/"
25
- git_cmd = "cd #{active_project_path} && git ls-files --cached --others --exclude-standard #{relative_dir}#{pattern}"
56
+ def git_repository?
57
+ Dir.chdir(active_project_path) do
58
+ system("git", "rev-parse", "--is-inside-work-tree",
59
+ out: File::NULL, err: File::NULL)
60
+ end
61
+ end
26
62
 
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")
63
+ def collect_files_git(directory, pattern)
64
+ relative_dir = directory.empty? ? "" : "#{directory}/"
65
+ search_pattern = "#{relative_dir}#{pattern}"
30
66
 
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
67
+ files = Dir.chdir(active_project_path) do
68
+ IO.popen(
69
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", search_pattern],
70
+ &:read
71
+ )
35
72
  end
36
73
 
37
- log(:debug, "Found #{files.size} files matching pattern")
74
+ files.to_s.split("\n").map(&:strip).reject(&:empty?).sort
75
+ end
38
76
 
39
- "Files in #{directory.empty? ? "project root" : directory} matching '#{pattern}':\n\n#{files.join("\n")}"
77
+ def collect_files_glob(full_path, pattern)
78
+ Dir.glob(File.join(full_path, pattern))
79
+ .map { |f| f.sub("#{active_project_path}/", "") }
80
+ .reject { |file| PathValidator.excluded_directory?(file) }
81
+ .sort
40
82
  end
41
83
  end
42
84
  end
@@ -3,21 +3,29 @@ module RailsMcpServer
3
3
  class LoadGuide < BaseAnalyzer
4
4
  GUIDE_LIBRARIES = %w[rails turbo stimulus kamal custom].freeze
5
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(", ")}"
6
+ # Parameters:
7
+ # library: The guide source - 'rails', 'turbo', 'stimulus', 'kamal', or 'custom'
8
+ # guide: (optional) Specific guide name to load. If omitted, lists all available guides.
9
+ #
10
+ # Examples:
11
+ # execute_tool("load_guide", { library: "rails" }) # List all Rails guides
12
+ # execute_tool("load_guide", { library: "rails", guide: "active_record" }) # Load ActiveRecord guide
13
+ # execute_tool("load_guide", { library: "custom", guide: "tailwind" }) # Load custom Tailwind guide
14
+ def call(library:, guide: nil)
15
+ unless GUIDE_LIBRARIES.include?(library.to_s.downcase)
16
+ return "Unknown guide library '#{library}'. Available: #{GUIDE_LIBRARIES.join(", ")}"
9
17
  end
10
18
 
11
- guides_path = get_guides_path(guides.to_s.downcase)
19
+ guides_path = get_guides_path(library.to_s.downcase)
12
20
 
13
21
  unless guides_path && File.directory?(guides_path)
14
- return "Guides for '#{guides}' not found. Run: rails-mcp-server-download-resources"
22
+ return "Guides for '#{library}' not found. Run: rails-mcp-server-download-resources #{library}"
15
23
  end
16
24
 
17
25
  if guide
18
26
  load_specific_guide(guides_path, guide)
19
27
  else
20
- list_available_guides(guides, guides_path)
28
+ list_available_guides(library, guides_path)
21
29
  end
22
30
  end
23
31
 
@@ -40,11 +48,21 @@ module RailsMcpServer
40
48
  output = ["Available #{library.capitalize} Guides (#{guide_files.size}):", ""]
41
49
  guide_files.each { |g| output << " #{g}" }
42
50
  output << ""
43
- output << "Load a guide with: load_guide(guides: '#{library}', guide: '<guide_name>')"
51
+ output << "Load a guide with: execute_tool(\"load_guide\", { library: \"#{library}\", guide: \"<guide_name>\" })"
44
52
  output.join("\n")
45
53
  end
46
54
 
47
55
  def load_specific_guide(guides_path, guide_name)
56
+ # Validate guide name format - allow letters, numbers, underscores, hyphens, and forward slashes
57
+ unless guide_name.to_s.match?(/\A[a-zA-Z0-9_\-\/]+\z/)
58
+ return "Invalid guide name '#{guide_name}'. Use letters, numbers, underscores, hyphens, or forward slashes only."
59
+ end
60
+
61
+ # Prevent directory traversal
62
+ if guide_name.to_s.include?("..") || guide_name.to_s.start_with?("/")
63
+ return "Invalid guide name '#{guide_name}'. Directory traversal is not allowed."
64
+ end
65
+
48
66
  # Try exact match first
49
67
  guide_file = File.join(guides_path, "#{guide_name}.md")
50
68