debug-mcp 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/README.ja.md +383 -0
- data/README.md +384 -0
- data/examples/01_simple_bug.rb +43 -0
- data/examples/02_data_pipeline.rb +93 -0
- data/examples/03_recursion.rb +96 -0
- data/examples/RAILS_SCENARIOS.md +350 -0
- data/examples/SCENARIOS.md +142 -0
- data/examples/rails_test_app/setup.sh +428 -0
- data/examples/rails_test_app/testapp/.dockerignore +10 -0
- data/examples/rails_test_app/testapp/.ruby-version +1 -0
- data/examples/rails_test_app/testapp/Dockerfile +23 -0
- data/examples/rails_test_app/testapp/Gemfile +17 -0
- data/examples/rails_test_app/testapp/README.md +65 -0
- data/examples/rails_test_app/testapp/Rakefile +6 -0
- data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
- data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
- data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
- data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
- data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
- data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
- data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
- data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
- data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
- data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
- data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
- data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
- data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
- data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
- data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
- data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
- data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
- data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
- data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
- data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
- data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
- data/examples/rails_test_app/testapp/bin/ci +6 -0
- data/examples/rails_test_app/testapp/bin/dev +2 -0
- data/examples/rails_test_app/testapp/bin/rails +4 -0
- data/examples/rails_test_app/testapp/bin/rake +4 -0
- data/examples/rails_test_app/testapp/bin/setup +35 -0
- data/examples/rails_test_app/testapp/config/application.rb +42 -0
- data/examples/rails_test_app/testapp/config/boot.rb +3 -0
- data/examples/rails_test_app/testapp/config/ci.rb +14 -0
- data/examples/rails_test_app/testapp/config/database.yml +32 -0
- data/examples/rails_test_app/testapp/config/environment.rb +5 -0
- data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
- data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
- data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
- data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
- data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
- data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
- data/examples/rails_test_app/testapp/config/puma.rb +39 -0
- data/examples/rails_test_app/testapp/config/routes.rb +34 -0
- data/examples/rails_test_app/testapp/config.ru +6 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
- data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
- data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
- data/examples/rails_test_app/testapp/db/schema.rb +71 -0
- data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
- data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
- data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
- data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
- data/examples/rails_test_app/testapp/log/.keep +0 -0
- data/examples/rails_test_app/testapp/public/400.html +135 -0
- data/examples/rails_test_app/testapp/public/404.html +135 -0
- data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
- data/examples/rails_test_app/testapp/public/422.html +135 -0
- data/examples/rails_test_app/testapp/public/500.html +135 -0
- data/examples/rails_test_app/testapp/public/icon.png +0 -0
- data/examples/rails_test_app/testapp/public/icon.svg +3 -0
- data/examples/rails_test_app/testapp/public/robots.txt +1 -0
- data/examples/rails_test_app/testapp/script/.keep +0 -0
- data/examples/rails_test_app/testapp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
- data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
- data/examples/rails_test_app/testapp/vendor/.keep +0 -0
- data/exe/debug-mcp +39 -0
- data/exe/debug-rails +127 -0
- data/lib/debug_mcp/client_cleanup.rb +102 -0
- data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
- data/lib/debug_mcp/debug_client.rb +1143 -0
- data/lib/debug_mcp/exit_message_builder.rb +112 -0
- data/lib/debug_mcp/pending_http_helper.rb +25 -0
- data/lib/debug_mcp/rails_helper.rb +155 -0
- data/lib/debug_mcp/server.rb +364 -0
- data/lib/debug_mcp/session_manager.rb +436 -0
- data/lib/debug_mcp/stop_event_annotator.rb +152 -0
- data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
- data/lib/debug_mcp/tools/connect.rb +669 -0
- data/lib/debug_mcp/tools/continue_execution.rb +161 -0
- data/lib/debug_mcp/tools/disconnect.rb +169 -0
- data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
- data/lib/debug_mcp/tools/finish.rb +84 -0
- data/lib/debug_mcp/tools/get_context.rb +217 -0
- data/lib/debug_mcp/tools/get_source.rb +193 -0
- data/lib/debug_mcp/tools/inspect_object.rb +107 -0
- data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
- data/lib/debug_mcp/tools/list_files.rb +189 -0
- data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
- data/lib/debug_mcp/tools/next.rb +70 -0
- data/lib/debug_mcp/tools/rails_info.rb +200 -0
- data/lib/debug_mcp/tools/rails_model.rb +362 -0
- data/lib/debug_mcp/tools/rails_routes.rb +186 -0
- data/lib/debug_mcp/tools/read_file.rb +214 -0
- data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
- data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
- data/lib/debug_mcp/tools/run_script.rb +293 -0
- data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
- data/lib/debug_mcp/tools/step.rb +67 -0
- data/lib/debug_mcp/tools/trigger_request.rb +515 -0
- data/lib/debug_mcp/version.rb +5 -0
- data/lib/debug_mcp.rb +40 -0
- metadata +251 -0
|
@@ -0,0 +1,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
|