rails-mcp-server 1.4.0 → 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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -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 +89 -75
  8. data/exe/rails-mcp-server +5 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +14 -7
  10. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +1 -1
  11. data/lib/rails-mcp-server/analyzers/analyze_models.rb +18 -9
  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 +5 -4
  15. data/lib/rails-mcp-server/analyzers/get_schema.rb +102 -44
  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/analyzers/project_info.rb +1 -1
  19. data/lib/rails-mcp-server/config.rb +87 -2
  20. data/lib/rails-mcp-server/helpers/resource_base.rb +48 -9
  21. data/lib/rails-mcp-server/helpers/resource_downloader.rb +2 -2
  22. data/lib/rails-mcp-server/resources/guide_content_formatter.rb +3 -3
  23. data/lib/rails-mcp-server/resources/guide_error_handler.rb +2 -2
  24. data/lib/rails-mcp-server/resources/guide_loader_template.rb +1 -1
  25. data/lib/rails-mcp-server/resources/guide_manifest_operations.rb +1 -1
  26. data/lib/rails-mcp-server/resources/kamal_guides_resources.rb +1 -1
  27. data/lib/rails-mcp-server/tools/execute_tool.rb +9 -3
  28. data/lib/rails-mcp-server/tools/get_model.rb +18 -13
  29. data/lib/rails-mcp-server/tools/search_tools.rb +4 -4
  30. data/lib/rails-mcp-server/tools/switch_project.rb +10 -1
  31. data/lib/rails-mcp-server/utilities/path_validator.rb +100 -0
  32. data/lib/rails-mcp-server/utilities/run_process.rb +25 -15
  33. data/lib/rails-mcp-server/version.rb +1 -1
  34. data/lib/rails_mcp_server.rb +1 -0
  35. metadata +34 -4
@@ -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."
@@ -10,11 +13,18 @@ module RailsMcpServer
10
13
 
11
14
  detail_level = "full" unless %w[tables summary full].include?(detail_level)
12
15
 
13
- if table_names && table_names.is_a?(Array) && table_names.any?
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 ? ", nullable" : ""}#{default ? ", default: #{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 ? " 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
 
@@ -8,7 +8,7 @@ module RailsMcpServer
8
8
  return message
9
9
  end
10
10
 
11
- max_depth = [[max_depth.to_i, 1].max, 5].min
11
+ max_depth = [[max_depth.to_i, 1].max, 5].min # rubocop:disable Style/ComparableClamp
12
12
  detail_level = "full" unless %w[minimal summary full].include?(detail_level)
13
13
 
14
14
  gemfile_path = File.join(active_project_path, "Gemfile")
@@ -29,6 +29,83 @@ module RailsMcpServer
29
29
  private
30
30
 
31
31
  def load_projects
