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