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,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class RunScript < MCP::Tool
|
|
9
|
+
description "[Entry Point] Launch a Ruby script under the debugger and automatically connect to it. " \
|
|
10
|
+
"The script starts and pauses at breakpoints or 'binding.break' in the source. " \
|
|
11
|
+
"Use breakpoints parameter to set initial breakpoints before execution starts " \
|
|
12
|
+
"(e.g., breakpoints: ['User#save', 'app/models/user.rb:10']). " \
|
|
13
|
+
"This is the easiest way to start debugging a script from scratch. " \
|
|
14
|
+
"Use restore_breakpoints: true to re-run with the same breakpoints after a crash."
|
|
15
|
+
|
|
16
|
+
annotations(
|
|
17
|
+
title: "Run Ruby Script",
|
|
18
|
+
read_only_hint: false,
|
|
19
|
+
destructive_hint: false,
|
|
20
|
+
open_world_hint: true,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
input_schema(
|
|
24
|
+
properties: {
|
|
25
|
+
file: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Path to the Ruby script to run",
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: { type: "string" },
|
|
32
|
+
description: "Command-line arguments to pass to the script",
|
|
33
|
+
},
|
|
34
|
+
port: {
|
|
35
|
+
type: "integer",
|
|
36
|
+
description: "TCP port for debug connection (auto-assigned if omitted)",
|
|
37
|
+
},
|
|
38
|
+
breakpoints: {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: { type: "string" },
|
|
41
|
+
description: "Breakpoints to set before execution starts. Each entry is a breakpoint spec: " \
|
|
42
|
+
"'file.rb:10' for line, 'Class#method' for method, or 'catch ExceptionClass' for exception. " \
|
|
43
|
+
"The script pauses at line 1, breakpoints are set, then execution continues.",
|
|
44
|
+
},
|
|
45
|
+
restore_breakpoints: {
|
|
46
|
+
type: "boolean",
|
|
47
|
+
description: "If true, restores breakpoints saved from previous sessions. " \
|
|
48
|
+
"Useful for re-running the same script after a crash with identical breakpoints. " \
|
|
49
|
+
"Default: false (starts fresh without inheriting previous breakpoints).",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["file"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
def call(file:, args: [], port: nil, breakpoints: nil, restore_breakpoints: nil, server_context:)
|
|
57
|
+
manager = server_context[:session_manager]
|
|
58
|
+
|
|
59
|
+
# Clean up dead sessions from previous runs
|
|
60
|
+
cleaned = manager.cleanup_dead_sessions
|
|
61
|
+
# Check if there are still active sessions
|
|
62
|
+
still_active = manager.active_sessions.select { |s| s[:connected] }
|
|
63
|
+
|
|
64
|
+
# Clear saved breakpoints unless explicitly restoring.
|
|
65
|
+
# Explicit breakpoints parameter takes precedence over restore.
|
|
66
|
+
manager.clear_breakpoint_specs if !restore_breakpoints || breakpoints&.any?
|
|
67
|
+
|
|
68
|
+
unless File.exist?(file)
|
|
69
|
+
return MCP::Tool::Response.new([{ type: "text", text: "Error: File not found: #{file}" }])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Verify rdbg is available
|
|
73
|
+
unless system("which rdbg > /dev/null 2>&1")
|
|
74
|
+
return MCP::Tool::Response.new([{ type: "text", text:
|
|
75
|
+
"Error: 'rdbg' command not found. Install the debug gem: gem install debug" }])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Start rdbg with --open so we can connect to it.
|
|
79
|
+
# When initial breakpoints are specified, omit --nonstop so the program
|
|
80
|
+
# pauses at line 1, giving us time to set breakpoints before execution.
|
|
81
|
+
debug_port = port || find_available_port
|
|
82
|
+
has_initial_bps = breakpoints&.any?
|
|
83
|
+
cmd = ["rdbg", "--open", "--port=#{debug_port}"]
|
|
84
|
+
cmd << "--nonstop" unless has_initial_bps
|
|
85
|
+
cmd += ["--", file, *args]
|
|
86
|
+
|
|
87
|
+
# Capture stdout/stderr to temp files for post-mortem diagnostics
|
|
88
|
+
stdout_tmpfile = Tempfile.create(["debug-mcp-stdout-", ".log"])
|
|
89
|
+
stdout_path = stdout_tmpfile.path
|
|
90
|
+
stdout_tmpfile.close
|
|
91
|
+
|
|
92
|
+
stderr_tmpfile = Tempfile.create(["debug-mcp-stderr-", ".log"])
|
|
93
|
+
stderr_path = stderr_tmpfile.path
|
|
94
|
+
stderr_tmpfile.close
|
|
95
|
+
|
|
96
|
+
pid = spawn(*cmd, out: stdout_path, err: stderr_path)
|
|
97
|
+
wait_thread = Process.detach(pid)
|
|
98
|
+
|
|
99
|
+
# Wait for the debug server to be ready
|
|
100
|
+
connected = false
|
|
101
|
+
10.times do
|
|
102
|
+
sleep 0.5
|
|
103
|
+
|
|
104
|
+
# Check if the process is still alive
|
|
105
|
+
unless process_alive?(pid)
|
|
106
|
+
return MCP::Tool::Response.new([{ type: "text", text:
|
|
107
|
+
"Error: Script exited immediately (PID: #{pid}). " \
|
|
108
|
+
"Check the script for syntax errors or missing dependencies." }])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
result = manager.connect(host: "localhost", port: debug_port)
|
|
113
|
+
connected = true
|
|
114
|
+
|
|
115
|
+
# Store metadata on the client for post-mortem diagnostics and rerun
|
|
116
|
+
client = manager.client(result[:session_id])
|
|
117
|
+
client.stdout_file = stdout_path
|
|
118
|
+
client.stderr_file = stderr_path
|
|
119
|
+
client.wait_thread = wait_thread
|
|
120
|
+
client.script_file = file
|
|
121
|
+
client.script_args = args
|
|
122
|
+
|
|
123
|
+
initial_output = result[:output]
|
|
124
|
+
|
|
125
|
+
# Auto-skip if stopped at internal Ruby code (e.g., bundled_gems.rb due to SIGURG)
|
|
126
|
+
initial_output, skipped = skip_internal_code(client, initial_output)
|
|
127
|
+
|
|
128
|
+
# Set initial breakpoints and continue past the line-1 stop
|
|
129
|
+
bp_results = []
|
|
130
|
+
deferred_bps = []
|
|
131
|
+
if has_initial_bps
|
|
132
|
+
breakpoints.each do |bp|
|
|
133
|
+
bp_cmd = bp.start_with?("catch ") ? bp : "break #{bp}"
|
|
134
|
+
bp_output = client.send_command(bp_cmd)
|
|
135
|
+
first_line = bp_output.lines.first&.strip || ""
|
|
136
|
+
|
|
137
|
+
if first_line.include?("Unknown") || first_line.include?("not found")
|
|
138
|
+
# Class not defined yet at line 1 — defer until after continue
|
|
139
|
+
deferred_bps << { spec: bp, cmd: bp_cmd, reason: first_line }
|
|
140
|
+
else
|
|
141
|
+
display = first_line.include?("duplicated") ? "Already set (reused existing)" : first_line
|
|
142
|
+
bp_results << { spec: bp, output: display }
|
|
143
|
+
manager.record_breakpoint(bp_cmd)
|
|
144
|
+
end
|
|
145
|
+
rescue DebugMcp::Error => e
|
|
146
|
+
bp_results << { spec: bp, error: e.message }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Continue past the initial line-1 stop (loads class definitions)
|
|
150
|
+
begin
|
|
151
|
+
initial_output = client.send_continue
|
|
152
|
+
initial_output, skipped = skip_internal_code(client, initial_output) unless skipped
|
|
153
|
+
rescue DebugMcp::SessionError => e
|
|
154
|
+
# Program exited before hitting any breakpoint
|
|
155
|
+
text = DebugMcp::ExitMessageBuilder.build_exit_message(
|
|
156
|
+
"Program finished before hitting any breakpoint.", e.final_output, client,
|
|
157
|
+
)
|
|
158
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Retry deferred breakpoints now that classes should be defined.
|
|
162
|
+
# Don't continue again — the program is already stopped at a useful
|
|
163
|
+
# point (debugger statement or an immediate breakpoint).
|
|
164
|
+
deferred_bps.each do |db|
|
|
165
|
+
bp_output = client.send_command(db[:cmd])
|
|
166
|
+
first_line = bp_output.lines.first&.strip || ""
|
|
167
|
+
display = first_line.include?("duplicated") ? "Already set (reused existing)" : first_line
|
|
168
|
+
bp_results << { spec: db[:spec], output: display, deferred: true }
|
|
169
|
+
manager.record_breakpoint(db[:cmd])
|
|
170
|
+
rescue DebugMcp::Error => e
|
|
171
|
+
bp_results << { spec: db[:spec], error: e.message }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
session_notes = []
|
|
176
|
+
if cleaned.any?
|
|
177
|
+
session_notes << "Cleaned up #{cleaned.size} previous session(s): " \
|
|
178
|
+
"#{cleaned.map { |c| c[:session_id] }.join(", ")}"
|
|
179
|
+
end
|
|
180
|
+
if still_active.any?
|
|
181
|
+
session_notes << "Note: #{still_active.size} other session(s) still active " \
|
|
182
|
+
"(#{still_active.map { |s| s[:session_id] }.join(", ")})"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
text = ""
|
|
186
|
+
text += session_notes.join("\n") + "\n\n" if session_notes.any?
|
|
187
|
+
text += "Script started (PID: #{pid}) and connected via port #{debug_port}.\n" \
|
|
188
|
+
"Session ID: #{result[:session_id]}"
|
|
189
|
+
text += "\n(auto-skipped internal code stop)" if skipped
|
|
190
|
+
text += "\n\n#{initial_output}"
|
|
191
|
+
|
|
192
|
+
# Show initial breakpoint results
|
|
193
|
+
if bp_results.any?
|
|
194
|
+
text += "\n\nSet #{bp_results.size} initial breakpoint(s):"
|
|
195
|
+
bp_results.each do |r|
|
|
196
|
+
text += if r[:error]
|
|
197
|
+
"\n #{r[:spec]} -> Error: #{r[:error]}"
|
|
198
|
+
elsif r[:deferred]
|
|
199
|
+
"\n #{r[:spec]} -> #{r[:output]} (set after class loaded)"
|
|
200
|
+
else
|
|
201
|
+
"\n #{r[:spec]} -> #{r[:output]}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Restore breakpoints from previous sessions (skip when initial BPs were provided)
|
|
207
|
+
restored = has_initial_bps ? [] : manager.restore_breakpoints(client)
|
|
208
|
+
if restored.any?
|
|
209
|
+
text += "\n\nRestored #{restored.size} breakpoint(s) from previous session:"
|
|
210
|
+
restored.each do |r|
|
|
211
|
+
text += if r[:error]
|
|
212
|
+
"\n #{r[:spec]} -> Error: #{r[:error]}"
|
|
213
|
+
else
|
|
214
|
+
"\n #{r[:spec]} -> #{r[:output]}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
220
|
+
rescue DebugMcp::Error
|
|
221
|
+
next
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
unless connected
|
|
226
|
+
Process.kill("TERM", pid) rescue nil
|
|
227
|
+
# Read any captured output for diagnostics
|
|
228
|
+
stdout_output = File.read(stdout_path, encoding: "UTF-8").strip rescue nil
|
|
229
|
+
stderr_output = File.read(stderr_path, encoding: "UTF-8").strip rescue nil
|
|
230
|
+
File.delete(stdout_path) rescue nil
|
|
231
|
+
File.delete(stderr_path) rescue nil
|
|
232
|
+
msg = "Error: Script started (PID: #{pid}) but could not connect to debug session " \
|
|
233
|
+
"on port #{debug_port} within 5 seconds. The script may have exited early."
|
|
234
|
+
msg += "\n\nProgram output (stdout):\n#{stdout_output}" if stdout_output && !stdout_output.empty?
|
|
235
|
+
msg += "\n\nProcess stderr:\n#{stderr_output}" if stderr_output && !stderr_output.empty?
|
|
236
|
+
return MCP::Tool::Response.new([{ type: "text", text: msg }])
|
|
237
|
+
end
|
|
238
|
+
rescue Errno::ENOENT => e
|
|
239
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: Command not found: #{e.message}" }])
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.class}: #{e.message}" }])
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
# Patterns indicating the debugger stopped at internal Ruby/gem code
|
|
247
|
+
# rather than user code. This can happen due to SIGURG or other signals.
|
|
248
|
+
INTERNAL_CODE_PATTERNS = [
|
|
249
|
+
%r{/bundled_gems\.rb}i,
|
|
250
|
+
%r{/rubygems/}i,
|
|
251
|
+
%r{/ruby/lib/}i,
|
|
252
|
+
%r{/lib/ruby/\d}i,
|
|
253
|
+
%r{<internal:}i,
|
|
254
|
+
].freeze
|
|
255
|
+
|
|
256
|
+
MAX_SKIP_ATTEMPTS = 5
|
|
257
|
+
|
|
258
|
+
# Check if the debugger stopped at internal code and auto-continue if so.
|
|
259
|
+
# Loops up to MAX_SKIP_ATTEMPTS times to skip through multiple internal stops.
|
|
260
|
+
# Returns [output, skipped] where skipped is true if we continued past internal code.
|
|
261
|
+
def skip_internal_code(client, output)
|
|
262
|
+
skipped = false
|
|
263
|
+
|
|
264
|
+
MAX_SKIP_ATTEMPTS.times do
|
|
265
|
+
break unless INTERNAL_CODE_PATTERNS.any? { |pattern| output.match?(pattern) }
|
|
266
|
+
|
|
267
|
+
output = client.send_continue
|
|
268
|
+
skipped = true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
[output, skipped]
|
|
272
|
+
rescue DebugMcp::Error
|
|
273
|
+
# If continue fails (e.g., program exited), return what we have
|
|
274
|
+
[output, skipped || false]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def find_available_port
|
|
278
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
279
|
+
port = server.addr[1]
|
|
280
|
+
server.close
|
|
281
|
+
port
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def process_alive?(pid)
|
|
285
|
+
Process.kill(0, pid)
|
|
286
|
+
true
|
|
287
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class SetBreakpoint < MCP::Tool
|
|
9
|
+
description "[Control] Set a breakpoint. Three modes are available:\n" \
|
|
10
|
+
"1. Line breakpoint: provide file + line. Pauses when the line is reached. " \
|
|
11
|
+
"Use condition to break only when criteria are met (e.g., condition: \"user.id == 1\"). " \
|
|
12
|
+
"Use one_shot: true to fire only once and auto-remove (useful in loops/blocks).\n" \
|
|
13
|
+
"2. Method breakpoint: provide method (e.g., 'DataPipeline#validate', 'User.find'). " \
|
|
14
|
+
"Pauses when the method is called. Use 'Class#method' for instance methods, " \
|
|
15
|
+
"'Class.method' for class methods. No need to know the file or line number.\n" \
|
|
16
|
+
"3. Exception breakpoint: provide exception_class (e.g., 'NoMethodError') to pause " \
|
|
17
|
+
"when that exception is raised, BEFORE it crashes the process."
|
|
18
|
+
|
|
19
|
+
annotations(
|
|
20
|
+
title: "Set Breakpoint",
|
|
21
|
+
read_only_hint: false,
|
|
22
|
+
destructive_hint: false,
|
|
23
|
+
open_world_hint: false,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
input_schema(
|
|
27
|
+
properties: {
|
|
28
|
+
file: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "File path (e.g., 'app/controllers/users_controller.rb'). Required for line breakpoints.",
|
|
31
|
+
},
|
|
32
|
+
line: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "Line number to break at. Required for line breakpoints.",
|
|
35
|
+
},
|
|
36
|
+
method: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Method name to break on (e.g., 'DataPipeline#validate' for instance method, " \
|
|
39
|
+
"'User.find' for class method). Pauses when the method is called. " \
|
|
40
|
+
"No need to know the file path or line number.",
|
|
41
|
+
},
|
|
42
|
+
exception_class: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Exception class to catch (e.g., 'NoMethodError', 'RuntimeError', 'ArgumentError'). " \
|
|
45
|
+
"When this exception is raised anywhere, execution pauses BEFORE the exception propagates. " \
|
|
46
|
+
"This is the best way to debug crashes — set it before calling continue_execution.",
|
|
47
|
+
},
|
|
48
|
+
condition: {
|
|
49
|
+
type: "string",
|
|
50
|
+
description: "Optional condition expression for line/method breakpoints (e.g., 'user.id == 1')",
|
|
51
|
+
},
|
|
52
|
+
one_shot: {
|
|
53
|
+
type: "boolean",
|
|
54
|
+
description: "If true, the breakpoint fires only once and is automatically removed after " \
|
|
55
|
+
"the first hit. Useful for stopping inside a block/loop without repeated stops. " \
|
|
56
|
+
"Applies to line and method breakpoints.",
|
|
57
|
+
},
|
|
58
|
+
session_id: {
|
|
59
|
+
type: "string",
|
|
60
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
def call(file: nil, line: nil, method: nil, exception_class: nil, condition: nil, one_shot: nil, session_id: nil, server_context:)
|
|
67
|
+
manager = server_context[:session_manager]
|
|
68
|
+
client = manager.client(session_id)
|
|
69
|
+
client.auto_repause!
|
|
70
|
+
|
|
71
|
+
if exception_class
|
|
72
|
+
set_catch_breakpoint(client, manager, exception_class)
|
|
73
|
+
elsif method
|
|
74
|
+
set_method_breakpoint(client, manager, method, condition: condition, one_shot: one_shot)
|
|
75
|
+
elsif file && line
|
|
76
|
+
set_line_breakpoint(client, manager, file, line, condition: condition, one_shot: one_shot)
|
|
77
|
+
else
|
|
78
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
79
|
+
text: "Error: Provide 'file' + 'line' for a line breakpoint, " \
|
|
80
|
+
"'method' for a method breakpoint (e.g., 'User#save'), " \
|
|
81
|
+
"or 'exception_class' for an exception breakpoint." }])
|
|
82
|
+
end
|
|
83
|
+
rescue DebugMcp::Error => e
|
|
84
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def set_line_breakpoint(client, manager, file, line, condition: nil, one_shot: nil)
|
|
90
|
+
command = "break #{file}:#{line}"
|
|
91
|
+
command += " if: #{condition}" if condition
|
|
92
|
+
|
|
93
|
+
output = client.send_command(command)
|
|
94
|
+
output = DebugMcp::StopEventAnnotator.annotate_breakpoint_set(output)
|
|
95
|
+
|
|
96
|
+
# Detect duplicate breakpoint
|
|
97
|
+
if output.include?("duplicated")
|
|
98
|
+
return MCP::Tool::Response.new([{ type: "text", text: annotate_duplicate(output) }])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Detect line adjustment by debug gem
|
|
102
|
+
if (bp_match = output.match(/#\d+\s+BP - Line\s+.+:(\d+)/))
|
|
103
|
+
actual_line = bp_match[1].to_i
|
|
104
|
+
if actual_line != line
|
|
105
|
+
output += "\n\nNote: Breakpoint was set on line #{actual_line} instead of the requested " \
|
|
106
|
+
"line #{line}. The debugger adjusted to the nearest breakable line."
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Record for preservation across sessions (skip one-shot breakpoints)
|
|
111
|
+
manager.record_breakpoint(command) unless one_shot
|
|
112
|
+
|
|
113
|
+
if one_shot
|
|
114
|
+
# Parse breakpoint number from output like "#3 BP - Line /path:47"
|
|
115
|
+
if (match = output.match(/#(\d+)/))
|
|
116
|
+
bp_num = match[1].to_i
|
|
117
|
+
client.register_one_shot(bp_num)
|
|
118
|
+
output += "\n(one-shot: will be auto-removed after first hit)"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
output = append_condition_warning(client, condition, output)
|
|
123
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def set_method_breakpoint(client, manager, method, condition: nil, one_shot: nil)
|
|
127
|
+
command = "break #{method}"
|
|
128
|
+
command += " if: #{condition}" if condition
|
|
129
|
+
|
|
130
|
+
output = client.send_command(command)
|
|
131
|
+
output = DebugMcp::StopEventAnnotator.annotate_breakpoint_set(output)
|
|
132
|
+
|
|
133
|
+
# Detect duplicate breakpoint
|
|
134
|
+
if output.include?("duplicated")
|
|
135
|
+
return MCP::Tool::Response.new([{ type: "text", text: annotate_duplicate(output) }])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
manager.record_breakpoint(command) unless one_shot
|
|
139
|
+
|
|
140
|
+
if one_shot
|
|
141
|
+
if (match = output.match(/#(\d+)/))
|
|
142
|
+
bp_num = match[1].to_i
|
|
143
|
+
client.register_one_shot(bp_num)
|
|
144
|
+
output += "\n(one-shot: will be auto-removed after first hit)"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
output = append_condition_warning(client, condition, output)
|
|
149
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Validate condition syntax and append warning if invalid.
|
|
153
|
+
def append_condition_warning(client, condition, output)
|
|
154
|
+
return output unless condition
|
|
155
|
+
|
|
156
|
+
warning = validate_condition(client, condition)
|
|
157
|
+
warning ? "#{output}\n\n#{warning}" : output
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check condition syntax via RubyVM::InstructionSequence.compile in the target process.
|
|
161
|
+
# Uses Base64 encoding to safely pass arbitrary condition strings without escaping issues.
|
|
162
|
+
# Returns a warning string if syntax error detected, nil otherwise.
|
|
163
|
+
def validate_condition(client, condition)
|
|
164
|
+
encoded = Base64.strict_encode64(condition.encode(Encoding::UTF_8))
|
|
165
|
+
result = client.send_command(
|
|
166
|
+
"p begin; require 'base64'; " \
|
|
167
|
+
"RubyVM::InstructionSequence.compile(Base64.decode64('#{encoded}')); " \
|
|
168
|
+
"nil; rescue SyntaxError => e; e.message; rescue LoadError; " \
|
|
169
|
+
"RubyVM::InstructionSequence.compile(#{condition.inspect}); nil; " \
|
|
170
|
+
"rescue SyntaxError => e; e.message; end",
|
|
171
|
+
)
|
|
172
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
173
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
174
|
+
|
|
175
|
+
# Remove surrounding quotes
|
|
176
|
+
cleaned = cleaned[1..-2] if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
177
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
178
|
+
|
|
179
|
+
"WARNING: Condition may have a syntax error: #{cleaned}\n" \
|
|
180
|
+
"The breakpoint was set but will never fire if the condition is invalid."
|
|
181
|
+
rescue DebugMcp::Error
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Annotate a "duplicated" breakpoint response with a clearer message.
|
|
186
|
+
# Debug gem output example: "#0 BP - Line app/models/user.rb:10 (line) (duplicated)"
|
|
187
|
+
def annotate_duplicate(output)
|
|
188
|
+
bp_id = output.match(/#(\d+)/)&.then { |m| m[1] }
|
|
189
|
+
if bp_id
|
|
190
|
+
"Already set: breakpoint ##{bp_id} exists at this location. No new breakpoint created.\n\n#{output}"
|
|
191
|
+
else
|
|
192
|
+
"Already set: a breakpoint already exists at this location. No new breakpoint created.\n\n#{output}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def set_catch_breakpoint(client, manager, exception_class)
|
|
197
|
+
command = "catch #{exception_class}"
|
|
198
|
+
output = client.send_command(command)
|
|
199
|
+
manager.record_breakpoint(command)
|
|
200
|
+
|
|
201
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class Step < MCP::Tool
|
|
8
|
+
description "[Control] Step into the next method call. Enters called methods to trace " \
|
|
9
|
+
"execution in detail. Use 'next' instead to stay in the current method. " \
|
|
10
|
+
"Use 'finish' to run until the current method/block returns. " \
|
|
11
|
+
"If an exception is raised and rescued during the step, it will be reported automatically."
|
|
12
|
+
|
|
13
|
+
annotations(
|
|
14
|
+
title: "Step Into",
|
|
15
|
+
read_only_hint: false,
|
|
16
|
+
destructive_hint: false,
|
|
17
|
+
open_world_hint: false,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
session_id: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def call(session_id: nil, server_context:)
|
|
31
|
+
client = server_context[:session_manager].client(session_id)
|
|
32
|
+
|
|
33
|
+
output = client.send_command("step")
|
|
34
|
+
|
|
35
|
+
if output.strip.empty? && client.process_finished?
|
|
36
|
+
text = DebugMcp::ExitMessageBuilder.build_exit_message(
|
|
37
|
+
"Program exited during step.", output, client,
|
|
38
|
+
)
|
|
39
|
+
return MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
client.cleanup_one_shot_breakpoints(output)
|
|
43
|
+
output = DebugMcp::StopEventAnnotator.annotate_breakpoint_hit(output)
|
|
44
|
+
output = DebugMcp::StopEventAnnotator.enrich_stop_context(output, client)
|
|
45
|
+
|
|
46
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
47
|
+
rescue DebugMcp::SessionError => e
|
|
48
|
+
text = if e.message.include?("session ended") || e.message.include?("finished execution")
|
|
49
|
+
DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during step.", e.final_output, client)
|
|
50
|
+
else
|
|
51
|
+
"Error: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
54
|
+
rescue DebugMcp::ConnectionError => e
|
|
55
|
+
text = if e.message.include?("Connection lost") || e.message.include?("connection closed")
|
|
56
|
+
DebugMcp::ExitMessageBuilder.build_exit_message("Program exited during step.", e.final_output, client)
|
|
57
|
+
else
|
|
58
|
+
"Error: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
61
|
+
rescue DebugMcp::Error => e
|
|
62
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|