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,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class ContinueExecution < MCP::Tool
|
|
8
|
+
description "[Control] Resume execution of the paused Ruby process. " \
|
|
9
|
+
"Continues until the next breakpoint is hit or the program finishes. " \
|
|
10
|
+
"If the program exits, the final output including any exception details will be returned. " \
|
|
11
|
+
"Use 'finish' to run until the current method/block returns instead. " \
|
|
12
|
+
"Tip: To catch exceptions before they crash the process, use " \
|
|
13
|
+
"set_breakpoint(exception_class: 'NoMethodError') before continuing."
|
|
14
|
+
|
|
15
|
+
annotations(
|
|
16
|
+
title: "Continue Execution",
|
|
17
|
+
read_only_hint: false,
|
|
18
|
+
destructive_hint: false,
|
|
19
|
+
open_world_hint: false,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
input_schema(
|
|
23
|
+
properties: {
|
|
24
|
+
session_id: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
HTTP_JOIN_TIMEOUT = 10
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def call(session_id: nil, server_context:)
|
|
35
|
+
client = server_context[:session_manager].client(session_id)
|
|
36
|
+
|
|
37
|
+
# Check breakpoint existence before continuing (for timeout message)
|
|
38
|
+
has_breakpoints = check_breakpoints(client)
|
|
39
|
+
|
|
40
|
+
# Retrieve and clear pending HTTP info before continuing
|
|
41
|
+
pending = client.pending_http
|
|
42
|
+
client.pending_http = nil if pending
|
|
43
|
+
|
|
44
|
+
output = if pending
|
|
45
|
+
client.send_continue { pending[:holder][:done] }
|
|
46
|
+
else
|
|
47
|
+
client.send_continue
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The debug gem may send an `input` prompt just before the process exits
|
|
51
|
+
# (e.g., on a return event from the main script). When output is empty,
|
|
52
|
+
# check if the process has actually exited.
|
|
53
|
+
if output.strip.empty? && client.process_finished?
|
|
54
|
+
text = DebugMcp::ExitMessageBuilder.build_exit_message(
|
|
55
|
+
"Program finished execution.", output, client,
|
|
56
|
+
)
|
|
57
|
+
text = append_http_response(text, pending)
|
|
58
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
client.cleanup_one_shot_breakpoints(output)
|
|
62
|
+
output = DebugMcp::StopEventAnnotator.annotate_breakpoint_hit(output)
|
|
63
|
+
output = DebugMcp::StopEventAnnotator.enrich_stop_context(output, client)
|
|
64
|
+
|
|
65
|
+
text = "Execution resumed.\n\n#{output}"
|
|
66
|
+
text = append_http_response(text, pending)
|
|
67
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
68
|
+
rescue DebugMcp::SessionError => e
|
|
69
|
+
text = if e.message.include?("session ended") || e.message.include?("finished execution")
|
|
70
|
+
DebugMcp::ExitMessageBuilder.build_exit_message("Program finished execution.", e.final_output, client)
|
|
71
|
+
else
|
|
72
|
+
"Error: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
text = append_http_response(text, pending)
|
|
75
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
76
|
+
rescue DebugMcp::TimeoutError
|
|
77
|
+
text = if has_breakpoints
|
|
78
|
+
">>> PROCESS STATE: RUNNING (not paused) <<<\n\n" \
|
|
79
|
+
"No breakpoint was hit within the timeout period.\n\n" \
|
|
80
|
+
"Next steps:\n" \
|
|
81
|
+
"1. set_breakpoint on a specific code path\n" \
|
|
82
|
+
"2. trigger_request to send an HTTP request (auto-resumes)\n" \
|
|
83
|
+
"3. disconnect to detach"
|
|
84
|
+
else
|
|
85
|
+
">>> PROCESS STATE: RUNNING (not paused) <<<\n\n" \
|
|
86
|
+
"Process resumed successfully (no breakpoints set).\n\n" \
|
|
87
|
+
"Next steps:\n" \
|
|
88
|
+
"1. set_breakpoint to add breakpoints\n" \
|
|
89
|
+
"2. trigger_request to send an HTTP request and hit a breakpoint"
|
|
90
|
+
end
|
|
91
|
+
timeout_sec = server_context[:session_manager]&.timeout
|
|
92
|
+
if timeout_sec
|
|
93
|
+
text += "\n\nNote: The debug session will remain active for " \
|
|
94
|
+
"#{timeout_sec / 60} minutes of inactivity. Any tool call resets the timer."
|
|
95
|
+
end
|
|
96
|
+
text = append_http_response(text, pending)
|
|
97
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
98
|
+
rescue DebugMcp::ConnectionError => e
|
|
99
|
+
text = if e.message.include?("Connection lost") || e.message.include?("connection closed")
|
|
100
|
+
DebugMcp::ExitMessageBuilder.build_exit_message(
|
|
101
|
+
"Program finished execution (connection closed).", e.final_output, client,
|
|
102
|
+
)
|
|
103
|
+
else
|
|
104
|
+
"Error: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
text = append_http_response(text, pending)
|
|
107
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
108
|
+
rescue DebugMcp::Error => e
|
|
109
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Join the pending HTTP thread and append the response to the text.
|
|
115
|
+
def append_http_response(text, pending)
|
|
116
|
+
return text unless pending
|
|
117
|
+
|
|
118
|
+
thread = pending[:thread]
|
|
119
|
+
holder = pending[:holder]
|
|
120
|
+
method = pending[:method]
|
|
121
|
+
url = pending[:url]
|
|
122
|
+
|
|
123
|
+
thread.join(HTTP_JOIN_TIMEOUT)
|
|
124
|
+
|
|
125
|
+
if holder[:error]
|
|
126
|
+
"#{text}\n\n--- HTTP Response ---\nHTTP #{method} #{url}\nRequest error: #{holder[:error].message}"
|
|
127
|
+
elsif holder[:response]
|
|
128
|
+
formatted = DebugMcp::Tools::TriggerRequest.send(:format_response, holder[:response])
|
|
129
|
+
"#{text}\n\n--- HTTP Response ---\nHTTP #{method} #{url}\n#{formatted}"
|
|
130
|
+
elsif holder[:done]
|
|
131
|
+
"#{text}\n\n--- HTTP Response ---\nHTTP #{method} #{url}\nUnexpected state: request completed without response."
|
|
132
|
+
else
|
|
133
|
+
elapsed_note = format_http_elapsed(pending[:started_at])
|
|
134
|
+
thread_note = thread.alive? ? "still running" : "thread exited without response"
|
|
135
|
+
"#{text}\n\n--- HTTP Response ---\nHTTP #{method} #{url}\n" \
|
|
136
|
+
"Request still in progress (#{thread_note}#{elapsed_note})."
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError
|
|
139
|
+
text
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Format elapsed time since HTTP request started.
|
|
143
|
+
def format_http_elapsed(started_at)
|
|
144
|
+
return "" unless started_at
|
|
145
|
+
|
|
146
|
+
elapsed = (Time.now - started_at).to_i
|
|
147
|
+
", started #{elapsed}s ago"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if any breakpoints are currently set.
|
|
151
|
+
# Returns true if breakpoints exist, false otherwise.
|
|
152
|
+
def check_breakpoints(client)
|
|
153
|
+
output = client.send_command("info breakpoints")
|
|
154
|
+
!output.strip.empty? && !output.include?("No breakpoints")
|
|
155
|
+
rescue DebugMcp::Error
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../client_cleanup"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class Disconnect < MCP::Tool
|
|
9
|
+
description "[Control] Disconnect from the current debug session and clean up. " \
|
|
10
|
+
"For sessions started with 'run_script', the target process is also terminated. " \
|
|
11
|
+
"Use this when you are done debugging or want to restart with a clean state. " \
|
|
12
|
+
"After disconnecting, use 'run_script' or 'connect' to start a new session."
|
|
13
|
+
|
|
14
|
+
annotations(
|
|
15
|
+
title: "Disconnect Session",
|
|
16
|
+
read_only_hint: false,
|
|
17
|
+
destructive_hint: false,
|
|
18
|
+
open_world_hint: false,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
input_schema(
|
|
22
|
+
properties: {
|
|
23
|
+
session_id: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Debug session ID to disconnect (uses default session if omitted)",
|
|
26
|
+
},
|
|
27
|
+
force: {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
description: "If true, skip all cleanup (breakpoint deletion, process resume) and " \
|
|
30
|
+
"immediately close the socket. Use when the process is unresponsive " \
|
|
31
|
+
"and normal disconnect times out. Default: false.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
CLEANUP_DEADLINE = 3
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def call(session_id: nil, force: nil, server_context:)
|
|
40
|
+
manager = server_context[:session_manager]
|
|
41
|
+
|
|
42
|
+
# Get session info before disconnecting
|
|
43
|
+
begin
|
|
44
|
+
client = manager.client(session_id)
|
|
45
|
+
pid = client.pid
|
|
46
|
+
has_process = !client.wait_thread.nil?
|
|
47
|
+
rescue DebugMcp::Error
|
|
48
|
+
return MCP::Tool::Response.new([{ type: "text",
|
|
49
|
+
text: "No active session to disconnect." }])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if force
|
|
53
|
+
# Force disconnect: skip all cleanup, just close the socket immediately.
|
|
54
|
+
# Use when the process is unresponsive and normal disconnect hangs.
|
|
55
|
+
|
|
56
|
+
# For run_script sessions, still attempt to kill the spawned process
|
|
57
|
+
# (Process.kill is non-blocking, so it won't hang even if the process is stuck).
|
|
58
|
+
process_killed = false
|
|
59
|
+
if has_process && pid
|
|
60
|
+
begin
|
|
61
|
+
Process.kill("TERM", pid.to_i)
|
|
62
|
+
process_killed = true
|
|
63
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
64
|
+
# Process already exited
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
manager.disconnect(session_id)
|
|
69
|
+
|
|
70
|
+
text = "Force-disconnected from session (cleanup skipped)."
|
|
71
|
+
text += " Process #{pid} terminated." if process_killed
|
|
72
|
+
if has_process && !process_killed
|
|
73
|
+
text += "\n\nWARNING: The spawned process (PID #{pid}) was NOT terminated and may still be running."
|
|
74
|
+
else
|
|
75
|
+
text += "\n\nWARNING: Breakpoints were NOT removed and the process was NOT resumed. " \
|
|
76
|
+
"The target process may be left in a paused state."
|
|
77
|
+
end
|
|
78
|
+
text += "\n\nUse 'run_script' or 'connect' to start a new debug session."
|
|
79
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Kill the target process if launched via run_script
|
|
83
|
+
process_killed = false
|
|
84
|
+
if has_process && pid
|
|
85
|
+
begin
|
|
86
|
+
Process.kill("TERM", pid.to_i)
|
|
87
|
+
process_killed = true
|
|
88
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
89
|
+
# Process already exited
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
# For connect sessions: best-effort cleanup (restore SIGINT,
|
|
93
|
+
# delete BPs, continue) bounded by a hard deadline.
|
|
94
|
+
# If not paused, try to re-pause before cleanup.
|
|
95
|
+
unless client.paused
|
|
96
|
+
# 1. Try repause() first — works for both remote (TCP/Docker) and local
|
|
97
|
+
begin
|
|
98
|
+
client.repause(timeout: 3)
|
|
99
|
+
rescue DebugMcp::Error
|
|
100
|
+
# Best-effort
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 2. Fall back to interrupt_and_wait (local SIGINT only)
|
|
104
|
+
unless client.paused
|
|
105
|
+
begin
|
|
106
|
+
client.interrupt_and_wait(timeout: 3)
|
|
107
|
+
rescue DebugMcp::Error
|
|
108
|
+
# Best-effort
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 3. For remote connections, try HTTP wake + check_paused
|
|
113
|
+
# (repause already sent the pause message at step 1 — avoid
|
|
114
|
+
# sending more to prevent stale messages after disconnect)
|
|
115
|
+
unless client.paused
|
|
116
|
+
if client.remote
|
|
117
|
+
if client.listen_ports&.any?
|
|
118
|
+
begin
|
|
119
|
+
client.wake_io_blocked_process(client.listen_ports.first)
|
|
120
|
+
sleep DebugClient::HTTP_WAKE_SETTLE_TIME
|
|
121
|
+
client.check_paused(timeout: 5)
|
|
122
|
+
rescue DebugMcp::Error
|
|
123
|
+
# Best-effort
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
begin
|
|
127
|
+
client.check_paused(timeout: 5)
|
|
128
|
+
rescue DebugMcp::Error
|
|
129
|
+
# Best-effort
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if client.paused
|
|
137
|
+
best_effort_cleanup(client)
|
|
138
|
+
else
|
|
139
|
+
# Both repause and interrupt failed — best-effort resume to prevent stuck process
|
|
140
|
+
begin
|
|
141
|
+
client.send_command_no_wait("c", force: true)
|
|
142
|
+
rescue StandardError
|
|
143
|
+
# Best-effort
|
|
144
|
+
end
|
|
145
|
+
force_warning = "Could not re-pause the process for cleanup. " \
|
|
146
|
+
"Breakpoints may remain. A resume command was sent to prevent the process from getting stuck."
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Disconnect the session (closes socket, cleans up temp files)
|
|
151
|
+
manager.disconnect(session_id)
|
|
152
|
+
|
|
153
|
+
text = "Disconnected from session."
|
|
154
|
+
text += " Process #{pid} terminated." if process_killed
|
|
155
|
+
text += "\n\nWARNING: #{force_warning}" if force_warning
|
|
156
|
+
text += "\n\nUse 'run_script' or 'connect' to start a new debug session."
|
|
157
|
+
|
|
158
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def best_effort_cleanup(client)
|
|
164
|
+
ClientCleanup.cleanup_and_resume(client, deadline: Time.now + CLEANUP_DEADLINE)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "base64"
|
|
5
|
+
require_relative "../code_safety_analyzer"
|
|
6
|
+
require_relative "../pending_http_helper"
|
|
7
|
+
|
|
8
|
+
module DebugMcp
|
|
9
|
+
module Tools
|
|
10
|
+
class EvaluateCode < MCP::Tool
|
|
11
|
+
description "[Investigation] Execute Ruby code in the live context of the paused process. " \
|
|
12
|
+
"The code runs in the current binding — you can access local variables, " \
|
|
13
|
+
"call methods, inspect return values, or test fixes. " \
|
|
14
|
+
"stdout output (from puts, print, etc.) is automatically captured and returned " \
|
|
15
|
+
"(suppressed when it duplicates the return value). " \
|
|
16
|
+
"Example: evaluate_code(code: \"user.errors.full_messages\")"
|
|
17
|
+
|
|
18
|
+
annotations(
|
|
19
|
+
title: "Evaluate Ruby Code",
|
|
20
|
+
read_only_hint: false,
|
|
21
|
+
destructive_hint: true,
|
|
22
|
+
idempotent_hint: false,
|
|
23
|
+
open_world_hint: true,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
input_schema(
|
|
27
|
+
properties: {
|
|
28
|
+
code: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Ruby code to execute (e.g., 'user.valid?', 'Order.where(status: :pending).count')",
|
|
31
|
+
},
|
|
32
|
+
session_id: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
35
|
+
},
|
|
36
|
+
acknowledge_mutations: {
|
|
37
|
+
type: "boolean",
|
|
38
|
+
description: "Set to true to suppress data mutation warnings (e.g., .save, .create!) " \
|
|
39
|
+
"for the rest of this session. Other warning categories are unaffected.",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ["code"],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def call(code:, session_id: nil, acknowledge_mutations: nil, server_context:)
|
|
47
|
+
manager = server_context[:session_manager]
|
|
48
|
+
client = manager.client(session_id)
|
|
49
|
+
client.auto_repause!
|
|
50
|
+
|
|
51
|
+
# Acknowledge mutation warnings for this session if requested
|
|
52
|
+
if acknowledge_mutations
|
|
53
|
+
manager.acknowledge_warning(session_id, :mutation_operations)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Layer 3: Code safety analysis — warn about dangerous operations
|
|
57
|
+
safety_warnings = CodeSafetyAnalyzer.analyze(code)
|
|
58
|
+
acknowledged = manager.acknowledged_warnings(session_id)
|
|
59
|
+
safety_warnings = CodeSafetyAnalyzer.filter_acknowledged(safety_warnings, acknowledged)
|
|
60
|
+
warning_text = CodeSafetyAnalyzer.format_warnings(safety_warnings)
|
|
61
|
+
|
|
62
|
+
# In trap context (e.g., after SIGURG-based repause), `require` and
|
|
63
|
+
# Mutex operations hang. Use a simplified evaluation path that avoids
|
|
64
|
+
# stdout redirect (which needs `require "stringio"`).
|
|
65
|
+
if client.trap_context
|
|
66
|
+
return call_in_trap_context(client, code, warning_text: warning_text)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
stdout_redirected = false
|
|
70
|
+
suspended_catch_bps = []
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
# Proactive recovery: if a previous timeout left $stdout redirected
|
|
74
|
+
# to StringIO, restore it to the original STDOUT constant (immutable).
|
|
75
|
+
begin
|
|
76
|
+
client.send_command('$stdout = STDOUT if $stdout != STDOUT; nil')
|
|
77
|
+
rescue DebugMcp::Error
|
|
78
|
+
# Best-effort
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Proactive recovery: if a previous timeout left catch breakpoints
|
|
82
|
+
# suspended (deleted but not restored), recreate them now.
|
|
83
|
+
if client.suspended_catch_bps&.any?
|
|
84
|
+
restore_catch_breakpoints(client, client.suspended_catch_bps)
|
|
85
|
+
client.suspended_catch_bps = []
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Temporarily disable catch breakpoints to prevent them from
|
|
89
|
+
# firing on exceptions raised during code evaluation
|
|
90
|
+
suspended_catch_bps = suspend_catch_breakpoints(client)
|
|
91
|
+
|
|
92
|
+
# Redirect $stdout to capture puts/print output.
|
|
93
|
+
# Use StringIO directly (always available in debug gem sessions)
|
|
94
|
+
# instead of `require "stringio"` which hangs in trap context.
|
|
95
|
+
client.send_command(
|
|
96
|
+
'$__debug_mcp_cap = StringIO.new; $stdout = $__debug_mcp_cap',
|
|
97
|
+
)
|
|
98
|
+
stdout_redirected = true
|
|
99
|
+
|
|
100
|
+
# Evaluate user code (pp formats the return value)
|
|
101
|
+
# The debug gem protocol is line-based, so multi-line code must be
|
|
102
|
+
# encoded into a single line to avoid breaking the protocol.
|
|
103
|
+
# The code is wrapped in begin/rescue to capture exceptions in
|
|
104
|
+
# $__debug_mcp_err, allowing us to distinguish errors from normal nil.
|
|
105
|
+
output = client.send_command(build_eval_command(code))
|
|
106
|
+
|
|
107
|
+
# Restore $stdout and read captured output in a single round-trip
|
|
108
|
+
captured = restore_and_read_stdout(client)
|
|
109
|
+
stdout_redirected = false
|
|
110
|
+
|
|
111
|
+
# Check if evaluation raised an exception
|
|
112
|
+
err_info = read_eval_error(client)
|
|
113
|
+
|
|
114
|
+
if err_info
|
|
115
|
+
text = "Error: #{err_info}"
|
|
116
|
+
text += "\n\nDebugger output:\n#{output}" if output && !output.strip.empty? && output.strip != "nil"
|
|
117
|
+
text += "\n\nCaptured stdout:\n#{captured}" if captured
|
|
118
|
+
if err_info.include?("ThreadError")
|
|
119
|
+
text += "\n\nThis error occurs in signal trap context (common when connecting to Puma/Rails via SIGURG).\n" \
|
|
120
|
+
"Thread operations (Mutex, DB queries, model autoloading) are not available here.\n\n" \
|
|
121
|
+
"To escape trap context:\n" \
|
|
122
|
+
" 1. set_breakpoint on a line in your controller/action\n" \
|
|
123
|
+
" 2. trigger_request to send an HTTP request (this auto-resumes the process)\n" \
|
|
124
|
+
" 3. Once stopped at the breakpoint, all operations work normally"
|
|
125
|
+
end
|
|
126
|
+
elsif captured
|
|
127
|
+
# pp() writes to $stdout, so captured stdout often contains
|
|
128
|
+
# just the pp output of the return value (identical content).
|
|
129
|
+
# Only show "Captured stdout" when it has additional content
|
|
130
|
+
# (e.g., from puts/print in the evaluated code).
|
|
131
|
+
return_val = output.strip.sub(/\A=> /, "")
|
|
132
|
+
if captured.strip == return_val.strip
|
|
133
|
+
text = output
|
|
134
|
+
else
|
|
135
|
+
text = "Return value:\n#{output}\n\nCaptured stdout:\n#{captured}"
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
text = output
|
|
139
|
+
end
|
|
140
|
+
text = append_frame_info(client, text)
|
|
141
|
+
text = append_trap_context_note(client, text)
|
|
142
|
+
text = append_pending_http_note(client, text)
|
|
143
|
+
text = prepend_warning(text, warning_text)
|
|
144
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
145
|
+
rescue DebugMcp::TimeoutError => e
|
|
146
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}\n\n" \
|
|
147
|
+
"The code may be taking too long to execute. Consider:\n" \
|
|
148
|
+
"- Breaking the expression into smaller parts\n" \
|
|
149
|
+
"- Using 'run_debug_command' with a custom timeout" }])
|
|
150
|
+
rescue DebugMcp::Error => e
|
|
151
|
+
text = "Error: #{e.message}"
|
|
152
|
+
if e.message.include?("ThreadError")
|
|
153
|
+
text += "\n\nThis error occurs in signal trap context. " \
|
|
154
|
+
"Use set_breakpoint + trigger_request to escape to normal context first."
|
|
155
|
+
end
|
|
156
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
157
|
+
ensure
|
|
158
|
+
if stdout_redirected
|
|
159
|
+
client.send_command('$stdout = STDOUT') rescue nil
|
|
160
|
+
end
|
|
161
|
+
# Save suspended catch BPs to client so they can be proactively
|
|
162
|
+
# restored on the next evaluate_code call if this restore fails.
|
|
163
|
+
client.suspended_catch_bps = suspended_catch_bps if suspended_catch_bps.any?
|
|
164
|
+
restore_catch_breakpoints(client, suspended_catch_bps)
|
|
165
|
+
client.suspended_catch_bps = []
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Simplified evaluation path for trap context (after SIGURG-based repause).
|
|
172
|
+
# Avoids `require`, Mutex, and stdout redirect — all of which hang in trap context.
|
|
173
|
+
# Only uses simple expressions that are safe in restricted context.
|
|
174
|
+
def call_in_trap_context(client, code, warning_text: nil)
|
|
175
|
+
# Use `p` instead of `pp` (pp may trigger autoload in some cases)
|
|
176
|
+
# Use $__debug_mcp_err pattern (same as normal path) for structured error detection.
|
|
177
|
+
# Single-line code only; multi-line code with newlines can't use Base64 (require hangs)
|
|
178
|
+
if code.include?("\n")
|
|
179
|
+
output = client.send_command(
|
|
180
|
+
"$__debug_mcp_err=nil; p(begin; eval(#{code.gsub("\n", ";").inspect}); " \
|
|
181
|
+
'rescue => __e; $__debug_mcp_err="#{__e.class}: #{__e.message}"; nil; end)',
|
|
182
|
+
)
|
|
183
|
+
else
|
|
184
|
+
output = client.send_command(
|
|
185
|
+
"$__debug_mcp_err=nil; p(begin; (#{code}); " \
|
|
186
|
+
'rescue => __e; $__debug_mcp_err="#{__e.class}: #{__e.message}"; nil; end)',
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Check for captured error (one additional round-trip, but safe in trap context)
|
|
191
|
+
err_info = read_eval_error(client)
|
|
192
|
+
|
|
193
|
+
if err_info
|
|
194
|
+
text = "Error: #{err_info}"
|
|
195
|
+
text += "\n\nDebugger output:\n#{output}" if output && !output.strip.empty? && output.strip != "nil"
|
|
196
|
+
if err_info.include?("ThreadError")
|
|
197
|
+
text += "\n\nThis error occurs in signal trap context (common when connecting to Puma/Rails via SIGURG).\n" \
|
|
198
|
+
"Thread operations (Mutex, DB queries, model autoloading) are not available here.\n\n" \
|
|
199
|
+
"To escape trap context:\n" \
|
|
200
|
+
" 1. set_breakpoint on a line in your controller/action\n" \
|
|
201
|
+
" 2. trigger_request to send an HTTP request (this auto-resumes the process)\n" \
|
|
202
|
+
" 3. Once stopped at the breakpoint, all operations work normally"
|
|
203
|
+
end
|
|
204
|
+
else
|
|
205
|
+
text = output
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
text = append_trap_context_note(client, text)
|
|
209
|
+
text = append_pending_http_note(client, text)
|
|
210
|
+
text = prepend_warning(text, warning_text)
|
|
211
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
212
|
+
rescue DebugMcp::TimeoutError => e
|
|
213
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}\n\n" \
|
|
214
|
+
"In trap context, some expressions may hang. Use simple expressions only.\n" \
|
|
215
|
+
"To escape trap context: set_breakpoint + trigger_request." }])
|
|
216
|
+
rescue DebugMcp::Error => e
|
|
217
|
+
text = "Error: #{e.message}"
|
|
218
|
+
text += "\n\n[trap context]" if client.trap_context
|
|
219
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Build a debug command that evaluates the given code.
|
|
223
|
+
# The code is wrapped in begin/rescue to capture exceptions in
|
|
224
|
+
# $__debug_mcp_err. On error, the rescue returns nil (which pp shows),
|
|
225
|
+
# but the error is preserved in the global variable for structured
|
|
226
|
+
# error reporting.
|
|
227
|
+
# Base64-encoding is used when the code contains newlines (the debug
|
|
228
|
+
# gem protocol is line-based) or non-ASCII characters (to avoid
|
|
229
|
+
# encoding conflicts on the socket).
|
|
230
|
+
def build_eval_command(code)
|
|
231
|
+
if code.include?("\n") || !code.ascii_only?
|
|
232
|
+
encoded = Base64.strict_encode64(code.encode(Encoding::UTF_8))
|
|
233
|
+
"$__debug_mcp_err=nil; pp(begin; require 'base64'; " \
|
|
234
|
+
"eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'), binding); " \
|
|
235
|
+
'rescue => __e; $__debug_mcp_err="#{__e.class}: #{__e.message}"; nil; end)'
|
|
236
|
+
else
|
|
237
|
+
"$__debug_mcp_err=nil; pp(begin; (#{code}); " \
|
|
238
|
+
'rescue => __e; $__debug_mcp_err="#{__e.class}: #{__e.message}"; nil; end)'
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Check $__debug_mcp_err for a captured exception from the eval wrapper.
|
|
243
|
+
# Returns "ClassName: message" string, or nil if no error.
|
|
244
|
+
def read_eval_error(client)
|
|
245
|
+
result = client.send_command("p $__debug_mcp_err")
|
|
246
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
247
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
248
|
+
|
|
249
|
+
cleaned = cleaned[1..-2] if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
250
|
+
cleaned.empty? ? nil : cleaned
|
|
251
|
+
rescue DebugMcp::Error
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Restore $stdout and read captured output in a single command.
|
|
256
|
+
# Combines two round-trips into one.
|
|
257
|
+
def restore_and_read_stdout(client)
|
|
258
|
+
result = client.send_command("$stdout = STDOUT; p $__debug_mcp_cap&.string")
|
|
259
|
+
parse_captured_stdout(result)
|
|
260
|
+
rescue DebugMcp::Error
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def parse_captured_stdout(result)
|
|
265
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
266
|
+
return nil if cleaned == '""' || cleaned == "nil" || cleaned.empty?
|
|
267
|
+
|
|
268
|
+
# Remove surrounding quotes and unescape Ruby string escapes
|
|
269
|
+
if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
270
|
+
cleaned = cleaned[1..-2]
|
|
271
|
+
cleaned = unescape_ruby_string(cleaned)
|
|
272
|
+
end
|
|
273
|
+
cleaned.empty? ? nil : cleaned
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def unescape_ruby_string(str)
|
|
277
|
+
str.gsub(/\\([nrt\\"'])/) do
|
|
278
|
+
case $1
|
|
279
|
+
when "n" then "\n"
|
|
280
|
+
when "r" then "\r"
|
|
281
|
+
when "t" then "\t"
|
|
282
|
+
when "\\" then "\\"
|
|
283
|
+
when '"' then '"'
|
|
284
|
+
when "'" then "'"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Prepend frame info if the debugger is not at frame 0 (i.e., after up/down).
|
|
290
|
+
def append_frame_info(client, text)
|
|
291
|
+
frame_output = client.send_command("frame", timeout: 3)
|
|
292
|
+
# Debug gem output: "#1 ClassName#method at /path/to/file.rb:10" or similar
|
|
293
|
+
if (match = frame_output.match(/#(\d+)\s+(.+)/))
|
|
294
|
+
frame_num = match[1].to_i
|
|
295
|
+
return "Frame ##{frame_num}: #{match[2].strip}\n\n#{text}" if frame_num > 0
|
|
296
|
+
end
|
|
297
|
+
text
|
|
298
|
+
rescue DebugMcp::Error
|
|
299
|
+
text
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Prepend safety warning to response text if present.
|
|
303
|
+
def prepend_warning(text, warning_text)
|
|
304
|
+
return text unless warning_text
|
|
305
|
+
|
|
306
|
+
"#{warning_text}\n\nThe code was executed. Result follows:\n---\n#{text}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def append_trap_context_note(client, text)
|
|
310
|
+
return text unless client.respond_to?(:trap_context) && client.trap_context
|
|
311
|
+
"#{text}\n\n[trap context — stdout capture (puts/print) is not available; use expression return values instead]"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def append_pending_http_note(client, text)
|
|
315
|
+
note = PendingHttpHelper.pending_http_note(client)
|
|
316
|
+
note ? "#{text}\n\n#{note}" : text
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Temporarily remove all catch breakpoints by deleting them.
|
|
320
|
+
# Returns an array of exception class names that were removed.
|
|
321
|
+
# The debug gem does not support disable/enable, so we must
|
|
322
|
+
# delete and recreate catch breakpoints.
|
|
323
|
+
def suspend_catch_breakpoints(client)
|
|
324
|
+
output = client.send_command("info break")
|
|
325
|
+
suspended = []
|
|
326
|
+
|
|
327
|
+
output.each_line do |line|
|
|
328
|
+
next unless line.include?("BP - Catch")
|
|
329
|
+
# Match: #1 BP - Catch "NoMethodError"
|
|
330
|
+
next unless (match = line.match(/#(\d+)\s+BP - Catch\s+"([^"]+)"/))
|
|
331
|
+
|
|
332
|
+
bp_num = match[1]
|
|
333
|
+
exception_class = match[2]
|
|
334
|
+
client.send_command("delete #{bp_num}")
|
|
335
|
+
suspended << exception_class
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
suspended
|
|
339
|
+
rescue DebugMcp::Error
|
|
340
|
+
[]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Recreate catch breakpoints that were previously suspended.
|
|
344
|
+
def restore_catch_breakpoints(client, exception_classes)
|
|
345
|
+
exception_classes.each do |exc_class|
|
|
346
|
+
client.send_command("catch #{exc_class}")
|
|
347
|
+
rescue DebugMcp::Error
|
|
348
|
+
# Best-effort: session may have ended
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|