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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/README.md +82 -4
- data/docs/{AGENT.md → AGENT.md} +84 -5
- data/docs/COPILOT_AGENT.md +261 -0
- data/docs/RESOURCES.md +2 -2
- data/exe/rails-mcp-config +89 -75
- data/exe/rails-mcp-server +5 -0
- data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +14 -7
- data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +1 -1
- data/lib/rails-mcp-server/analyzers/analyze_models.rb +18 -9
- data/lib/rails-mcp-server/analyzers/base_analyzer.rb +16 -6
- data/lib/rails-mcp-server/analyzers/get_file.rb +16 -4
- data/lib/rails-mcp-server/analyzers/get_routes.rb +5 -4
- data/lib/rails-mcp-server/analyzers/get_schema.rb +102 -44
- data/lib/rails-mcp-server/analyzers/list_files.rb +56 -14
- data/lib/rails-mcp-server/analyzers/load_guide.rb +25 -7
- data/lib/rails-mcp-server/analyzers/project_info.rb +1 -1
- data/lib/rails-mcp-server/config.rb +87 -2
- data/lib/rails-mcp-server/helpers/resource_base.rb +48 -9
- data/lib/rails-mcp-server/helpers/resource_downloader.rb +2 -2
- data/lib/rails-mcp-server/resources/guide_content_formatter.rb +3 -3
- data/lib/rails-mcp-server/resources/guide_error_handler.rb +2 -2
- data/lib/rails-mcp-server/resources/guide_loader_template.rb +1 -1
- data/lib/rails-mcp-server/resources/guide_manifest_operations.rb +1 -1
- data/lib/rails-mcp-server/resources/kamal_guides_resources.rb +1 -1
- data/lib/rails-mcp-server/tools/execute_tool.rb +9 -3
- data/lib/rails-mcp-server/tools/get_model.rb +18 -13
- data/lib/rails-mcp-server/tools/search_tools.rb +4 -4
- data/lib/rails-mcp-server/tools/switch_project.rb +10 -1
- data/lib/rails-mcp-server/utilities/path_validator.rb +100 -0
- data/lib/rails-mcp-server/utilities/run_process.rb +25 -15
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +1 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 |
|
|
113
|
-
" #{name} (#{type})
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
172
|
+
|
|
173
|
+
parse_lines(output)
|
|
150
174
|
end
|
|
151
175
|
|
|
152
176
|
def get_column_count(table_name)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
215
|
+
lines = parse_lines(output)
|
|
216
|
+
return nil if lines.empty?
|
|
169
217
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
218
|
+
formatted_fks = lines.map do |line|
|
|
219
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
220
|
+
next if parts.size < 4
|
|
173
221
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
245
|
+
lines = parse_lines(output)
|
|
246
|
+
return nil if lines.empty?
|
|
195
247
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
248
|
+
formatted_indexes = lines.map do |line|
|
|
249
|
+
parts = line.split(FIELD_SEPARATOR)
|
|
250
|
+
next if parts.size < 3
|
|
199
251
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
" #{
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
63
|
+
def collect_files_git(directory, pattern)
|
|
64
|
+
relative_dir = directory.empty? ? "" : "#{directory}/"
|
|
65
|
+
search_pattern = "#{relative_dir}#{pattern}"
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
74
|
+
files.to_s.split("\n").map(&:strip).reject(&:empty?).sort
|
|
75
|
+
end
|
|
38
76
|
|
|
39
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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(
|
|
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 '#{
|
|
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(
|
|
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
|
|
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.
|
|
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}
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
clean = clean
|
|
92
|
-
clean = clean.
|
|
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.
|
|
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.
|
|
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
|
-
#{
|
|
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
|
-
#{
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|