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