debug-mcp 0.1.2
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 +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/README.ja.md +383 -0
- data/README.md +384 -0
- data/examples/01_simple_bug.rb +43 -0
- data/examples/02_data_pipeline.rb +93 -0
- data/examples/03_recursion.rb +96 -0
- data/examples/RAILS_SCENARIOS.md +350 -0
- data/examples/SCENARIOS.md +142 -0
- data/examples/rails_test_app/setup.sh +428 -0
- data/examples/rails_test_app/testapp/.dockerignore +10 -0
- data/examples/rails_test_app/testapp/.ruby-version +1 -0
- data/examples/rails_test_app/testapp/Dockerfile +23 -0
- data/examples/rails_test_app/testapp/Gemfile +17 -0
- data/examples/rails_test_app/testapp/README.md +65 -0
- data/examples/rails_test_app/testapp/Rakefile +6 -0
- data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
- data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
- data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
- data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
- data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
- data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
- data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
- data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
- data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
- data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
- data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
- data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
- data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
- data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
- data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
- data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
- data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
- data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
- data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
- data/examples/rails_test_app/testapp/bin/ci +6 -0
- data/examples/rails_test_app/testapp/bin/dev +2 -0
- data/examples/rails_test_app/testapp/bin/rails +4 -0
- data/examples/rails_test_app/testapp/bin/rake +4 -0
- data/examples/rails_test_app/testapp/bin/setup +35 -0
- data/examples/rails_test_app/testapp/config/application.rb +42 -0
- data/examples/rails_test_app/testapp/config/boot.rb +3 -0
- data/examples/rails_test_app/testapp/config/ci.rb +14 -0
- data/examples/rails_test_app/testapp/config/database.yml +32 -0
- data/examples/rails_test_app/testapp/config/environment.rb +5 -0
- data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
- data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
- data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
- data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
- data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
- data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
- data/examples/rails_test_app/testapp/config/puma.rb +39 -0
- data/examples/rails_test_app/testapp/config/routes.rb +34 -0
- data/examples/rails_test_app/testapp/config.ru +6 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
- data/examples/rails_test_app/testapp/db/schema.rb +71 -0
- data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
- data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
- data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
- data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
- data/examples/rails_test_app/testapp/log/.keep +0 -0
- data/examples/rails_test_app/testapp/public/400.html +135 -0
- data/examples/rails_test_app/testapp/public/404.html +135 -0
- data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
- data/examples/rails_test_app/testapp/public/422.html +135 -0
- data/examples/rails_test_app/testapp/public/500.html +135 -0
- data/examples/rails_test_app/testapp/public/icon.png +0 -0
- data/examples/rails_test_app/testapp/public/icon.svg +3 -0
- data/examples/rails_test_app/testapp/public/robots.txt +1 -0
- data/examples/rails_test_app/testapp/script/.keep +0 -0
- data/examples/rails_test_app/testapp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/vendor/.keep +0 -0
- data/exe/debug-mcp +39 -0
- data/exe/debug-rails +127 -0
- data/lib/debug_mcp/client_cleanup.rb +102 -0
- data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
- data/lib/debug_mcp/debug_client.rb +1143 -0
- data/lib/debug_mcp/exit_message_builder.rb +112 -0
- data/lib/debug_mcp/pending_http_helper.rb +25 -0
- data/lib/debug_mcp/rails_helper.rb +155 -0
- data/lib/debug_mcp/server.rb +364 -0
- data/lib/debug_mcp/session_manager.rb +436 -0
- data/lib/debug_mcp/stop_event_annotator.rb +152 -0
- data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
- data/lib/debug_mcp/tools/connect.rb +669 -0
- data/lib/debug_mcp/tools/continue_execution.rb +161 -0
- data/lib/debug_mcp/tools/disconnect.rb +169 -0
- data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
- data/lib/debug_mcp/tools/finish.rb +84 -0
- data/lib/debug_mcp/tools/get_context.rb +217 -0
- data/lib/debug_mcp/tools/get_source.rb +193 -0
- data/lib/debug_mcp/tools/inspect_object.rb +107 -0
- data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
- data/lib/debug_mcp/tools/list_files.rb +189 -0
- data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
- data/lib/debug_mcp/tools/next.rb +70 -0
- data/lib/debug_mcp/tools/rails_info.rb +200 -0
- data/lib/debug_mcp/tools/rails_model.rb +362 -0
- data/lib/debug_mcp/tools/rails_routes.rb +186 -0
- data/lib/debug_mcp/tools/read_file.rb +214 -0
- data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
- data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
- data/lib/debug_mcp/tools/run_script.rb +293 -0
- data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
- data/lib/debug_mcp/tools/step.rb +67 -0
- data/lib/debug_mcp/tools/trigger_request.rb +515 -0
- data/lib/debug_mcp/version.rb +5 -0
- data/lib/debug_mcp.rb +40 -0
- metadata +251 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../rails_helper"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class RailsModel < MCP::Tool
|
|
9
|
+
description "[Investigation] Show ActiveRecord model structure: table name, columns (with types), " \
|
|
10
|
+
"associations, validations, enums, and scopes. " \
|
|
11
|
+
"Omit model_name to list all model files in the application. " \
|
|
12
|
+
"Use this to understand a model's schema and relationships during debugging."
|
|
13
|
+
|
|
14
|
+
annotations(
|
|
15
|
+
title: "Rails Model Info",
|
|
16
|
+
read_only_hint: true,
|
|
17
|
+
destructive_hint: false,
|
|
18
|
+
open_world_hint: false,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
input_schema(
|
|
22
|
+
properties: {
|
|
23
|
+
model_name: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Model class name (e.g., 'User', 'Order', 'Admin::Account'). " \
|
|
26
|
+
"Omit to list all available models.",
|
|
27
|
+
},
|
|
28
|
+
session_id: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def call(model_name: nil, session_id: nil, server_context:)
|
|
37
|
+
client = server_context[:session_manager].client(session_id)
|
|
38
|
+
client.auto_repause!
|
|
39
|
+
RailsHelper.require_rails!(client)
|
|
40
|
+
|
|
41
|
+
# List models when model_name is omitted
|
|
42
|
+
return list_models(client) unless model_name
|
|
43
|
+
|
|
44
|
+
# Verify model exists and is an ActiveRecord model.
|
|
45
|
+
# Uses a single rescue-wrapped expression to distinguish:
|
|
46
|
+
# "ar" — confirmed ActiveRecord model
|
|
47
|
+
# "not_ar" — constant exists but not AR
|
|
48
|
+
# "undefined" — constant not defined (autoloading may have failed)
|
|
49
|
+
# "err:Class" — evaluation raised (ThreadError in trap context, etc.)
|
|
50
|
+
verify_result = verify_ar_model(client, model_name)
|
|
51
|
+
return verify_result if verify_result.is_a?(MCP::Tool::Response)
|
|
52
|
+
|
|
53
|
+
parts = []
|
|
54
|
+
|
|
55
|
+
# Header with table name
|
|
56
|
+
table_name = eval_expr(client, "#{model_name}.table_name")
|
|
57
|
+
parts << "=== #{model_name} (table: #{table_name || "unknown"}) ==="
|
|
58
|
+
|
|
59
|
+
# Columns
|
|
60
|
+
parts << build_columns_section(client, model_name)
|
|
61
|
+
|
|
62
|
+
# Associations
|
|
63
|
+
section = build_associations_section(client, model_name)
|
|
64
|
+
parts << section if section
|
|
65
|
+
|
|
66
|
+
# Validations
|
|
67
|
+
section = build_validations_section(client, model_name)
|
|
68
|
+
parts << section if section
|
|
69
|
+
|
|
70
|
+
# Enums
|
|
71
|
+
section = build_enums_section(client, model_name)
|
|
72
|
+
parts << section if section
|
|
73
|
+
|
|
74
|
+
# Scopes
|
|
75
|
+
section = build_scopes_section(client, model_name)
|
|
76
|
+
parts << section if section
|
|
77
|
+
|
|
78
|
+
# Callbacks
|
|
79
|
+
section = build_callbacks_section(client, model_name)
|
|
80
|
+
parts << section if section
|
|
81
|
+
|
|
82
|
+
text = parts.compact.join("\n\n")
|
|
83
|
+
|
|
84
|
+
# If columns section shows an error, it likely failed due to trap context
|
|
85
|
+
if text.include?("unable to retrieve") || text.include?("Error:")
|
|
86
|
+
text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if RailsHelper.trap_context?(client)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
90
|
+
rescue DebugMcp::Error => e
|
|
91
|
+
text = "Error: #{e.message}"
|
|
92
|
+
text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if begin
|
|
93
|
+
RailsHelper.trap_context?(client)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def list_models(client)
|
|
103
|
+
models = RailsHelper.model_files(client)
|
|
104
|
+
|
|
105
|
+
if models && models.any?
|
|
106
|
+
text = "Models in app/models/ (#{models.size} files):\n"
|
|
107
|
+
models.each do |m|
|
|
108
|
+
# Convert file path to likely class name: "user" → "User", "admin/account" → "Admin::Account"
|
|
109
|
+
class_name = m.split("/").map { |p| p.split("_").map(&:capitalize).join }.join("::")
|
|
110
|
+
text += " #{class_name} (#{m}.rb)\n"
|
|
111
|
+
end
|
|
112
|
+
text += "\nUse rails_model(model_name: \"ModelName\") to see details for a specific model."
|
|
113
|
+
else
|
|
114
|
+
text = "No model files found in app/models/."
|
|
115
|
+
text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if RailsHelper.trap_context?(client)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Verify model is an ActiveRecord model using a single rescue-wrapped expression.
|
|
122
|
+
# Returns nil on success (proceed with inspection), or an error Response.
|
|
123
|
+
def verify_ar_model(client, model_name)
|
|
124
|
+
# Single expression with rescue — captures the ACTUAL error class
|
|
125
|
+
# instead of relying on external trap context detection.
|
|
126
|
+
status = eval_expr(client,
|
|
127
|
+
"begin; d = defined?(#{model_name}); " \
|
|
128
|
+
"unless d; 'undefined'; else; " \
|
|
129
|
+
"#{model_name} < ActiveRecord::Base ? 'ar' : 'not_ar'; end; " \
|
|
130
|
+
"rescue => e; 'err:' + e.class.to_s; end")
|
|
131
|
+
|
|
132
|
+
case status
|
|
133
|
+
when "ar"
|
|
134
|
+
nil # Verified, proceed
|
|
135
|
+
when "not_ar"
|
|
136
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
137
|
+
text: "Error: #{model_name} is not an ActiveRecord model." }])
|
|
138
|
+
when /\Aerr:/
|
|
139
|
+
# Evaluation raised an exception — ThreadError (trap context),
|
|
140
|
+
# NameError (autoloading failed), etc.
|
|
141
|
+
error_class = status.sub("err:", "")
|
|
142
|
+
hint = if error_class == "ThreadError"
|
|
143
|
+
"In signal trap context, model inspection requires DB connections and " \
|
|
144
|
+
"autoloading, which need thread operations (Mutex/Thread). " \
|
|
145
|
+
"Tools like rails_routes work because they use file I/O only.\n\n" \
|
|
146
|
+
"#{RailsHelper::TRAP_CONTEXT_HINT}"
|
|
147
|
+
else
|
|
148
|
+
"The process may be in a restricted context where model " \
|
|
149
|
+
"autoloading or class verification cannot run.\n\n" \
|
|
150
|
+
"#{RailsHelper::TRAP_CONTEXT_HINT}"
|
|
151
|
+
end
|
|
152
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
153
|
+
text: "Error: Unable to inspect #{model_name} (#{error_class}). #{hint}" }])
|
|
154
|
+
when "undefined"
|
|
155
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
156
|
+
text: "Error: #{model_name} is not defined. " \
|
|
157
|
+
"The model may not be loaded yet (autoloading may be restricted " \
|
|
158
|
+
"in the current context).\n\n" \
|
|
159
|
+
"#{RailsHelper::TRAP_CONTEXT_HINT}" }])
|
|
160
|
+
else
|
|
161
|
+
# eval_expr returned nil — complete evaluation failure
|
|
162
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
163
|
+
text: "Error: Unable to verify #{model_name}. " \
|
|
164
|
+
"Model verification failed (possible restricted context).\n\n" \
|
|
165
|
+
"#{RailsHelper::TRAP_CONTEXT_HINT}" }])
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def build_columns_section(client, model_name)
|
|
170
|
+
result = RailsHelper.run_base64_script(client, build_columns_script(model_name))
|
|
171
|
+
result || "Columns:\n (unable to retrieve)"
|
|
172
|
+
rescue DebugMcp::Error
|
|
173
|
+
"Columns:\n (unable to retrieve)"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_associations_section(client, model_name)
|
|
177
|
+
RailsHelper.run_base64_script(client, build_associations_script(model_name))
|
|
178
|
+
rescue DebugMcp::Error
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_validations_section(client, model_name)
|
|
183
|
+
RailsHelper.run_base64_script(client, build_validations_script(model_name))
|
|
184
|
+
rescue DebugMcp::Error
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_enums_section(client, model_name)
|
|
189
|
+
RailsHelper.run_base64_script(client, build_enums_script(model_name))
|
|
190
|
+
rescue DebugMcp::Error
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def build_callbacks_section(client, model_name)
|
|
195
|
+
RailsHelper.run_base64_script(client, build_callbacks_script(model_name))
|
|
196
|
+
rescue DebugMcp::Error
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build_scopes_section(client, model_name)
|
|
201
|
+
RailsHelper.run_base64_script(client, build_scopes_script(model_name))
|
|
202
|
+
rescue DebugMcp::Error
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def eval_expr(client, expr)
|
|
207
|
+
RailsHelper.eval_expr(client, expr)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_columns_script(model_name)
|
|
211
|
+
<<~RUBY
|
|
212
|
+
begin
|
|
213
|
+
cols = #{model_name}.columns
|
|
214
|
+
pk = #{model_name}.primary_key
|
|
215
|
+
lines = ["Columns:"]
|
|
216
|
+
name_width = [cols.map { |c| c.name.length }.max || 0, 4].max
|
|
217
|
+
type_width = [cols.map { |c| c.type.to_s.length }.max || 0, 4].max
|
|
218
|
+
cols.each do |c|
|
|
219
|
+
extras = []
|
|
220
|
+
extras << "NOT NULL" unless c.null
|
|
221
|
+
extras << "PK" if c.name == pk
|
|
222
|
+
extras << "default: " + c.default.inspect unless c.default.nil?
|
|
223
|
+
extra_str = extras.empty? ? "" : " " + extras.join(" ")
|
|
224
|
+
lines << " " + c.name.ljust(name_width) + " " + c.type.to_s.ljust(type_width) + extra_str
|
|
225
|
+
end
|
|
226
|
+
lines.join("\\n")
|
|
227
|
+
rescue => e
|
|
228
|
+
"Columns:\\n Error: " + e.message
|
|
229
|
+
end
|
|
230
|
+
RUBY
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def build_associations_script(model_name)
|
|
234
|
+
<<~RUBY
|
|
235
|
+
begin
|
|
236
|
+
assocs = #{model_name}.reflect_on_all_associations
|
|
237
|
+
if assocs.empty?
|
|
238
|
+
nil
|
|
239
|
+
else
|
|
240
|
+
lines = ["Associations:"]
|
|
241
|
+
macro_width = [assocs.map { |a| a.macro.to_s.length }.max, 10].max
|
|
242
|
+
name_width = [assocs.map { |a| a.name.to_s.length + 1 }.max, 4].max
|
|
243
|
+
assocs.each do |a|
|
|
244
|
+
class_name = begin; a.klass.name; rescue => e; a.options[:class_name] || a.name.to_s.classify; end
|
|
245
|
+
lines << " " + a.macro.to_s.ljust(macro_width) + " :" + a.name.to_s.ljust(name_width) + " -> " + class_name
|
|
246
|
+
end
|
|
247
|
+
lines.join("\\n")
|
|
248
|
+
end
|
|
249
|
+
rescue => e
|
|
250
|
+
"Associations:\\n Error: " + e.message
|
|
251
|
+
end
|
|
252
|
+
RUBY
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def build_validations_script(model_name)
|
|
256
|
+
<<~RUBY
|
|
257
|
+
begin
|
|
258
|
+
validators = #{model_name}.validators
|
|
259
|
+
if validators.empty?
|
|
260
|
+
nil
|
|
261
|
+
else
|
|
262
|
+
lines = ["Validations:"]
|
|
263
|
+
grouped = {}
|
|
264
|
+
validators.each do |v|
|
|
265
|
+
kind = v.kind.to_s
|
|
266
|
+
attrs = v.attributes.map(&:to_s)
|
|
267
|
+
grouped[kind] ||= []
|
|
268
|
+
grouped[kind].concat(attrs)
|
|
269
|
+
end
|
|
270
|
+
grouped.each do |kind, attrs|
|
|
271
|
+
lines << " " + kind.ljust(14) + " [:" + attrs.uniq.join(", :") + "]"
|
|
272
|
+
end
|
|
273
|
+
lines.join("\\n")
|
|
274
|
+
end
|
|
275
|
+
rescue => e
|
|
276
|
+
"Validations:\\n Error: " + e.message
|
|
277
|
+
end
|
|
278
|
+
RUBY
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def build_enums_script(model_name)
|
|
282
|
+
<<~RUBY
|
|
283
|
+
begin
|
|
284
|
+
if #{model_name}.respond_to?(:defined_enums)
|
|
285
|
+
enums = #{model_name}.defined_enums
|
|
286
|
+
if enums.empty?
|
|
287
|
+
nil
|
|
288
|
+
else
|
|
289
|
+
lines = ["Enums:"]
|
|
290
|
+
enums.each do |name, mapping|
|
|
291
|
+
lines << " " + name + ": { " + mapping.map { |k, v| k.to_s + ": " + v.to_s }.join(", ") + " }"
|
|
292
|
+
end
|
|
293
|
+
lines.join("\\n")
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
rescue => e
|
|
297
|
+
"Enums:\\n Error: " + e.message
|
|
298
|
+
end
|
|
299
|
+
RUBY
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def build_scopes_script(model_name)
|
|
303
|
+
<<~RUBY
|
|
304
|
+
begin
|
|
305
|
+
if #{model_name}.respond_to?(:scope_names)
|
|
306
|
+
scope_list = #{model_name}.scope_names
|
|
307
|
+
else
|
|
308
|
+
# Fallback: detect scope methods by comparing with ActiveRecord::Base
|
|
309
|
+
base_methods = ActiveRecord::Base.methods
|
|
310
|
+
model_methods = #{model_name}.methods - base_methods
|
|
311
|
+
# Scopes return ActiveRecord::Relation
|
|
312
|
+
scope_list = model_methods.select do |m|
|
|
313
|
+
begin
|
|
314
|
+
#{model_name}.method(m).owner != Class && #{model_name}.method(m).arity <= 0
|
|
315
|
+
rescue
|
|
316
|
+
false
|
|
317
|
+
end
|
|
318
|
+
end.sort
|
|
319
|
+
end
|
|
320
|
+
if scope_list && !scope_list.empty?
|
|
321
|
+
"Scopes:\\n " + scope_list.map(&:to_s).join(", ")
|
|
322
|
+
end
|
|
323
|
+
rescue => e
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
RUBY
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def build_callbacks_script(model_name)
|
|
330
|
+
<<~RUBY
|
|
331
|
+
begin
|
|
332
|
+
callback_types = %w[save create update destroy validate]
|
|
333
|
+
sections = []
|
|
334
|
+
callback_types.each do |type|
|
|
335
|
+
method_name = "_\#{type}_callbacks"
|
|
336
|
+
next unless #{model_name}.respond_to?(method_name)
|
|
337
|
+
chain = #{model_name}.public_send(method_name)
|
|
338
|
+
entries = []
|
|
339
|
+
chain.each do |cb|
|
|
340
|
+
filter = cb.filter
|
|
341
|
+
next unless filter.is_a?(Symbol)
|
|
342
|
+
entries << [cb.kind.to_s, filter.to_s]
|
|
343
|
+
end
|
|
344
|
+
unless entries.empty?
|
|
345
|
+
lines = entries.map { |kind, filter| " " + kind.ljust(7) + " :" + filter }
|
|
346
|
+
sections << " " + type + ":\\n" + lines.join("\\n")
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
if sections.empty?
|
|
350
|
+
nil
|
|
351
|
+
else
|
|
352
|
+
"Callbacks:\\n" + sections.join("\\n")
|
|
353
|
+
end
|
|
354
|
+
rescue => e
|
|
355
|
+
nil
|
|
356
|
+
end
|
|
357
|
+
RUBY
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../rails_helper"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class RailsRoutes < MCP::Tool
|
|
9
|
+
description "[Investigation] Show Rails application routes. " \
|
|
10
|
+
"Displays HTTP verb, path, controller#action, and route name. " \
|
|
11
|
+
"Can filter by controller name or path pattern. " \
|
|
12
|
+
"Works in trap context (lightweight mode)."
|
|
13
|
+
|
|
14
|
+
annotations(
|
|
15
|
+
title: "Rails Routes",
|
|
16
|
+
read_only_hint: true,
|
|
17
|
+
destructive_hint: false,
|
|
18
|
+
open_world_hint: false,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
input_schema(
|
|
22
|
+
properties: {
|
|
23
|
+
controller: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Filter by controller name (e.g., 'users', 'api/v1/orders')",
|
|
26
|
+
},
|
|
27
|
+
path: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Filter by path pattern (partial match, e.g., '/users')",
|
|
30
|
+
},
|
|
31
|
+
session_id: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def call(controller: nil, path: nil, session_id: nil, server_context:)
|
|
40
|
+
client = server_context[:session_manager].client(session_id)
|
|
41
|
+
client.auto_repause!
|
|
42
|
+
RailsHelper.require_rails!(client)
|
|
43
|
+
|
|
44
|
+
# Try Base64 script first (better formatting with aligned columns)
|
|
45
|
+
text = fetch_routes_base64(client, controller, path)
|
|
46
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }]) if text
|
|
47
|
+
|
|
48
|
+
# Fall back to lightweight approach (works in trap context)
|
|
49
|
+
text = fetch_routes_lightweight(client, controller, path)
|
|
50
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }]) if text
|
|
51
|
+
|
|
52
|
+
# Both failed — show clear unavailable message
|
|
53
|
+
text = "Routes: unavailable."
|
|
54
|
+
text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if RailsHelper.trap_context?(client)
|
|
55
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
56
|
+
rescue DebugMcp::Error => e
|
|
57
|
+
text = "Error: #{e.message}"
|
|
58
|
+
text += "\n\n#{RailsHelper::TRAP_CONTEXT_HINT}" if begin
|
|
59
|
+
RailsHelper.trap_context?(client)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Full Base64 script approach — better formatting but may fail in trap context
|
|
69
|
+
# because `require 'base64'` or `puts` may not work.
|
|
70
|
+
def fetch_routes_base64(client, controller, path)
|
|
71
|
+
result = RailsHelper.run_base64_script(client, build_routes_script(controller, path), timeout: 30)
|
|
72
|
+
return nil unless result
|
|
73
|
+
return nil if result.include?("Error loading routes:")
|
|
74
|
+
|
|
75
|
+
result
|
|
76
|
+
rescue DebugMcp::Error
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Lightweight approach using `p` expression — works in trap context.
|
|
81
|
+
# Uses expression return values (captured by debug gem) instead of `puts`.
|
|
82
|
+
def fetch_routes_lightweight(client, controller, path)
|
|
83
|
+
result = RailsHelper.lightweight_routes(client, controller: controller, path: path)
|
|
84
|
+
return nil unless result
|
|
85
|
+
|
|
86
|
+
lines = result[:lines]
|
|
87
|
+
count = result[:count]
|
|
88
|
+
|
|
89
|
+
if lines.empty?
|
|
90
|
+
filter_desc = build_filter_description(controller, path)
|
|
91
|
+
if filter_desc
|
|
92
|
+
"No routes found matching #{filter_desc}.\n\nTotal routes in app: #{count}"
|
|
93
|
+
else
|
|
94
|
+
"No routes found."
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
text = ""
|
|
98
|
+
filter_desc = build_filter_description(controller, path)
|
|
99
|
+
text += filter_desc ? "Routes (filtered by #{filter_desc}):\n" : "Routes:\n"
|
|
100
|
+
lines.each_line { |line| text += " #{line}" }
|
|
101
|
+
shown = lines.count("\n") + 1
|
|
102
|
+
text += "\nTotal: #{count} routes"
|
|
103
|
+
text += " (showing #{shown})" if shown < count
|
|
104
|
+
text
|
|
105
|
+
end
|
|
106
|
+
rescue DebugMcp::Error
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_filter_description(controller, path)
|
|
111
|
+
parts = []
|
|
112
|
+
parts << "controller: \"#{controller}\"" if controller
|
|
113
|
+
parts << "path: \"#{path}\"" if path
|
|
114
|
+
parts.empty? ? nil : parts.join(", ")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Base64 script that RETURNS a value instead of using puts.
|
|
118
|
+
# In trap context, puts output is not captured by the debug gem,
|
|
119
|
+
# but expression return values are always captured.
|
|
120
|
+
def build_routes_script(controller, path)
|
|
121
|
+
<<~RUBY
|
|
122
|
+
begin
|
|
123
|
+
routes = Rails.application.routes.routes
|
|
124
|
+
controller_filter = #{controller&.to_s.inspect}
|
|
125
|
+
path_filter = #{path&.to_s.inspect}
|
|
126
|
+
|
|
127
|
+
results = []
|
|
128
|
+
routes.each do |route|
|
|
129
|
+
defaults = route.defaults
|
|
130
|
+
ctrl = defaults[:controller].to_s
|
|
131
|
+
action = defaults[:action].to_s
|
|
132
|
+
next if ctrl.empty? && action.empty?
|
|
133
|
+
|
|
134
|
+
route_path = route.path.spec.to_s.sub('(.:format)', '')
|
|
135
|
+
verb = route.verb.to_s
|
|
136
|
+
verb = "ANY" if verb.empty?
|
|
137
|
+
name = route.name.to_s
|
|
138
|
+
|
|
139
|
+
if controller_filter
|
|
140
|
+
next unless ctrl.include?(controller_filter)
|
|
141
|
+
end
|
|
142
|
+
if path_filter
|
|
143
|
+
next unless route_path.include?(path_filter)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
results << { verb: verb, path: route_path, controller: ctrl, action: action, name: name }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if results.empty?
|
|
150
|
+
filter_desc = []
|
|
151
|
+
filter_desc << "controller: \\\"" + controller_filter + "\\\"" if controller_filter
|
|
152
|
+
filter_desc << "path: \\\"" + path_filter + "\\\"" if path_filter
|
|
153
|
+
if filter_desc.empty?
|
|
154
|
+
"No routes found."
|
|
155
|
+
else
|
|
156
|
+
"No routes found matching " + filter_desc.join(", ") + "."
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
lines = []
|
|
160
|
+
filter_desc = []
|
|
161
|
+
filter_desc << "controller: \\\"" + controller_filter + "\\\"" if controller_filter
|
|
162
|
+
filter_desc << "path: \\\"" + path_filter + "\\\"" if path_filter
|
|
163
|
+
header = filter_desc.empty? ? "Routes:" : "Routes (filtered by " + filter_desc.join(", ") + "):"
|
|
164
|
+
lines << header
|
|
165
|
+
|
|
166
|
+
verb_width = [results.map { |r| r[:verb].length }.max, 6].max
|
|
167
|
+
path_width = [results.map { |r| r[:path].length }.max, 4].max
|
|
168
|
+
|
|
169
|
+
results.each do |r|
|
|
170
|
+
name_part = r[:name].empty? ? "" : " (" + r[:name] + ")"
|
|
171
|
+
lines << " " + r[:verb].ljust(verb_width) + " " + r[:path].ljust(path_width) + " " + r[:controller] + "#" + r[:action] + name_part
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Total: " + results.length.to_s + " routes"
|
|
176
|
+
lines.join("\\n")
|
|
177
|
+
end
|
|
178
|
+
rescue => e
|
|
179
|
+
"Error loading routes: " + e.class.to_s + ": " + e.message
|
|
180
|
+
end
|
|
181
|
+
RUBY
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|