32
+ # Priority 1: Environment variable (GitHub Copilot Agent mode)
33
+ return if load_from_env_var
34
+
35
+ # Priority 2: Auto-detect Rails project in current directory
36
+ return if auto_detect_rails_project
37
+
38
+ # Priority 3: Load from projects.yml (existing behavior)
39
+ load_from_projects_file
40
+ end
41
+
42
+ def load_from_env_var
43
+ return false unless ENV["RAILS_MCP_PROJECT_PATH"]
44
+
45
+ path = File.expand_path(ENV["RAILS_MCP_PROJECT_PATH"])
46
+ project_name = File.basename(path)
47
+
48
+ @projects = {project_name => path}
49
+ @current_project = project_name
50
+ @active_project_path = path
51
+ @logger.add(Logger::INFO, "Using RAILS_MCP_PROJECT_PATH: #{project_name} at #{path}")
52
+ true
53
+ end
54
+
55
+ def auto_detect_rails_project
56
+ # Check for Rails app (Gemfile with rails gem)
57
+ if rails_app?
58
+ set_auto_detected_project("Rails application")
59
+ return true
60
+ end
61
+
62
+ # Check for Rails engine (gemspec with rails dependency)
63
+ if rails_engine?
64
+ set_auto_detected_project("Rails engine")
65
+ return true
66
+ end
67
+
68
+ false
69
+ rescue => e
70
+ @logger.add(Logger::DEBUG, "Auto-detect failed: #{e.message}")
71
+ false
72
+ end
73
+
74
+ def rails_app?
75
+ gemfile = File.join(Dir.pwd, "Gemfile")
76
+ return false unless File.exist?(gemfile)
77
+
78
+ content = File.read(gemfile)
79
+ content.include?("'rails'") || content.include?('"rails"') ||
80
+ content.match?(/gem\s+['"]rails['"]/)
81
+ end
82
+
83
+ def rails_engine?
84
+ # Find gemspec files in current directory
85
+ gemspec_files = Dir.glob(File.join(Dir.pwd, "*.gemspec"))
86
+ return false if gemspec_files.empty?
87
+
88
+ gemspec_files.any? do |gemspec_file|
89
+ content = File.read(gemspec_file)
90
+ # Check for rails dependency in gemspec
91
+ # Common patterns:
92
+ # add_dependency "rails", ...
93
+ # add_runtime_dependency "rails", ...
94
+ # add_dependency "railties", ...
95
+ # add_runtime_dependency "actionpack", ...
96
+ content.match?(/add(?:_runtime)?_dependency\s+['"](?:rails|railties|actionpack|activerecord|activesupport|actionview|actionmailer|activejob|actioncable|activestorage|actionmailbox|actiontext)['"]/)
97
+ end
98
+ end
99
+
100
+ def set_auto_detected_project(project_type)
101
+ project_name = File.basename(Dir.pwd)
102
+ @projects = {project_name => Dir.pwd}
103
+ @current_project = project_name
104
+ @active_project_path = Dir.pwd
105
+ @logger.add(Logger::INFO, "Auto-detected #{project_type}: #{project_name} at #{Dir.pwd}")
106
+ end
107
+
108
+ def load_from_projects_file
32
109
  projects_file = File.join(@config_dir, "projects.yml")
33
110
  @projects = {}
34
111
 
@@ -41,16 +118,24 @@ module RailsMcpServer
41
118
  File.write(projects_file, "# Rails MCP Projects\n# Format: project_name: /path/to/project\n")
42
119
  end
43
120
 
44
- @projects = YAML.load_file(projects_file) || {}
121
+ @projects = YAML.safe_load_file(projects_file, permitted_classes: [Symbol]) || {}
45
122
  found_projects_size = @projects.size
46
123
  @logger.add(Logger::INFO, "Loaded #{found_projects_size} projects: #{@projects.keys.join(", ")}")
47
124
 
48
125
  if found_projects_size.zero?
49
- message = "No projects found.\nPlease add a project to #{projects_file} and try again."
126
+ message = "No projects found.\nPlease add a project to #{projects_file} or run from a Rails directory."
50
127
  puts message
51
128
  @logger.add(Logger::ERROR, message)
52
129
  exit 1
53
130
  end
131
+
132
+ # Auto-switch if only one project configured
133
+ if @projects.size == 1
134
+ name, path = @projects.first
135
+ @current_project = name
136
+ @active_project_path = File.expand_path(path)
137
+ @logger.add(Logger::INFO, "Auto-switched to single project: #{name}")
138
+ end
54
139
  end
55
140
 
56
141
  def configure_logger
@@ -27,7 +27,7 @@ module RailsMcpServer
27
27
 
28
28
  def load_manifest
29
29
  @manifest = if File.exist?(@manifest_file)
30
- YAML.load_file(@manifest_file)
30
+ YAML.safe_load_file(@manifest_file, permitted_classes: [Symbol, Time])
31
31
  else
32
32
  create_manifest
33
33
  end
@@ -70,15 +70,17 @@ module RailsMcpServer
70
70
  def find_title(content)
71
71
  lines = content.lines
72
72
 
73
- # H1 header
74
73
  lines.each do |line|
75
- return $1.strip if line.strip =~ /^#\s+(.+)$/
74
+ stripped = line.strip
75
+ if stripped.start_with?("# ") && stripped.length > 2
76
+ return stripped[2..].strip
77
+ end
76
78
  end
77
79
 
78
- # Underlined title
79
80
  lines.each_with_index do |line, index|
80
81
  next if index >= lines.length - 1
81
- return line.strip if /^=+$/.match?(lines[index + 1].strip)
82
+ next_line = lines[index + 1].strip
83
+ return line.strip if !next_line.empty? && next_line.chars.all? { |c| c == "=" }
82
84
  end
83
85
 
84
86
  nil
@@ -87,10 +89,9 @@ module RailsMcpServer
87
89
  def find_description(content)
88
90
  # Clean content
89
91
  clean = content.dup
90
- clean = clean.sub(/^---\s*\n.*?\n---\s*\n/m, "") # Remove YAML frontmatter
91
- clean = clean.gsub(/^#\s+.*?\n/, "") # Remove H1 headers
92
- clean = clean.gsub(/^.+\n=+\s*\n/, "") # Remove underlined titles
93
- clean = clean.strip.gsub(/\n+/, " ").gsub(/\s+/, " ")
92
+ clean = remove_yaml_frontmatter(clean)
93
+ clean = remove_markdown_headers(clean)
94
+ clean = clean.strip.tr("\n", " ").gsub(/\s+/, " ")
94
95
 
95
96
  return "" if clean.empty?
96
97
 
@@ -122,6 +123,44 @@ module RailsMcpServer
122
123
  title.strip.empty? ? "Untitled Guide" : title
123
124
  end
124
125
 
126
+ def remove_yaml_frontmatter(content)
127
+ lines = content.lines
128
+ return content unless lines.first&.strip == "---"
129
+
130
+ closing_index = lines[1..].index { |l| l.strip == "---" }
131
+ return content unless closing_index
132
+
133
+ lines[(closing_index + 2)..].join
134
+ end
135
+
136
+ def remove_markdown_headers(content)
137
+ lines = content.lines
138
+ result = []
139
+ skip_next = false
140
+
141
+ lines.each_with_index do |line, index|
142
+ if skip_next
143
+ skip_next = false
144
+ next
145
+ end
146
+
147
+ stripped = line.strip
148
+ next if stripped.start_with?("# ") && stripped.length > 2
149
+
150
+ if index < lines.length - 1
151
+ next_stripped = lines[index + 1].strip
152
+ if !next_stripped.empty? && next_stripped.chars.all? { |c| c == "=" }
153
+ skip_next = true
154
+ next
155
+ end
156
+ end
157
+
158
+ result << line
159
+ end
160
+
161
+ result.join
162
+ end
163
+
125
164
  def file_hash(file_path)
126
165
  Digest::SHA256.file(file_path).hexdigest
127
166
  end
@@ -32,7 +32,7 @@ module RailsMcpServer
32
32
  config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml")
33
33
  return [] unless File.exist?(config_file)
34
34
 
35
- YAML.load_file(config_file).keys
35
+ YAML.safe_load_file(config_file, permitted_classes: [Symbol]).keys
36
36
  rescue => e
37
37
  warn "Failed to load resource configuration: #{e.message}"
38
38
  []
@@ -63,7 +63,7 @@ module RailsMcpServer
63
63
 
64
64
  raise DownloadError, "Resource configuration file not found" unless File.exist?(config_file)
65
65
 
66
- all_configs = YAML.load_file(config_file)
66
+ all_configs = YAML.safe_load_file(config_file, permitted_classes: [Symbol])
67
67
  @config = all_configs[@resource_name]
68
68
 
69
69
  raise DownloadError, "Unknown resource: #{@resource_name}" unless @config
@@ -40,13 +40,13 @@ module RailsMcpServer
40
40
  <<~GUIDE
41
41
  ### #{title}
42
42
  **Guide name:** `#{short_name}` or `#{full_name}`
43
- #{description.empty? ? "" : "**Description:** #{description}"}
43
+ #{"**Description:** #{description}" unless description.empty?}
44
44
  GUIDE
45
45
  else
46
46
  <<~GUIDE
47
47
  ## #{title}
48
48
  **Guide name:** `#{short_name}`
49
- #{description.empty? ? "" : "**Description:** #{description}"}
49
+ #{"**Description:** #{description}" unless description.empty?}
50
50
  GUIDE
51
51
  end
52
52
  end
@@ -59,7 +59,7 @@ module RailsMcpServer
59
59
  usage += "```\n"
60
60
 
61
61
  examples.each do |example|
62
- usage += "load_guide guides: \"#{framework_name.downcase}\", guide: \"#{example[:guide]}\"#{example[:comment] ? " # " + example[:comment] : ""}\n"
62
+ usage += "execute_tool(\"load_guide\", { library: \"#{framework_name.downcase}\", guide: \"#{example[:guide]}\" })#{" # " + example[:comment] if example[:comment]}\n"
63
63
  end
64
64
 
65
65
  usage += "```\n"
@@ -19,10 +19,10 @@ module RailsMcpServer
19
19
  if suggestions.any?
20
20
  message += "## Did you mean one of these?\n\n"
21
21
  suggestions.each { |suggestion| message += "- #{suggestion}\n" }
22
- message += "\n**Try:** `load_guide guides: \"#{framework_name.downcase}\", guide: \"#{suggestions.first}\"`\n"
22
+ message += "\n**Try:** `execute_tool(\"load_guide\", { library: \"#{framework_name.downcase}\", guide: \"#{suggestions.first}\" })`\n"
23
23
  else
24
24
  message += format_available_guides_section(available_guides)
25
- message += "Use `load_guide guides: \"#{framework_name.downcase}\"` to see all available guides with descriptions.\n"
25
+ message += "Use `execute_tool(\"load_guide\", { library: \"#{framework_name.downcase}\" })` to see all available guides with descriptions.\n"
26
26
  end
27
27
 
28
28
  message
@@ -78,7 +78,7 @@ module RailsMcpServer
78
78
  guides = []
79
79
 
80
80
  guides << "# Available #{framework_name} Guides\n"
81
- guides << "Use the `load_guide` tool with `guides: \"#{framework_name.downcase}\"` and `guide: \"guide_name\"` to load a specific guide.\n"
81
+ guides << "Use `execute_tool(\"load_guide\", { library: \"#{framework_name.downcase}\", guide: \"guide_name\" })` to load a specific guide.\n"
82
82
 
83
83
  if supports_sections?
84
84
  guides << "You can use either the full path (e.g., `handbook/01_introduction`) or just the filename (e.g., `01_introduction`).\n"
@@ -13,7 +13,7 @@ module RailsMcpServer
13
13
  raise StandardError, error_message
14
14
  end
15
15
 
16
- YAML.load_file(manifest_file)
16
+ YAML.safe_load_file(manifest_file, permitted_classes: [Symbol, Time])
17
17
  end
18
18
 
19
19
  # Extract guide metadata from manifest entry