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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -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 +19 -1
- data/exe/rails-mcp-server +5 -0
- data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +12 -5
- data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +1 -1
- data/lib/rails-mcp-server/analyzers/analyze_models.rb +17 -8
- 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 +3 -2
- data/lib/rails-mcp-server/analyzers/get_schema.rb +101 -43
- 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/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 +1 -1
- 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/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 +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|
|
|
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
|
-
|
|
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
|
-
|
|
106
|
+
raw_output = execute_rails_runner(script)
|
|
98
107
|
data = begin
|
|
99
|
-
JSON.parse(
|
|
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
|
-
|
|
173
|
+
raw_output = execute_rails_runner(script)
|
|
165
174
|
data = begin
|
|
166
|
-
JSON.parse(
|
|
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.
|
|
32
|
+
string.to_s.camelize
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
def underscore(string)
|
|
34
|
-
string.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
11
|
+
validated_path = PathValidator.validate_path(path, active_project_path)
|
|
12
12
|
|
|
13
|
-
|
|
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?(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|