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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +168 -166
  3. data/docs/AGENT.md +345 -0
  4. data/exe/rails-mcp-config +1411 -0
  5. data/exe/rails-mcp-server +23 -10
  6. data/exe/rails-mcp-setup-claude +1 -1
  7. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
  8. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
  10. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
  11. data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
  12. data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
  13. data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
  14. data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
  15. data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
  16. data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
  17. data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
  18. data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
  19. data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
  20. data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
  21. data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
  22. data/lib/rails-mcp-server/version.rb +1 -1
  23. data/lib/rails_mcp_server.rb +19 -53
  24. metadata +65 -18
  25. data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
  26. data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
  27. data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
  28. data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
  29. data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
  30. data/lib/rails-mcp-server/tools/get_file.rb +0 -55
  31. data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
  32. data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
  33. data/lib/rails-mcp-server/tools/list_files.rb +0 -54
  34. data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
  35. 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
- # Create tools configuration for both modes
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(RailsMcpServer::SwitchProject, RailsMcpServer::ProjectInfo,
78
- RailsMcpServer::ListFiles, RailsMcpServer::GetFile, RailsMcpServer::GetRoutes, RailsMcpServer::AnalyzeModels,
79
- RailsMcpServer::GetSchema, RailsMcpServer::AnalyzeControllerViews, RailsMcpServer::AnalyzeEnvironmentConfig,
80
- RailsMcpServer::LoadGuide)
81
-
82
- server.register_resources(RailsMcpServer::RailsGuidesResource, RailsMcpServer::RailsGuidesResources,
83
- RailsMcpServer::StimulusGuidesResource, RailsMcpServer::StimulusGuidesResources, RailsMcpServer::TurboGuidesResource,
84
- RailsMcpServer::TurboGuidesResources, RailsMcpServer::CustomGuidesResource, RailsMcpServer::CustomGuidesResources,
85
- RailsMcpServer::KamalGuidesResource, RailsMcpServer::KamalGuidesResources)
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
@@ -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