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.
Files changed (122) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +83 -0
  4. data/LICENSE +21 -0
  5. data/README.ja.md +383 -0
  6. data/README.md +384 -0
  7. data/examples/01_simple_bug.rb +43 -0
  8. data/examples/02_data_pipeline.rb +93 -0
  9. data/examples/03_recursion.rb +96 -0
  10. data/examples/RAILS_SCENARIOS.md +350 -0
  11. data/examples/SCENARIOS.md +142 -0
  12. data/examples/rails_test_app/setup.sh +428 -0
  13. data/examples/rails_test_app/testapp/.dockerignore +10 -0
  14. data/examples/rails_test_app/testapp/.ruby-version +1 -0
  15. data/examples/rails_test_app/testapp/Dockerfile +23 -0
  16. data/examples/rails_test_app/testapp/Gemfile +17 -0
  17. data/examples/rails_test_app/testapp/README.md +65 -0
  18. data/examples/rails_test_app/testapp/Rakefile +6 -0
  19. data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
  20. data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
  21. data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
  22. data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
  23. data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
  24. data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
  25. data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
  26. data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
  27. data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
  28. data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
  29. data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
  30. data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
  31. data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
  32. data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
  33. data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
  34. data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
  35. data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
  36. data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
  37. data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
  38. data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
  39. data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
  40. data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
  41. data/examples/rails_test_app/testapp/bin/ci +6 -0
  42. data/examples/rails_test_app/testapp/bin/dev +2 -0
  43. data/examples/rails_test_app/testapp/bin/rails +4 -0
  44. data/examples/rails_test_app/testapp/bin/rake +4 -0
  45. data/examples/rails_test_app/testapp/bin/setup +35 -0
  46. data/examples/rails_test_app/testapp/config/application.rb +42 -0
  47. data/examples/rails_test_app/testapp/config/boot.rb +3 -0
  48. data/examples/rails_test_app/testapp/config/ci.rb +14 -0
  49. data/examples/rails_test_app/testapp/config/database.yml +32 -0
  50. data/examples/rails_test_app/testapp/config/environment.rb +5 -0
  51. data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
  52. data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
  53. data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
  54. data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
  55. data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
  56. data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
  57. data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
  58. data/examples/rails_test_app/testapp/config/puma.rb +39 -0
  59. data/examples/rails_test_app/testapp/config/routes.rb +34 -0
  60. data/examples/rails_test_app/testapp/config.ru +6 -0
  61. data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
  62. data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
  63. data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
  64. data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
  65. data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
  66. data/examples/rails_test_app/testapp/db/schema.rb +71 -0
  67. data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
  68. data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
  69. data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
  70. data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
  71. data/examples/rails_test_app/testapp/log/.keep +0 -0
  72. data/examples/rails_test_app/testapp/public/400.html +135 -0
  73. data/examples/rails_test_app/testapp/public/404.html +135 -0
  74. data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
  75. data/examples/rails_test_app/testapp/public/422.html +135 -0
  76. data/examples/rails_test_app/testapp/public/500.html +135 -0
  77. data/examples/rails_test_app/testapp/public/icon.png +0 -0
  78. data/examples/rails_test_app/testapp/public/icon.svg +3 -0
  79. data/examples/rails_test_app/testapp/public/robots.txt +1 -0
  80. data/examples/rails_test_app/testapp/script/.keep +0 -0
  81. data/examples/rails_test_app/testapp/storage/.keep +0 -0
  82. data/examples/rails_test_app/testapp/tmp/.keep +0 -0
  83. data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
  84. data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
  85. data/examples/rails_test_app/testapp/vendor/.keep +0 -0
  86. data/exe/debug-mcp +39 -0
  87. data/exe/debug-rails +127 -0
  88. data/lib/debug_mcp/client_cleanup.rb +102 -0
  89. data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
  90. data/lib/debug_mcp/debug_client.rb +1143 -0
  91. data/lib/debug_mcp/exit_message_builder.rb +112 -0
  92. data/lib/debug_mcp/pending_http_helper.rb +25 -0
  93. data/lib/debug_mcp/rails_helper.rb +155 -0
  94. data/lib/debug_mcp/server.rb +364 -0
  95. data/lib/debug_mcp/session_manager.rb +436 -0
  96. data/lib/debug_mcp/stop_event_annotator.rb +152 -0
  97. data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
  98. data/lib/debug_mcp/tools/connect.rb +669 -0
  99. data/lib/debug_mcp/tools/continue_execution.rb +161 -0
  100. data/lib/debug_mcp/tools/disconnect.rb +169 -0
  101. data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
  102. data/lib/debug_mcp/tools/finish.rb +84 -0
  103. data/lib/debug_mcp/tools/get_context.rb +217 -0
  104. data/lib/debug_mcp/tools/get_source.rb +193 -0
  105. data/lib/debug_mcp/tools/inspect_object.rb +107 -0
  106. data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
  107. data/lib/debug_mcp/tools/list_files.rb +189 -0
  108. data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
  109. data/lib/debug_mcp/tools/next.rb +70 -0
  110. data/lib/debug_mcp/tools/rails_info.rb +200 -0
  111. data/lib/debug_mcp/tools/rails_model.rb +362 -0
  112. data/lib/debug_mcp/tools/rails_routes.rb +186 -0
  113. data/lib/debug_mcp/tools/read_file.rb +214 -0
  114. data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
  115. data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
  116. data/lib/debug_mcp/tools/run_script.rb +293 -0
  117. data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
  118. data/lib/debug_mcp/tools/step.rb +67 -0
  119. data/lib/debug_mcp/tools/trigger_request.rb +515 -0
  120. data/lib/debug_mcp/version.rb +5 -0
  121. data/lib/debug_mcp.rb +40 -0
  122. 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