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
data/exe/rails-mcp-server
CHANGED
|
@@ -72,17 +72,30 @@ end
|
|
|
72
72
|
RailsMcpServer.config.log_level = log_level
|
|
73
73
|
RailsMcpServer.log(:info, "Starting Rails MCP Server in #{mode} mode...")
|
|
74
74
|
|
|
75
|
-
#
|
|
75
|
+
# Register only bootstrap tools - other tools are invoked via execute_tool
|
|
76
|
+
# This reduces context token usage by not loading all tool definitions upfront
|
|
77
|
+
#
|
|
78
|
+
# Workflow:
|
|
79
|
+
# 1. switch_project - Select a Rails project to work with
|
|
80
|
+
# 2. search_tools - Discover available tools and their parameters
|
|
81
|
+
# 3. execute_tool - Invoke internal tools by name
|
|
82
|
+
# 4. execute_ruby - Run custom Ruby code for complex queries
|
|
83
|
+
#
|
|
76
84
|
def setup_mcp_tools(server)
|
|
77
|
-
server.register_tools(
|
|
78
|
-
RailsMcpServer::
|
|
79
|
-
RailsMcpServer::
|
|
80
|
-
RailsMcpServer::
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
RailsMcpServer::
|
|
85
|
+
server.register_tools(
|
|
86
|
+
RailsMcpServer::SwitchProject,
|
|
87
|
+
RailsMcpServer::SearchTools,
|
|
88
|
+
RailsMcpServer::ExecuteTool,
|
|
89
|
+
RailsMcpServer::ExecuteRuby
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
server.register_resources(
|
|
93
|
+
RailsMcpServer::RailsGuidesResource, RailsMcpServer::RailsGuidesResources,
|
|
94
|
+
RailsMcpServer::StimulusGuidesResource, RailsMcpServer::StimulusGuidesResources,
|
|
95
|
+
RailsMcpServer::TurboGuidesResource, RailsMcpServer::TurboGuidesResources,
|
|
96
|
+
RailsMcpServer::CustomGuidesResource, RailsMcpServer::CustomGuidesResources,
|
|
97
|
+
RailsMcpServer::KamalGuidesResource, RailsMcpServer::KamalGuidesResources
|
|
98
|
+
)
|
|
86
99
|
end
|
|
87
100
|
|
|
88
101
|
case mode
|
data/exe/rails-mcp-setup-claude
CHANGED
|
@@ -100,7 +100,7 @@ FileUtils.mkdir_p(claude_config_dir)
|
|
|
100
100
|
server_path = File.join(project_dir, "exe", "rails-mcp-server")
|
|
101
101
|
|
|
102
102
|
# Ensure the server is executable
|
|
103
|
-
FileUtils.chmod("+x", server_path)
|
|
103
|
+
FileUtils.chmod("+x", server_path) unless File.stat(server_path).executable?
|
|
104
104
|
puts "✓".green + " Made server executable"
|
|
105
105
|
|
|
106
106
|
# Create or update the Claude Desktop config file
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
module Analyzers
|
|
3
|
+
class AnalyzeControllerViews < BaseAnalyzer
|
|
4
|
+
def call(controller_name: nil, detail_level: "full", analysis_type: "introspection")
|
|
5
|
+
unless current_project
|
|
6
|
+
return "No active project. Please switch to a project first."
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
detail_level = "full" unless %w[names summary full].include?(detail_level)
|
|
10
|
+
analysis_type = "introspection" unless %w[introspection static full].include?(analysis_type)
|
|
11
|
+
|
|
12
|
+
controllers_dir = File.join(active_project_path, "app", "controllers")
|
|
13
|
+
return "Controllers directory not found." unless File.directory?(controllers_dir)
|
|
14
|
+
|
|
15
|
+
controller_files = Dir.glob(File.join(controllers_dir, "**", "*_controller.rb"))
|
|
16
|
+
.reject { |f| f.include?("application_controller") }
|
|
17
|
+
|
|
18
|
+
return "No controllers found." if controller_files.empty?
|
|
19
|
+
|
|
20
|
+
if controller_name
|
|
21
|
+
normalized = normalize_controller_name(controller_name)
|
|
22
|
+
controller_files = controller_files.select { |f| File.basename(f).downcase == "#{normalized}_controller.rb" }
|
|
23
|
+
return "Controller '#{controller_name}' not found." if controller_files.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
analyze_controllers(controller_files, detail_level, analysis_type)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def normalize_controller_name(name)
|
|
32
|
+
name.sub(/_?controller$/i, "").downcase
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def analyze_controllers(controller_files, detail_level, analysis_type)
|
|
36
|
+
output = []
|
|
37
|
+
controller_files.each do |file_path|
|
|
38
|
+
controller_class = derive_controller_class(file_path)
|
|
39
|
+
relative_path = file_path.sub(active_project_path + "/", "")
|
|
40
|
+
|
|
41
|
+
case detail_level
|
|
42
|
+
when "names"
|
|
43
|
+
actions = get_actions_via_introspection(controller_class)
|
|
44
|
+
output << "#{controller_class} (#{actions.size} actions)"
|
|
45
|
+
output << " Actions: #{actions.join(", ")}" << ""
|
|
46
|
+
when "summary"
|
|
47
|
+
output << format_summary(controller_class, file_path, relative_path)
|
|
48
|
+
else
|
|
49
|
+
output << format_full_analysis(controller_class, file_path, relative_path, analysis_type)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
output.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get_actions_via_introspection(controller_class)
|
|
56
|
+
script = "require 'json'; puts (#{controller_class}.action_methods.to_a.sort rescue []).to_json"
|
|
57
|
+
begin
|
|
58
|
+
JSON.parse(execute_rails_runner(script))
|
|
59
|
+
rescue
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_summary(controller_class, file_path, relative_path)
|
|
65
|
+
data = get_controller_summary(controller_class)
|
|
66
|
+
output = ["#{controller_class}", " File: #{relative_path}", " Actions: #{data[:actions].size}"]
|
|
67
|
+
data[:actions].each do |action|
|
|
68
|
+
route = data[:routes].find { |r| r[:action] == action }
|
|
69
|
+
output << if route
|
|
70
|
+
" #{action}: [#{route[:verb].empty? ? "ANY" : route[:verb]}] #{route[:path]}"
|
|
71
|
+
else
|
|
72
|
+
" #{action}: (no route)"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
output << ""
|
|
76
|
+
output.join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get_controller_summary(controller_class)
|
|
80
|
+
script = <<~RUBY
|
|
81
|
+
require 'json'
|
|
82
|
+
begin
|
|
83
|
+
c = #{controller_class}
|
|
84
|
+
actions = c.action_methods.to_a.sort
|
|
85
|
+
routes = Rails.application.routes.routes.select { |r| r.defaults[:controller] == '#{controller_class.sub("Controller", "").underscore}' }
|
|
86
|
+
.map { |r| { verb: r.verb.to_s.gsub(/[\\^$\\/]/, ''), path: r.path.spec.to_s.gsub('(.:format)', ''), action: r.defaults[:action].to_s } }
|
|
87
|
+
puts({ actions: actions, routes: routes }.to_json)
|
|
88
|
+
rescue => e
|
|
89
|
+
puts({ actions: [], routes: [], error: e.message }.to_json)
|
|
90
|
+
end
|
|
91
|
+
RUBY
|
|
92
|
+
begin
|
|
93
|
+
JSON.parse(execute_rails_runner(script), symbolize_names: true)
|
|
94
|
+
rescue
|
|
95
|
+
{actions: [], routes: []}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_full_analysis(controller_class, file_path, relative_path, analysis_type)
|
|
100
|
+
output = ["=" * 70, "Controller: #{controller_class}", "File: #{relative_path}", "=" * 70]
|
|
101
|
+
|
|
102
|
+
if %w[introspection full].include?(analysis_type)
|
|
103
|
+
output << "" << introspection_analysis(controller_class)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if %w[static full].include?(analysis_type)
|
|
107
|
+
output << "" << static_analysis(file_path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
output << "" << view_analysis(file_path)
|
|
111
|
+
output.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def introspection_analysis(controller_class)
|
|
115
|
+
script = <<~RUBY
|
|
116
|
+
require 'json'
|
|
117
|
+
begin
|
|
118
|
+
c = #{controller_class}
|
|
119
|
+
result = {
|
|
120
|
+
actions: c.action_methods.to_a.sort,
|
|
121
|
+
parent: c.superclass.name,
|
|
122
|
+
callbacks: c._process_action_callbacks.map { |cb| { kind: cb.kind.to_s, filter: cb.filter.to_s, only: Array(cb.options[:only]).map(&:to_s), except: Array(cb.options[:except]).map(&:to_s) } },
|
|
123
|
+
routes: Rails.application.routes.routes.select { |r| r.defaults[:controller] == '#{controller_class.sub("Controller", "").underscore}' }
|
|
124
|
+
.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
|
+
}
|
|
126
|
+
puts result.to_json
|
|
127
|
+
rescue => e
|
|
128
|
+
puts({ error: e.message }.to_json)
|
|
129
|
+
end
|
|
130
|
+
RUBY
|
|
131
|
+
data = begin
|
|
132
|
+
JSON.parse(execute_rails_runner(script), symbolize_names: true)
|
|
133
|
+
rescue
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
return "Introspection Error" unless data
|
|
137
|
+
return "Error: #{data[:error]}" if data[:error]
|
|
138
|
+
format_introspection_result(data)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_introspection_result(data)
|
|
142
|
+
output = ["RAILS INTROSPECTION:", "-" * 40, "Parent: #{data[:parent]}", "", "Actions (#{data[:actions].size}):"]
|
|
143
|
+
data[:actions].each { |a| output << " #{a}" }
|
|
144
|
+
|
|
145
|
+
if data[:callbacks]&.any?
|
|
146
|
+
output << "" << "Callbacks:"
|
|
147
|
+
data[:callbacks].each do |cb|
|
|
148
|
+
opts = []
|
|
149
|
+
opts << "only: [#{cb[:only].join(", ")}]" if cb[:only]&.any?
|
|
150
|
+
opts << "except: [#{cb[:except].join(", ")}]" if cb[:except]&.any?
|
|
151
|
+
output << " #{cb[:kind]}_action :#{cb[:filter]}#{opts.any? ? ", #{opts.join(", ")}" : ""}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if data[:routes]&.any?
|
|
156
|
+
output << "" << "Routes (#{data[:routes].size}):"
|
|
157
|
+
data[:routes].each do |r|
|
|
158
|
+
verb = r[:verb].empty? ? "ANY" : r[:verb]
|
|
159
|
+
output << " #{verb.ljust(7)} #{r[:path].ljust(35)} -> #{r[:action]}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
output.join("\n")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def static_analysis(file_path)
|
|
167
|
+
script = <<~RUBY
|
|
168
|
+
require 'json'
|
|
169
|
+
begin
|
|
170
|
+
require 'prism'
|
|
171
|
+
source = File.read('#{file_path}')
|
|
172
|
+
result = Prism.parse(source)
|
|
173
|
+
before_actions, strong_params, instance_vars = [], [], {}
|
|
174
|
+
|
|
175
|
+
visit = ->(node, ctx = {}) {
|
|
176
|
+
case node
|
|
177
|
+
when Prism::CallNode
|
|
178
|
+
name = node.name.to_s
|
|
179
|
+
args = node.arguments&.arguments&.map { |a| a.is_a?(Prism::SymbolNode) ? a.value.to_s : nil }&.compact || []
|
|
180
|
+
before_actions << { method: args.first, name: name } if %w[before_action after_action skip_before_action].include?(name)
|
|
181
|
+
strong_params << args if name == 'permit'
|
|
182
|
+
when Prism::DefNode
|
|
183
|
+
mname = node.name.to_s
|
|
184
|
+
instance_vars[mname] = []
|
|
185
|
+
node.child_nodes.compact.each { |c| visit.call(c, { method: mname }) }
|
|
186
|
+
return
|
|
187
|
+
when Prism::InstanceVariableWriteNode
|
|
188
|
+
instance_vars[ctx[:method]] << node.name.to_s if ctx[:method]
|
|
189
|
+
end
|
|
190
|
+
node.child_nodes.compact.each { |c| visit.call(c, ctx) }
|
|
191
|
+
}
|
|
192
|
+
result.value.statements.body.each { |n| visit.call(n) }
|
|
193
|
+
puts({ before_actions: before_actions, strong_params: strong_params, instance_vars: instance_vars }.to_json)
|
|
194
|
+
rescue LoadError
|
|
195
|
+
puts({ error: "Prism not available" }.to_json)
|
|
196
|
+
rescue => e
|
|
197
|
+
puts({ error: e.message }.to_json)
|
|
198
|
+
end
|
|
199
|
+
RUBY
|
|
200
|
+
data = begin
|
|
201
|
+
JSON.parse(execute_rails_runner(script), symbolize_names: true)
|
|
202
|
+
rescue
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
return "Static Analysis Error" unless data
|
|
206
|
+
return "Error: #{data[:error]}" if data[:error]
|
|
207
|
+
format_static_result(data)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def format_static_result(data)
|
|
211
|
+
output = ["PRISM STATIC ANALYSIS:", "-" * 40]
|
|
212
|
+
if data[:before_actions]&.any?
|
|
213
|
+
output << "Filters:"
|
|
214
|
+
data[:before_actions].each { |ba| output << " #{ba[:name]} :#{ba[:method]}" }
|
|
215
|
+
end
|
|
216
|
+
if data[:strong_params]&.any?
|
|
217
|
+
output << "Strong Parameters:"
|
|
218
|
+
data[:strong_params].each { |sp| output << " permit(#{sp.join(", ")})" }
|
|
219
|
+
end
|
|
220
|
+
if data[:instance_vars]&.any?
|
|
221
|
+
output << "Instance Variables by Action:"
|
|
222
|
+
data[:instance_vars].each { |action, vars| output << " #{action}: #{vars.uniq.join(", ")}" if vars.any? }
|
|
223
|
+
end
|
|
224
|
+
output.join("\n")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def view_analysis(file_path)
|
|
228
|
+
controller_name = File.basename(file_path, "_controller.rb")
|
|
229
|
+
views_dir = File.join(active_project_path, "app", "views", controller_name)
|
|
230
|
+
output = ["VIEW TEMPLATES:", "-" * 40]
|
|
231
|
+
|
|
232
|
+
unless File.directory?(views_dir)
|
|
233
|
+
output << " No views directory found"
|
|
234
|
+
return output.join("\n")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
view_files = Dir.glob(File.join(views_dir, "*")).reject { |f| File.directory?(f) }
|
|
238
|
+
if view_files.empty?
|
|
239
|
+
output << " No view templates found"
|
|
240
|
+
else
|
|
241
|
+
by_action = view_files.group_by { |f| File.basename(f).split(".").first }
|
|
242
|
+
by_action.sort.each { |action, files| output << " #{action}: #{files.map { |f| File.basename(f) }.join(", ")}" }
|
|
243
|
+
end
|
|
244
|
+
output.join("\n")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def derive_controller_class(file_path)
|
|
248
|
+
relative = file_path.sub(File.join(active_project_path, "app", "controllers") + "/", "").sub(/_controller\.rb$/, "")
|
|
249
|
+
relative.split("/").map { |part| camelize(part) }.join("::") + "Controller"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
module Analyzers
|
|
3
|
+
class AnalyzeEnvironmentConfig < BaseAnalyzer
|
|
4
|
+
def call
|
|
5
|
+
unless current_project
|
|
6
|
+
return "No active project. Please switch to a project first."
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
environments_dir = File.join(active_project_path, "config", "environments")
|
|
10
|
+
unless File.directory?(environments_dir)
|
|
11
|
+
return "Environments directory not found at config/environments."
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
env_files = Dir.glob(File.join(environments_dir, "*.rb"))
|
|
15
|
+
return "No environment files found." if env_files.empty?
|
|
16
|
+
|
|
17
|
+
analyze_environments(env_files)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def analyze_environments(env_files)
|
|
23
|
+
configs = {}
|
|
24
|
+
|
|
25
|
+
env_files.each do |file|
|
|
26
|
+
env_name = File.basename(file, ".rb")
|
|
27
|
+
content = File.read(file)
|
|
28
|
+
configs[env_name] = extract_config_settings(content)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
output = ["Environment Configuration Analysis", "=" * 50, ""]
|
|
32
|
+
|
|
33
|
+
# List each environment's settings
|
|
34
|
+
configs.each do |env, settings|
|
|
35
|
+
output << "#{env.capitalize}:"
|
|
36
|
+
settings.each do |key, value|
|
|
37
|
+
output << " #{key} = #{value}"
|
|
38
|
+
end
|
|
39
|
+
output << ""
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Find inconsistencies
|
|
43
|
+
all_keys = configs.values.flat_map(&:keys).uniq.sort
|
|
44
|
+
inconsistencies = []
|
|
45
|
+
|
|
46
|
+
all_keys.each do |key|
|
|
47
|
+
values_by_env = configs.map { |env, settings| [env, settings[key]] }.to_h
|
|
48
|
+
present_in = values_by_env.select { |_, v| !v.nil? }.keys
|
|
49
|
+
|
|
50
|
+
if present_in.size < configs.size && present_in.size > 0
|
|
51
|
+
missing = configs.keys - present_in
|
|
52
|
+
inconsistencies << "#{key}: missing in #{missing.join(", ")}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if inconsistencies.any?
|
|
57
|
+
output << "Potential Issues:"
|
|
58
|
+
output << "-" * 30
|
|
59
|
+
inconsistencies.each { |i| output << " ⚠ #{i}" }
|
|
60
|
+
else
|
|
61
|
+
output << "No inconsistencies found between environments."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
output.join("\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_config_settings(content)
|
|
68
|
+
settings = {}
|
|
69
|
+
|
|
70
|
+
# Match config.setting = value patterns
|
|
71
|
+
content.scan(/config\.(\w+(?:\.\w+)*)\s*=\s*(.+?)(?:\n|$)/).each do |key, value|
|
|
72
|
+
settings[key] = value.strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
settings
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
module Analyzers
|
|
3
|
+
class AnalyzeModels < BaseAnalyzer
|
|
4
|
+
def call(model_name: nil, model_names: nil, detail_level: "full", analysis_type: "introspection")
|
|
5
|
+
unless current_project
|
|
6
|
+
return "No active project. Please switch to a project first."
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
detail_level = "full" unless %w[names associations full].include?(detail_level)
|
|
10
|
+
analysis_type = "introspection" unless %w[introspection static full].include?(analysis_type)
|
|
11
|
+
|
|
12
|
+
if model_names && model_names.is_a?(Array) && model_names.any?
|
|
13
|
+
return batch_model_info(model_names, detail_level, analysis_type)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if model_name
|
|
17
|
+
return single_model_info(model_name, detail_level, analysis_type)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
list_all_models(detail_level)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def list_all_models(detail_level)
|
|
26
|
+
models_dir = File.join(active_project_path, "app", "models")
|
|
27
|
+
return "Models directory not found." unless File.directory?(models_dir)
|
|
28
|
+
|
|
29
|
+
model_files = Dir.glob(File.join(models_dir, "**", "*.rb"))
|
|
30
|
+
.map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") }
|
|
31
|
+
.reject { |f| f.include?("concern") || f.include?("application_record") }
|
|
32
|
+
.sort
|
|
33
|
+
|
|
34
|
+
case detail_level
|
|
35
|
+
when "names"
|
|
36
|
+
"Models in project (#{model_files.size}):\n\n#{model_files.join("\n")}"
|
|
37
|
+
when "associations"
|
|
38
|
+
output = ["Models with associations (#{model_files.size} models):\n"]
|
|
39
|
+
model_files.each do |model_file|
|
|
40
|
+
model_name = classify_model_name(model_file)
|
|
41
|
+
associations = get_associations_via_introspection(model_name)
|
|
42
|
+
if associations&.any?
|
|
43
|
+
output << " #{model_name}:"
|
|
44
|
+
associations.each { |a| output << " #{a[:type]} :#{a[:name]}" }
|
|
45
|
+
else
|
|
46
|
+
output << " #{model_name}: (no associations)"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
output.join("\n")
|
|
50
|
+
else
|
|
51
|
+
"Models in the project (#{model_files.size}):\n\n#{model_files.join("\n")}\n\nUse model_name parameter for details."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def single_model_info(model_name, detail_level, analysis_type)
|
|
56
|
+
model_file = find_model_file(model_name)
|
|
57
|
+
return "Model '#{model_name}' not found." unless model_file && File.exist?(model_file)
|
|
58
|
+
|
|
59
|
+
case detail_level
|
|
60
|
+
when "names"
|
|
61
|
+
"Model: #{model_name}\nFile: #{model_file.sub(active_project_path + "/", "")}"
|
|
62
|
+
when "associations"
|
|
63
|
+
format_associations_only(model_name, model_file)
|
|
64
|
+
else
|
|
65
|
+
build_full_analysis(model_name, model_file, analysis_type)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_associations_only(model_name, model_file)
|
|
70
|
+
associations = get_associations_via_introspection(model_name)
|
|
71
|
+
output = ["Model: #{model_name}", "File: #{model_file.sub(active_project_path + "/", "")}", "", "Associations:"]
|
|
72
|
+
if associations&.any?
|
|
73
|
+
associations.each { |a| output << " #{a[:type]} :#{a[:name]}" }
|
|
74
|
+
else
|
|
75
|
+
output << " None found"
|
|
76
|
+
end
|
|
77
|
+
output.join("\n")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_full_analysis(model_name, model_file, analysis_type)
|
|
81
|
+
output = ["=" * 60, "Model: #{model_name}", "File: #{model_file.sub(active_project_path + "/", "")}", "=" * 60]
|
|
82
|
+
|
|
83
|
+
if %w[introspection full].include?(analysis_type)
|
|
84
|
+
output << "" << introspection_analysis(model_name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if %w[static full].include?(analysis_type)
|
|
88
|
+
output << "" << static_analysis(model_file)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
output << "" << "Source Code:" << "```ruby" << File.read(model_file) << "```"
|
|
92
|
+
output.join("\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def introspection_analysis(model_name)
|
|
96
|
+
script = build_introspection_script(model_name)
|
|
97
|
+
json_output = execute_rails_runner(script)
|
|
98
|
+
data = begin
|
|
99
|
+
JSON.parse(json_output)
|
|
100
|
+
rescue
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
return "Introspection Error: Could not parse output" unless data
|
|
104
|
+
format_introspection_result(data)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_introspection_script(model_name)
|
|
108
|
+
<<~RUBY
|
|
109
|
+
require 'json'
|
|
110
|
+
begin
|
|
111
|
+
model = #{model_name}
|
|
112
|
+
result = {}
|
|
113
|
+
if model.respond_to?(:table_name) && model.table_exists?
|
|
114
|
+
result[:table_name] = model.table_name
|
|
115
|
+
result[:primary_key] = model.primary_key
|
|
116
|
+
result[:columns] = model.columns.map { |c| { name: c.name, type: c.type.to_s, null: c.null, default: c.default } }
|
|
117
|
+
result[:associations] = model.reflect_on_all_associations.map { |a| { name: a.name.to_s, type: a.macro.to_s, class_name: a.class_name, options: a.options.transform_keys(&:to_s) } }
|
|
118
|
+
result[:validations] = model.validators.map { |v| { type: v.class.name.demodulize.underscore.sub('_validator', ''), attributes: v.attributes.map(&:to_s) } }
|
|
119
|
+
result[:enums] = model.defined_enums.transform_values { |v| v.keys } if model.respond_to?(:defined_enums)
|
|
120
|
+
else
|
|
121
|
+
result[:error] = "Not an ActiveRecord model or table doesn't exist"
|
|
122
|
+
end
|
|
123
|
+
puts result.to_json
|
|
124
|
+
rescue => e
|
|
125
|
+
puts({ error: e.message }.to_json)
|
|
126
|
+
end
|
|
127
|
+
RUBY
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def format_introspection_result(data)
|
|
131
|
+
return "Introspection Error: #{data["error"]}" if data["error"]
|
|
132
|
+
output = ["RAILS INTROSPECTION:", "-" * 40]
|
|
133
|
+
output << "Table: #{data["table_name"]} (PK: #{data["primary_key"]})" << ""
|
|
134
|
+
|
|
135
|
+
if data["columns"]&.any?
|
|
136
|
+
output << "Columns (#{data["columns"].size}):"
|
|
137
|
+
data["columns"].each { |c| output << " #{c["name"]}: #{c["type"]} #{c["null"] ? "NULL" : "NOT NULL"}" }
|
|
138
|
+
output << ""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if data["associations"]&.any?
|
|
142
|
+
output << "Associations (#{data["associations"].size}):"
|
|
143
|
+
data["associations"].each { |a| output << " #{a["type"]} :#{a["name"]} -> #{a["class_name"]}" }
|
|
144
|
+
output << ""
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if data["validations"]&.any?
|
|
148
|
+
output << "Validations:"
|
|
149
|
+
grouped = data["validations"].group_by { |v| v["attributes"].join(", ") }
|
|
150
|
+
grouped.each { |attrs, vals| output << " #{attrs}: #{vals.map { |v| v["type"] }.join(", ")}" }
|
|
151
|
+
output << ""
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if data["enums"]&.any?
|
|
155
|
+
output << "Enums:"
|
|
156
|
+
data["enums"].each { |name, values| output << " #{name}: #{values.join(", ")}" }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
output.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def static_analysis(model_file)
|
|
163
|
+
script = build_static_analysis_script(model_file)
|
|
164
|
+
json_output = execute_rails_runner(script)
|
|
165
|
+
data = begin
|
|
166
|
+
JSON.parse(json_output)
|
|
167
|
+
rescue
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
return "Static Analysis Error: Could not parse output" unless data
|
|
171
|
+
return "Static Analysis Error: #{data["error"]}" if data["error"]
|
|
172
|
+
format_static_result(data)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_static_analysis_script(model_file)
|
|
176
|
+
<<~RUBY
|
|
177
|
+
require 'json'
|
|
178
|
+
begin
|
|
179
|
+
require 'prism'
|
|
180
|
+
source = File.read('#{model_file}')
|
|
181
|
+
result = Prism.parse(source)
|
|
182
|
+
callbacks, scopes, concerns, methods = [], [], [], []
|
|
183
|
+
|
|
184
|
+
visit = ->(node) {
|
|
185
|
+
case node
|
|
186
|
+
when Prism::CallNode
|
|
187
|
+
name = node.name.to_s
|
|
188
|
+
args = node.arguments&.arguments&.map { |a| a.is_a?(Prism::SymbolNode) ? a.value.to_s : nil }&.compact || []
|
|
189
|
+
if %w[before_save after_save before_create after_create before_update after_update before_destroy after_destroy after_commit before_validation after_validation].include?(name)
|
|
190
|
+
callbacks << { name: name, args: args }
|
|
191
|
+
elsif name == 'scope'
|
|
192
|
+
scopes << args.first
|
|
193
|
+
elsif %w[include extend].include?(name)
|
|
194
|
+
concerns.concat(args)
|
|
195
|
+
end
|
|
196
|
+
when Prism::DefNode
|
|
197
|
+
methods << { name: node.name.to_s, line: node.location.start_line }
|
|
198
|
+
end
|
|
199
|
+
node.child_nodes.compact.each { |c| visit.call(c) }
|
|
200
|
+
}
|
|
201
|
+
result.value.statements.body.each { |n| visit.call(n) }
|
|
202
|
+
puts({ callbacks: callbacks, scopes: scopes, concerns: concerns, methods: methods }.to_json)
|
|
203
|
+
rescue LoadError
|
|
204
|
+
puts({ error: "Prism not available" }.to_json)
|
|
205
|
+
rescue => e
|
|
206
|
+
puts({ error: e.message }.to_json)
|
|
207
|
+
end
|
|
208
|
+
RUBY
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def format_static_result(data)
|
|
212
|
+
output = ["PRISM STATIC ANALYSIS:", "-" * 40]
|
|
213
|
+
output << "Concerns: #{data["concerns"].join(", ")}" if data["concerns"]&.any?
|
|
214
|
+
if data["callbacks"]&.any?
|
|
215
|
+
output << "Callbacks:"
|
|
216
|
+
data["callbacks"].each { |c| output << " #{c["name"]} :#{c["args"].join(", :")}" }
|
|
217
|
+
end
|
|
218
|
+
output << "Scopes: #{data["scopes"].join(", ")}" if data["scopes"]&.any?
|
|
219
|
+
if data["methods"]&.any?
|
|
220
|
+
output << "Methods:"
|
|
221
|
+
data["methods"].each { |m| output << " #{m["name"]} (line #{m["line"]})" }
|
|
222
|
+
end
|
|
223
|
+
output.join("\n")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def batch_model_info(model_names, detail_level, analysis_type)
|
|
227
|
+
output = ["Analysis for #{model_names.size} models:\n"]
|
|
228
|
+
model_names.each { |m| output << single_model_info(m, detail_level, analysis_type) << "" }
|
|
229
|
+
output.join("\n")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def get_associations_via_introspection(model_name)
|
|
233
|
+
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
|
+
begin
|
|
235
|
+
JSON.parse(execute_rails_runner(script)).map { |a| a.transform_keys(&:to_sym) }
|
|
236
|
+
rescue
|
|
237
|
+
[]
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def find_model_file(model_name)
|
|
242
|
+
path = File.join(active_project_path, "app", "models", "#{underscore(model_name)}.rb")
|
|
243
|
+
File.exist?(path) ? path : Dir.glob(File.join(active_project_path, "app", "models", "**", "#{underscore(model_name).split("/").last}.rb")).first
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def classify_model_name(model_file)
|
|
247
|
+
model_file.split("/").map { |part| camelize(part) }.join("::")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RailsMcpServer
|
|
2
|
+
module Analyzers
|
|
3
|
+
class BaseAnalyzer
|
|
4
|
+
extend Forwardable
|
|
5
|
+
|
|
6
|
+
def_delegators :RailsMcpServer, :log, :projects
|
|
7
|
+
def_delegators :RailsMcpServer, :current_project, :active_project_path
|
|
8
|
+
|
|
9
|
+
def call(**params)
|
|
10
|
+
raise NotImplementedError, "Subclasses must implement #call"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def execute_rails_runner(script)
|
|
16
|
+
require "tempfile"
|
|
17
|
+
|
|
18
|
+
Tempfile.create(["analyzer", ".rb"]) do |f|
|
|
19
|
+
f.write(script)
|
|
20
|
+
f.flush
|
|
21
|
+
|
|
22
|
+
RailsMcpServer::RunProcess.execute_rails_command(
|
|
23
|
+
active_project_path,
|
|
24
|
+
"bin/rails runner #{f.path} 2>/dev/null"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def camelize(string)
|
|
30
|
+
string.split("_").map(&:capitalize).join
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def underscore(string)
|
|
34
|
+
string.gsub("::", "/")
|
|
35
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
36
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
37
|
+
.tr("-", "_")
|
|
38
|
+
.downcase
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|