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,669 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "set"
5
+ require "net/http"
6
+ require "uri"
7
+ require_relative "../rails_helper"
8
+
9
+ module DebugMcp
10
+ module Tools
11
+ class Connect < MCP::Tool
12
+ description "[Entry Point] Connect to an already-running Ruby debug session " \
13
+ "(e.g., a Rails server or background process started with 'rdbg --open'). " \
14
+ "For debugging scripts, prefer 'run_script' which also captures stdout/stderr. " \
15
+ "If only one session exists, connects automatically. " \
16
+ "You can specify a TCP port (e.g., port: 12345) or a Unix socket path. " \
17
+ "After connecting, use 'get_context' to see the current state. " \
18
+ "Previous session breakpoints are NOT restored by default (use restore_breakpoints: true to restore). " \
19
+ "Note: stdout/stderr are NOT captured for connect sessions."
20
+
21
+ annotations(
22
+ title: "Connect to Debug Session",
23
+ read_only_hint: false,
24
+ destructive_hint: false,
25
+ open_world_hint: true,
26
+ )
27
+
28
+ input_schema(
29
+ properties: {
30
+ path: {
31
+ type: "string",
32
+ description: "Unix domain socket path (e.g., /tmp/rdbg-1000/rdbg-12345)",
33
+ },
34
+ host: {
35
+ type: "string",
36
+ description: "TCP host for the debug connection (default: localhost)",
37
+ },
38
+ port: {
39
+ type: "integer",
40
+ description: "TCP port for remote debug connection",
41
+ },
42
+ session_id: {
43
+ type: "string",
44
+ description: "Custom session ID for this connection (auto-generated if omitted)",
45
+ },
46
+ restore_breakpoints: {
47
+ type: "boolean",
48
+ description: "If true, restores breakpoints saved from previous sessions. " \
49
+ "Useful when reconnecting to debug the same code with identical breakpoints. " \
50
+ "Default: false (starts fresh without inheriting previous breakpoints).",
51
+ },
52
+ remote: {
53
+ type: "boolean",
54
+ description: "Set to true when the target process is in a different PID namespace " \
55
+ "(e.g., Docker container connected via Unix socket volume mount). " \
56
+ "Required because OS signals cannot cross PID namespaces — without this, " \
57
+ "pause/resume will fail. " \
58
+ "Not needed for TCP connections (auto-detected) or local Unix sockets. " \
59
+ "Default: auto-detect (TCP → true, Unix socket → false).",
60
+ },
61
+ auto_escape: {
62
+ type: "boolean",
63
+ description: "If false, skip automatic trap context escape. " \
64
+ "Default: true (automatically escape signal trap context when possible).",
65
+ },
66
+ force_reset: {
67
+ type: "boolean",
68
+ description: "If true, forces cleanup of any existing connection and uses a longer timeout. " \
69
+ "Use when a previous session left the debug gem stuck. Default: false.",
70
+ },
71
+ },
72
+ )
73
+
74
+ class << self
75
+ FORCE_RESET_CONNECT_TIMEOUT = 30
76
+
77
+ def call(path: nil, host: nil, port: nil, session_id: nil, remote: nil,
78
+ restore_breakpoints: nil, auto_escape: nil, force_reset: nil, server_context:)
79
+ manager = server_context[:session_manager]
80
+
81
+ # Force reset: clean up existing sessions aggressively before reconnecting
82
+ if force_reset
83
+ begin
84
+ existing_client = manager.client(session_id)
85
+ # Try auto_repause! (includes HTTP wake for remote) before resuming
86
+ unless existing_client.paused
87
+ begin
88
+ existing_client.auto_repause!
89
+ rescue DebugMcp::Error
90
+ # auto_repause failed — try HTTP wake + check_paused as last resort
91
+ # (auto_repause already sent the pause message — avoid sending more)
92
+ if existing_client.remote && existing_client.listen_ports&.any?
93
+ begin
94
+ existing_client.wake_io_blocked_process(existing_client.listen_ports.first)
95
+ sleep DebugClient::HTTP_WAKE_SETTLE_TIME
96
+ existing_client.check_paused(timeout: 5)
97
+ rescue DebugMcp::Error
98
+ # Best-effort
99
+ end
100
+ end
101
+ end
102
+ end
103
+ existing_client.send_command_no_wait("c", force: true) rescue nil
104
+ manager.disconnect(session_id)
105
+ sleep 1
106
+ rescue DebugMcp::Error
107
+ # No existing session or already disconnected
108
+ end
109
+ end
110
+
111
+ # Clear saved breakpoints unless explicitly restoring
112
+ manager.clear_breakpoint_specs unless restore_breakpoints
113
+
114
+ # Detect target PID and listen ports BEFORE connecting.
115
+ # When the process is IO-blocked (e.g., Puma in IO.select), the debug
116
+ # gem's SIGURG sets a pending pause flag but can't interrupt IO.select
117
+ # (SA_RESTART). An HTTP request wakes IO.select, causing the trace point
118
+ # to fire and the pending pause to execute.
119
+ pre_target_pid = resolve_target_pid(path, port)
120
+ pre_listen_ports = pre_target_pid ? detect_listen_ports(pre_target_pid) : []
121
+
122
+ woke = false
123
+ connect_timeout = if force_reset
124
+ FORCE_RESET_CONNECT_TIMEOUT
125
+ elsif pre_listen_ports.any?
126
+ 5
127
+ end
128
+
129
+ result = manager.connect(
130
+ session_id: session_id,
131
+ path: path,
132
+ host: host,
133
+ port: port,
134
+ remote: remote,
135
+ connect_timeout: connect_timeout,
136
+ pre_cleanup_pid: pre_target_pid,
137
+ pre_cleanup_port: port,
138
+ ) {
139
+ if pre_listen_ports.any?
140
+ woke = true
141
+ wake_process_via_http(pre_listen_ports)
142
+ end
143
+ }
144
+
145
+ client = manager.client(result[:session_id])
146
+
147
+ # Health check for force_reset: verify the session is responsive
148
+ if force_reset
149
+ begin
150
+ health = client.send_command("p :debug_mcp_health_check", timeout: 5)
151
+ unless health.include?("debug_mcp_health_check")
152
+ # Process is stuck — try to resume and let the caller reconnect
153
+ client.send_command_no_wait("c", force: true) rescue nil
154
+ manager.disconnect(result[:session_id])
155
+ raise ConnectionError, "Health check failed after force_reset. " \
156
+ "The process may need to be restarted."
157
+ end
158
+ rescue DebugMcp::TimeoutError
159
+ client.send_command_no_wait("c", force: true) rescue nil
160
+ manager.disconnect(result[:session_id])
161
+ raise ConnectionError, "Health check timed out after force_reset. " \
162
+ "The process may need to be restarted."
163
+ end
164
+ end
165
+
166
+ text = "Connected to debug session.\n" \
167
+ " Session ID: #{result[:session_id]}\n" \
168
+ " PID: #{result[:pid]}\n"
169
+
170
+ if woke
171
+ text += " Status: Woke IO-blocked process via HTTP (port #{pre_listen_ports.first})\n"
172
+ end
173
+
174
+ # Detect listen ports (useful for trigger_request URL)
175
+ listen_ports = detect_listen_ports(result[:pid])
176
+ # Docker/TCP: /proc-based detection fails due to PID namespace mismatch.
177
+ # Fall back to Docker inspect to find web server port mappings.
178
+ if listen_ports.empty? && client.remote && port
179
+ listen_ports = TcpSessionDiscovery.container_web_ports(port)
180
+ end
181
+ if listen_ports.any?
182
+ port_list = listen_ports.map { |p| "http://127.0.0.1:#{p}" }.join(", ")
183
+ text += " Listening on: #{port_list}\n"
184
+ end
185
+
186
+ # Check if this is a Rails process (needed for auto-escape and summary)
187
+ is_rails = RailsHelper.rails?(client)
188
+
189
+ # Compute route summary before escape (trap-safe, needed for auto-escape target)
190
+ route_info = is_rails ? RailsHelper.route_summary(client, limit: 5) : nil
191
+
192
+ # Detect and escape signal trap context (common with Puma/SIGURG).
193
+ # In trap context, Mutex/thread operations fail with ThreadError.
194
+ auto_escape_enabled = auto_escape != false
195
+ text += escape_trap_context(client,
196
+ listen_ports: listen_ports,
197
+ route_info: route_info,
198
+ auto_escape: auto_escape_enabled)
199
+
200
+ # Cache escape info for auto_repause! (re-escape after continue_execution)
201
+ client.listen_ports = listen_ports
202
+ if is_rails && listen_ports.any?
203
+ url_path = extract_get_path(route_info)
204
+ client.escape_target = find_target_from_framework(client, url_path)
205
+ end
206
+
207
+ # Install double Ctrl+C force-quit handler on the target process
208
+ install_sigint_handler(client)
209
+
210
+ # Clear existing breakpoints from previous sessions (unless restoring)
211
+ unless restore_breakpoints
212
+ clear_process_breakpoints(client)
213
+ end
214
+
215
+ escaped = text.include?("Auto-escaped signal trap context")
216
+
217
+ text += "\nIMPORTANT: The target process is now PAUSED. " \
218
+ "Use 'continue_execution' to resume it when done investigating, " \
219
+ "or 'disconnect' to detach (which also resumes the process).\n" \
220
+ "Note: stdout/stderr are not captured for 'connect' sessions " \
221
+ "(use 'run_script' for capture).\n\n" \
222
+ "Initial state:\n#{result[:output]}"
223
+
224
+ if is_rails
225
+ text += build_rails_summary(client, result[:output], listen_ports, route_info,
226
+ escaped: escaped)
227
+ end
228
+
229
+ # Restore breakpoints from previous sessions
230
+ restored = manager.restore_breakpoints(client)
231
+ if restored.any?
232
+ text += "\n\nRestored #{restored.size} breakpoint(s) from previous session:"
233
+ restored.each do |r|
234
+ text += if r[:error]
235
+ "\n #{r[:spec]} -> Error: #{r[:error]}"
236
+ else
237
+ "\n #{r[:spec]} -> #{r[:output]}"
238
+ end
239
+ end
240
+ end
241
+
242
+ MCP::Tool::Response.new([{ type: "text", text: text }])
243
+ rescue DebugMcp::Error => e
244
+ error_text = "Error: #{e.message}"
245
+ unless force_reset
246
+ if e.message.include?("timed out") || e.message.include?("stuck")
247
+ error_text += "\n\nTip: Try 'connect' with force_reset: true to force cleanup of any stuck session."
248
+ end
249
+ else
250
+ error_text += "\n\nThe process may need to be restarted (kill and re-launch with 'rdbg --open')."
251
+ end
252
+ MCP::Tool::Response.new([{ type: "text", text: error_text }])
253
+ end
254
+
255
+ private
256
+
257
+ # Resolve the target PID before connecting.
258
+ # Used to detect listen ports pre-connect so we can wake IO-blocked processes.
259
+ # Returns an integer PID or nil.
260
+ def resolve_target_pid(path, port)
261
+ if path
262
+ DebugClient.extract_pid(path)
263
+ elsif !port
264
+ sessions = DebugClient.list_sessions
265
+ sessions.first[:pid] if sessions.size == 1
266
+ end
267
+ # TCP port connections: can't determine PID pre-connect, return nil
268
+ end
269
+
270
+ # Send a fire-and-forget HTTP GET to wake an IO-blocked process.
271
+ # Delegates to DebugClient.wake_io_blocked_process (single implementation).
272
+ def wake_process_via_http(listen_ports)
273
+ DebugClient.wake_io_blocked_process(listen_ports.first)
274
+ end
275
+
276
+ # Detect TCP listen ports owned by the target process.
277
+ # Cross-references /proc/PID/fd (socket inodes) with /proc/PID/net/tcp
278
+ # to return only ports that belong to this specific process.
279
+ # Returns an array of port numbers (e.g., [3000, 3035]).
280
+ # Works without sending any commands to the debug session (safe in trap context).
281
+ def detect_listen_ports(pid)
282
+ return [] unless pid
283
+
284
+ # Step 1: Find socket inodes owned by this process
285
+ process_inodes = collect_socket_inodes(pid)
286
+ return [] if process_inodes.empty?
287
+
288
+ # Step 2: Find LISTEN ports matching those inodes
289
+ ports = []
290
+ ["/proc/#{pid}/net/tcp", "/proc/#{pid}/net/tcp6"].each do |path|
291
+ next unless File.exist?(path)
292
+
293
+ File.readlines(path).each do |line|
294
+ fields = line.strip.split
295
+ next if fields[0] == "sl" # header line
296
+
297
+ state = fields[3]
298
+ next unless state == "0A" # 0A = LISTEN
299
+
300
+ inode = fields[9]
301
+ next unless process_inodes.include?(inode)
302
+
303
+ local_addr = fields[1]
304
+ port = local_addr.split(":").last.to_i(16)
305
+ ports << port if port > 0
306
+ end
307
+ end
308
+
309
+ ports.uniq.sort
310
+ rescue StandardError
311
+ []
312
+ end
313
+
314
+ # Read /proc/PID/fd to find socket inodes owned by the process.
315
+ # Each socket fd is a symlink like "socket:[12345]".
316
+ def collect_socket_inodes(pid)
317
+ fd_dir = "/proc/#{pid}/fd"
318
+ return Set.new unless Dir.exist?(fd_dir)
319
+
320
+ inodes = Set.new
321
+ Dir.foreach(fd_dir) do |entry|
322
+ next if entry == "." || entry == ".."
323
+
324
+ link = File.readlink(File.join(fd_dir, entry))
325
+ if link =~ /\Asocket:\[(\d+)\]\z/
326
+ inodes.add($1)
327
+ end
328
+ rescue Errno::ENOENT, Errno::EACCES
329
+ next
330
+ end
331
+ inodes
332
+ rescue StandardError
333
+ Set.new
334
+ end
335
+
336
+ # Build a comprehensive Rails summary including app info, routes, and models.
337
+ # Uses lightweight (trap-safe) methods that work even in signal trap context.
338
+ # Accepts precomputed route_info to avoid duplicate queries.
339
+ def build_rails_summary(client, initial_output, listen_ports, route_info = nil, escaped: false)
340
+ text = "\n"
341
+
342
+ # App info header
343
+ app_name = RailsHelper.eval_expr(client, "Rails.application.class.module_parent_name")
344
+ rails_ver = RailsHelper.eval_expr(client, "Rails::VERSION::STRING")
345
+ rails_env = RailsHelper.eval_expr(client, "Rails.env")
346
+ ruby_ver = RailsHelper.eval_expr(client, "RUBY_VERSION")
347
+ root_path = RailsHelper.eval_expr(client, "Rails.root.to_s")
348
+
349
+ header = "=== Rails"
350
+ header += ": #{app_name}" if app_name
351
+ header += " (#{rails_env})" if rails_env
352
+ header += " ==="
353
+ text += "#{header}\n"
354
+
355
+ version_parts = []
356
+ version_parts << "Rails #{rails_ver}" if rails_ver
357
+ version_parts << "Ruby #{ruby_ver}" if ruby_ver
358
+ text += "#{version_parts.join(" / ")}\n" if version_parts.any?
359
+ text += "Root: #{root_path}\n" if root_path
360
+
361
+ # Route summary (use precomputed if available)
362
+ route_info ||= RailsHelper.route_summary(client, limit: 5)
363
+ if route_info && route_info[:count] > 0
364
+ text += "\nRoutes: #{route_info[:count]} defined\n"
365
+ route_info[:samples].each { |s| text += " #{s}\n" }
366
+ remaining = route_info[:count] - route_info[:samples].size
367
+ text += " ... and #{remaining} more (use 'rails_routes' for full list)\n" if remaining > 0
368
+ end
369
+
370
+ # Model files
371
+ models = RailsHelper.model_files(client)
372
+ if models && models.any?
373
+ text += "\nModels: #{models.join(", ")}\n"
374
+ end
375
+
376
+ # Always show next steps for Rails apps
377
+ in_gem_code = initial_output&.match?(%r{/gems/|/rubygems/|No sourcefile available})
378
+ if escaped
379
+ # Auto-escape succeeded — we're in app code now
380
+ text += "\nYou are now in application code context. " \
381
+ "All tools (DB queries, model loading, etc.) work normally.\n"
382
+ elsif in_gem_code
383
+ # Stuck in gem/framework code — show concrete steps to reach app code
384
+ text += build_next_steps(route_info, listen_ports)
385
+ else
386
+ # In app code already — show available actions
387
+ text += build_app_code_next_steps(route_info, listen_ports)
388
+ end
389
+
390
+ text += "\nRails tools (use only when you need details not shown above):\n" \
391
+ " rails_info → database config (adapter, DB name)\n" \
392
+ " rails_routes → full route list with filtering\n" \
393
+ " rails_model → column schema, associations, validations for a specific model\n"
394
+ text
395
+ end
396
+
397
+ # Suggest actions when already in application code.
398
+ def build_app_code_next_steps(route_info, listen_ports)
399
+ text = "\nYou are in application code. Available actions:\n"
400
+ text += " - Use 'get_context' to inspect current variables and call stack\n"
401
+ text += " - Use 'set_breakpoint' to add breakpoints on specific actions\n"
402
+ if listen_ports&.any?
403
+ text += " - Use 'trigger_request' to send HTTP requests (auto-resumes the process)\n"
404
+ end
405
+ text += " - Use 'evaluate_code' to run Ruby expressions in the current context\n"
406
+ text
407
+ end
408
+
409
+ # Build concrete next steps using discovered route and port info.
410
+ def build_next_steps(route_info, listen_ports)
411
+ text = "\nTo debug your application code:\n"
412
+
413
+ # Suggest a specific controller if we have route info
414
+ if route_info && route_info[:samples]&.any?
415
+ sample = route_info[:samples].first
416
+ # Parse "GET /users users#index" to extract controller
417
+ parts = sample.strip.split
418
+ if parts.size >= 3
419
+ controller_action = parts[2] # e.g., "users#index"
420
+ controller = controller_action.split("#").first
421
+ text += " 1. set_breakpoint on app/controllers/#{controller}_controller.rb\n"
422
+ else
423
+ text += " 1. set_breakpoint on a controller action\n"
424
+ end
425
+
426
+ # Suggest a specific URL if we have port info
427
+ url_path = parts[1] if parts.size >= 2 # e.g., "/users"
428
+ if listen_ports&.any? && url_path
429
+ text += " 2. trigger_request with GET http://127.0.0.1:#{listen_ports.first}#{url_path}\n"
430
+ else
431
+ text += " 2. trigger_request to send an HTTP request\n"
432
+ end
433
+ else
434
+ text += " 1. set_breakpoint on a controller action\n"
435
+ if listen_ports&.any?
436
+ text += " 2. trigger_request with GET http://127.0.0.1:#{listen_ports.first}/\n"
437
+ else
438
+ text += " 2. trigger_request to send an HTTP request\n"
439
+ end
440
+ end
441
+
442
+ text += " 3. Once at the breakpoint, all tools work normally\n"
443
+ text
444
+ end
445
+
446
+ def escape_trap_context(client, listen_ports: [], route_info: nil, auto_escape: true)
447
+ return "" unless client.in_trap_context?
448
+
449
+ # When a web server is detected (listen ports available), go directly to
450
+ # breakpoint+HTTP auto-escape. The `next` command causes protocol desync
451
+ # when the process is IO-blocked (common with Puma's IO.select loop):
452
+ # `next` times out → command stays in-flight → subsequent commands receive
453
+ # wrong responses → all auto-escape logic fails.
454
+ if auto_escape && listen_ports.any?
455
+ auto_result = auto_escape_trap_context(client, listen_ports, route_info)
456
+ return auto_result if auto_result
457
+ end
458
+
459
+ # Fall back to simple step escape (only for local, non-web-server processes
460
+ # where listen_ports is empty and auto-escape is not available).
461
+ # Skip when:
462
+ # - auto_escape is false (user explicitly opted out)
463
+ # - client.remote is true (TCP/Docker: `next` will timeout on IO-blocked
464
+ # processes and Process.kill can't recover due to PID namespace mismatch)
465
+ if auto_escape && !client.remote && !listen_ports.any?
466
+ step_output = client.escape_trap_context!
467
+ if step_output
468
+ return "\n Status: Escaped signal trap context (thread operations now available)\n"
469
+ end
470
+ end
471
+
472
+ "\n WARNING: Running in signal trap context (common with Puma/threaded servers).\n" \
473
+ " Thread operations (DB queries, model autoloading) will fail with ThreadError.\n" \
474
+ " Simple expressions (variables, constants, p/pp) still work.\n\n" \
475
+ " To escape to normal context:\n" \
476
+ " 1. set_breakpoint on a line in your controller/action\n" \
477
+ " 2. trigger_request to send an HTTP request (auto-resumes the process)\n" \
478
+ " 3. Once stopped at the breakpoint, all operations work normally\n"
479
+ end
480
+
481
+ # Automatically escape trap context by setting a breakpoint on a controller action
482
+ # and sending a GET request to trigger it.
483
+ # Returns the status string on success, nil on failure.
484
+ def auto_escape_trap_context(client, listen_ports, route_info)
485
+ target = find_breakpoint_target(client, route_info)
486
+ return nil unless target
487
+
488
+ file, line, url_path = target[:file], target[:line], target[:path]
489
+
490
+ # Set a temporary breakpoint
491
+ bp_output = client.send_command("break #{file}:#{line}")
492
+ bp_match = bp_output.match(/#(\d+)/)
493
+ return nil unless bp_match
494
+
495
+ bp_number = bp_match[1].to_i
496
+ port = listen_ports.first
497
+ url = "http://127.0.0.1:#{port}#{url_path || "/"}"
498
+
499
+ # Send GET request in background thread and wait for breakpoint
500
+ result = perform_escape_request(client, url)
501
+
502
+ unless result
503
+ # Escape failed: process may still be running after continue_and_wait
504
+ # timed out. Try to re-pause it so subsequent commands don't all timeout.
505
+ begin
506
+ client.ensure_paused(timeout: 3)
507
+ rescue DebugMcp::Error
508
+ # Best-effort: if this fails, subsequent commands will also fail,
509
+ # but at least we tried
510
+ end
511
+ end
512
+
513
+ # Clean up the temporary breakpoint (only works if process is paused)
514
+ begin
515
+ client.send_command("delete #{bp_number}")
516
+ rescue DebugMcp::Error
517
+ # Best-effort cleanup
518
+ end
519
+
520
+ if result
521
+ "\n Status: Auto-escaped signal trap context (via #{url_path || "/"})\n" \
522
+ " Thread operations (DB, autoloading) now available.\n"
523
+ else
524
+ nil
525
+ end
526
+ rescue DebugMcp::Error
527
+ nil
528
+ end
529
+
530
+ # Find a suitable breakpoint target for auto-escape.
531
+ # Returns { file:, line:, path: } or nil.
532
+ def find_breakpoint_target(client, route_info)
533
+ # Strategy 1: Use route info to find a controller action
534
+ target = find_target_from_routes(client, route_info)
535
+ return target if target
536
+
537
+ # Strategy 2: Use framework internal method as fallback
538
+ url_path = extract_get_path(route_info)
539
+ find_target_from_framework(client, url_path)
540
+ end
541
+
542
+ # Find a breakpoint target from route info by constructing the file path
543
+ # directly from Rails.root + controller name convention.
544
+ # Does NOT use const_source_location (which triggers autoloading and fails in trap context).
545
+ def find_target_from_routes(client, route_info)
546
+ return nil unless route_info && route_info[:samples]&.any?
547
+
548
+ root = RailsHelper.eval_expr(client, "Rails.root.to_s")
549
+ return nil unless root
550
+
551
+ route_info[:samples].each do |sample|
552
+ parts = sample.strip.split
553
+ next unless parts.size >= 3 && parts[0] == "GET"
554
+
555
+ url_path = parts[1]
556
+ controller_action = parts[2]
557
+ controller_name, action_name = controller_action.split("#")
558
+ next unless controller_name && action_name
559
+
560
+ # Construct file path directly from convention (no autoloading needed)
561
+ file = "#{root}/app/controllers/#{controller_name}_controller.rb"
562
+
563
+ # Verify the file exists and find the action line (File I/O is trap-safe)
564
+ line_expr = "File.exist?(#{file.inspect}) && " \
565
+ "File.readlines(#{file.inspect}).each_with_index.detect{|l,i|" \
566
+ "l.strip.match?(/\\Adef\\s+#{action_name}\\b/)}&.last&.+(1)"
567
+ line_str = RailsHelper.eval_expr(client, line_expr)
568
+ next unless line_str && line_str != "false"
569
+
570
+ line = line_str.to_i
571
+ next unless line > 0
572
+
573
+ return { file: file, line: line, path: url_path }
574
+ end
575
+
576
+ nil
577
+ rescue DebugMcp::Error
578
+ nil
579
+ end
580
+
581
+ # Fallback: find a breakpoint target using framework internals.
582
+ # Uses a real GET URL from route_info instead of "/" (which may not be routed).
583
+ def find_target_from_framework(client, url_path = nil)
584
+ # Use ActionController::Metal#dispatch source location
585
+ location = RailsHelper.eval_expr(client,
586
+ "ActionController::Metal.instance_method(:dispatch).source_location.inspect")
587
+ return nil unless location
588
+
589
+ # Parse the [file, line] array
590
+ match = location.match(/\["([^"]+)",\s*(\d+)\]/)
591
+ return nil unless match
592
+
593
+ # Use line + 1 to target the method body (def line may not trigger `:line` event)
594
+ { file: match[1], line: match[2].to_i + 1, path: url_path || "/" }
595
+ rescue DebugMcp::Error
596
+ nil
597
+ end
598
+
599
+ # Extract the first GET path from route info.
600
+ def extract_get_path(route_info)
601
+ return nil unless route_info && route_info[:samples]&.any?
602
+
603
+ route_info[:samples].each do |sample|
604
+ parts = sample.strip.split
605
+ return parts[1] if parts.size >= 3 && parts[0] == "GET"
606
+ end
607
+ nil
608
+ end
609
+
610
+ # Install a double Ctrl+C force-quit handler on the target process.
611
+ # First Ctrl+C prints a warning, second within 3s calls exit!(1).
612
+ # Uses ||= to avoid double-registration on reconnect.
613
+ # All operations (trap, STDERR.write, exit!) are async-signal-safe.
614
+ def install_sigint_handler(client)
615
+ handler_code = "$_debug_mcp_int_at=0;" \
616
+ "$_debug_mcp_orig_int||=trap('INT'){" \
617
+ "t=Process.clock_gettime(Process::CLOCK_MONOTONIC);" \
618
+ "if t-$_debug_mcp_int_at<3;exit!(1);" \
619
+ "else;$_debug_mcp_int_at=t;" \
620
+ "STDERR.write(\"\\nPress Ctrl+C again within 3s to force quit\\n\")end}"
621
+ client.send_command("p begin;#{handler_code};:ok;rescue =>e;e.class.name end")
622
+ rescue DebugMcp::Error
623
+ # Best-effort: don't fail connect if handler installation fails
624
+ end
625
+
626
+ # Clear all breakpoints in the target process.
627
+ # This removes breakpoints left over from previous sessions that weren't
628
+ # cleaned up properly (e.g., after a timeout-induced disconnect).
629
+ def clear_process_breakpoints(client)
630
+ bp_output = client.send_command("info breakpoints", timeout: 3)
631
+ return if bp_output.strip.empty?
632
+
633
+ bp_output.each_line do |line|
634
+ if (match = line.match(/#(\d+)/))
635
+ client.send_command("delete #{match[1]}", timeout: 2) rescue nil
636
+ end
637
+ end
638
+ rescue DebugMcp::Error
639
+ # Best-effort — don't fail connect if BP cleanup fails
640
+ end
641
+
642
+ # Send an HTTP GET request in a background thread and wait for breakpoint hit.
643
+ # Returns true if breakpoint was hit, false otherwise.
644
+ def perform_escape_request(client, url)
645
+ http_done = false
646
+ http_thread = Thread.new do
647
+ uri = URI.parse(url)
648
+ http = Net::HTTP.new(uri.host, uri.port)
649
+ http.open_timeout = 5
650
+ http.read_timeout = 10
651
+ http.get(uri.request_uri)
652
+ rescue StandardError
653
+ # Ignore HTTP errors — we only care about triggering the breakpoint
654
+ ensure
655
+ http_done = true
656
+ end
657
+
658
+ result = client.continue_and_wait(timeout: 10) { http_done }
659
+ http_thread.join(1)
660
+
661
+ result[:type] == :breakpoint
662
+ rescue DebugMcp::Error
663
+ http_thread&.join(1)
664
+ false
665
+ end
666
+ end
667
+ end
668
+ end
669
+ end