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,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "../rails_helper"
|
|
5
|
+
|
|
6
|
+
module DebugMcp
|
|
7
|
+
module Tools
|
|
8
|
+
class ReadFile < MCP::Tool
|
|
9
|
+
MAX_LINES = 500
|
|
10
|
+
# Max lines to fetch per chunk via debug session (keeps output within debug gem's width limit)
|
|
11
|
+
REMOTE_CHUNK_SIZE = 50
|
|
12
|
+
|
|
13
|
+
description "[Investigation] Read a source file from the debug session's machine. " \
|
|
14
|
+
"Use this to view code around a breakpoint or understand the surrounding logic. " \
|
|
15
|
+
"Relative paths are resolved against the debugged process's working directory."
|
|
16
|
+
|
|
17
|
+
annotations(
|
|
18
|
+
title: "Read Source File",
|
|
19
|
+
read_only_hint: true,
|
|
20
|
+
destructive_hint: false,
|
|
21
|
+
open_world_hint: false,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
input_schema(
|
|
25
|
+
properties: {
|
|
26
|
+
path: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "File path (relative to working directory or absolute)",
|
|
29
|
+
},
|
|
30
|
+
start_line: {
|
|
31
|
+
type: "integer",
|
|
32
|
+
description: "Start line number (1-indexed, optional)",
|
|
33
|
+
},
|
|
34
|
+
end_line: {
|
|
35
|
+
type: "integer",
|
|
36
|
+
description: "End line number (1-indexed, optional)",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ["path"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def call(path:, start_line: nil, end_line: nil, server_context:)
|
|
44
|
+
# Check if we should read via the debug session (remote/Docker connection)
|
|
45
|
+
client = get_client(server_context)
|
|
46
|
+
if client&.remote
|
|
47
|
+
return read_remote_file(client, path, start_line, end_line)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
full_path = resolve_path(path, server_context)
|
|
51
|
+
|
|
52
|
+
unless File.exist?(full_path)
|
|
53
|
+
return MCP::Tool::Response.new([{ type: "text", text: "Error: File not found: #{path}" }])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines = File.readlines(full_path)
|
|
57
|
+
total_lines = lines.length
|
|
58
|
+
|
|
59
|
+
if start_line || end_line
|
|
60
|
+
start_idx = [(start_line || 1) - 1, 0].max
|
|
61
|
+
end_idx = [(end_line || total_lines) - 1, total_lines - 1].min
|
|
62
|
+
end_idx = [end_idx, start_idx + MAX_LINES - 1].min
|
|
63
|
+
selected = lines[start_idx..end_idx]
|
|
64
|
+
content = selected.map.with_index(start_idx + 1) { |line, num| "#{num}: #{line}" }.join
|
|
65
|
+
header = "#{full_path} (lines #{start_idx + 1}-#{end_idx + 1} of #{total_lines})"
|
|
66
|
+
else
|
|
67
|
+
if lines.length > MAX_LINES
|
|
68
|
+
content = lines.first(MAX_LINES).map.with_index(1) { |line, num| "#{num}: #{line}" }.join
|
|
69
|
+
header = "#{full_path} (lines 1-#{MAX_LINES} of #{total_lines}, truncated)"
|
|
70
|
+
else
|
|
71
|
+
content = lines.map.with_index(1) { |line, num| "#{num}: #{line}" }.join
|
|
72
|
+
header = "#{full_path} (#{total_lines} lines)"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
MCP::Tool::Response.new([{ type: "text", text: "#{header}\n\n#{content}" }])
|
|
77
|
+
rescue FileNotFoundError => e
|
|
78
|
+
MCP::Tool::Response.new([{ type: "text", text:
|
|
79
|
+
"Error: File not found: #{e.message}\n\n" \
|
|
80
|
+
"This is a relative path but no active debug session is available to resolve it. " \
|
|
81
|
+
"Use an absolute path, or connect to a debug session first." }])
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.class}: #{e.message}" }])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Get the debug client, or nil if no active session.
|
|
89
|
+
def get_client(server_context)
|
|
90
|
+
server_context[:session_manager].client
|
|
91
|
+
rescue DebugMcp::Error
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Read a file via the debug session (for remote/Docker connections).
|
|
96
|
+
# Uses eval_expr to call File operations in the target process.
|
|
97
|
+
def read_remote_file(client, path, start_line, end_line)
|
|
98
|
+
client.auto_repause!
|
|
99
|
+
|
|
100
|
+
# Resolve relative path via remote cwd
|
|
101
|
+
full_path = if path.start_with?("/")
|
|
102
|
+
path
|
|
103
|
+
else
|
|
104
|
+
cwd = RailsHelper.eval_expr(client, "Dir.pwd")
|
|
105
|
+
cwd ? File.join(cwd, path) : path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check file existence
|
|
109
|
+
exists = RailsHelper.eval_expr(client, "File.exist?(#{full_path.inspect})")
|
|
110
|
+
unless exists == "true"
|
|
111
|
+
return MCP::Tool::Response.new([{ type: "text",
|
|
112
|
+
text: "Error: File not found on remote process: #{full_path}" }])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get total line count
|
|
116
|
+
count_str = RailsHelper.eval_expr(client, "File.readlines(#{full_path.inspect}).size")
|
|
117
|
+
unless count_str
|
|
118
|
+
return MCP::Tool::Response.new([{ type: "text",
|
|
119
|
+
text: "Error reading remote file: failed to get line count for #{full_path}" }])
|
|
120
|
+
end
|
|
121
|
+
total_lines = count_str.to_i
|
|
122
|
+
|
|
123
|
+
# Determine line range
|
|
124
|
+
if start_line || end_line
|
|
125
|
+
start_idx = [(start_line || 1) - 1, 0].max
|
|
126
|
+
end_idx = [(end_line || total_lines) - 1, total_lines - 1].min
|
|
127
|
+
end_idx = [end_idx, start_idx + MAX_LINES - 1].min
|
|
128
|
+
else
|
|
129
|
+
start_idx = 0
|
|
130
|
+
end_idx = [total_lines - 1, MAX_LINES - 1].min
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Fetch lines in chunks to stay within debug gem output width limits
|
|
134
|
+
all_lines = []
|
|
135
|
+
pos = start_idx
|
|
136
|
+
while pos <= end_idx
|
|
137
|
+
chunk_end = [pos + REMOTE_CHUNK_SIZE - 1, end_idx].min
|
|
138
|
+
chunk = RailsHelper.eval_expr(client,
|
|
139
|
+
"File.readlines(#{full_path.inspect})[#{pos}..#{chunk_end}].join")
|
|
140
|
+
break unless chunk
|
|
141
|
+
|
|
142
|
+
all_lines << chunk
|
|
143
|
+
pos = chunk_end + 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
content_raw = all_lines.join
|
|
147
|
+
lines = content_raw.lines
|
|
148
|
+
content = lines.map.with_index(start_idx + 1) { |line, num| "#{num}: #{line}" }.join
|
|
149
|
+
|
|
150
|
+
# Detect partial fetch (chunk returned nil before all lines were retrieved)
|
|
151
|
+
actual_end = start_idx + lines.length - 1
|
|
152
|
+
partial = actual_end < end_idx
|
|
153
|
+
|
|
154
|
+
if start_line || end_line
|
|
155
|
+
if partial
|
|
156
|
+
header = "#{full_path} (lines #{start_idx + 1}-#{actual_end + 1} of #{total_lines}, " \
|
|
157
|
+
"partial: fetch failed after line #{actual_end + 1}) [remote]"
|
|
158
|
+
else
|
|
159
|
+
header = "#{full_path} (lines #{start_idx + 1}-#{end_idx + 1} of #{total_lines}) [remote]"
|
|
160
|
+
end
|
|
161
|
+
elsif partial
|
|
162
|
+
header = "#{full_path} (lines 1-#{actual_end + 1} of #{total_lines}, " \
|
|
163
|
+
"partial: fetch failed after line #{actual_end + 1}) [remote]"
|
|
164
|
+
elsif total_lines > MAX_LINES
|
|
165
|
+
header = "#{full_path} (lines 1-#{MAX_LINES} of #{total_lines}, truncated) [remote]"
|
|
166
|
+
else
|
|
167
|
+
header = "#{full_path} (#{total_lines} lines) [remote]"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
MCP::Tool::Response.new([{ type: "text", text: "#{header}\n\n#{content}" }])
|
|
171
|
+
rescue DebugMcp::Error => e
|
|
172
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error reading remote file: #{e.message}" }])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Resolve relative paths against the debugged process's working directory.
|
|
176
|
+
# Falls back to MCP server's working directory if no active session.
|
|
177
|
+
def resolve_path(path, server_context)
|
|
178
|
+
return File.expand_path(path) if path.start_with?("/") || path.start_with?("~")
|
|
179
|
+
|
|
180
|
+
# Try to get the debugged process's working directory
|
|
181
|
+
cwd = remote_cwd(server_context)
|
|
182
|
+
if cwd
|
|
183
|
+
File.join(cwd, path)
|
|
184
|
+
else
|
|
185
|
+
# No active session — try local resolution but warn in error message
|
|
186
|
+
local_path = File.expand_path(path)
|
|
187
|
+
return local_path if File.exist?(local_path)
|
|
188
|
+
|
|
189
|
+
# File doesn't exist locally either — raise with helpful context
|
|
190
|
+
raise FileNotFoundError, path
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Custom error to distinguish "no session for relative path" from other errors
|
|
195
|
+
class FileNotFoundError < StandardError; end
|
|
196
|
+
|
|
197
|
+
def remote_cwd(server_context)
|
|
198
|
+
client = server_context[:session_manager].client
|
|
199
|
+
client.auto_repause!
|
|
200
|
+
result = client.send_command("p Dir.pwd")
|
|
201
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
202
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
203
|
+
|
|
204
|
+
if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
205
|
+
cleaned = cleaned[1..-2]
|
|
206
|
+
end
|
|
207
|
+
cleaned.empty? ? nil : cleaned
|
|
208
|
+
rescue DebugMcp::Error
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class RemoveBreakpoint < MCP::Tool
|
|
8
|
+
description "[Control] Remove breakpoints. Can specify: " \
|
|
9
|
+
"(1) all: true to remove ALL breakpoints at once, " \
|
|
10
|
+
"(2) file + line for line breakpoints, " \
|
|
11
|
+
"(3) method for method breakpoints (e.g., 'User#save', 'User.find'), " \
|
|
12
|
+
"(4) exception_class for catch breakpoints (e.g., 'NoMethodError'), or " \
|
|
13
|
+
"(5) breakpoint_number as a fallback. " \
|
|
14
|
+
"Using named parameters is recommended over breakpoint_number, " \
|
|
15
|
+
"as numbers can shift when breakpoints are deleted. " \
|
|
16
|
+
"Use 'get_context' to see current breakpoints."
|
|
17
|
+
|
|
18
|
+
annotations(
|
|
19
|
+
title: "Remove Breakpoint",
|
|
20
|
+
read_only_hint: false,
|
|
21
|
+
destructive_hint: false,
|
|
22
|
+
open_world_hint: false,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
input_schema(
|
|
26
|
+
properties: {
|
|
27
|
+
all: {
|
|
28
|
+
type: "boolean",
|
|
29
|
+
description: "If true, remove ALL breakpoints at once. " \
|
|
30
|
+
"This clears all line, method, and catch breakpoints in a single operation.",
|
|
31
|
+
},
|
|
32
|
+
breakpoint_number: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "Breakpoint number to remove (shown in breakpoint listing). " \
|
|
35
|
+
"Use this as a fallback when file+line, method, or exception_class don't apply.",
|
|
36
|
+
},
|
|
37
|
+
file: {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: "File path of the breakpoint to remove (e.g., 'app/models/user.rb'). " \
|
|
40
|
+
"Must be used together with 'line'.",
|
|
41
|
+
},
|
|
42
|
+
line: {
|
|
43
|
+
type: "integer",
|
|
44
|
+
description: "Line number of the breakpoint to remove. Must be used together with 'file'.",
|
|
45
|
+
},
|
|
46
|
+
method: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Method name to remove breakpoints for (e.g., 'User#save', 'DataPipeline#validate'). " \
|
|
49
|
+
"Matches against method breakpoints set with set_breakpoint(method: ...).",
|
|
50
|
+
},
|
|
51
|
+
exception_class: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Exception class name to remove catch breakpoints for (e.g., 'NoMethodError'). " \
|
|
54
|
+
"Removes all catch breakpoints matching this class.",
|
|
55
|
+
},
|
|
56
|
+
session_id: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
def call(all: nil, breakpoint_number: nil, file: nil, line: nil, method: nil, exception_class: nil, session_id: nil, server_context:)
|
|
65
|
+
client = server_context[:session_manager].client(session_id)
|
|
66
|
+
manager = server_context[:session_manager]
|
|
67
|
+
client.auto_repause!
|
|
68
|
+
|
|
69
|
+
if all
|
|
70
|
+
remove_all_breakpoints(client, manager)
|
|
71
|
+
elsif exception_class
|
|
72
|
+
remove_catch_breakpoint(client, manager, exception_class)
|
|
73
|
+
elsif method
|
|
74
|
+
remove_method_breakpoint(client, manager, method)
|
|
75
|
+
elsif file && line
|
|
76
|
+
remove_by_location(client, manager, file, line)
|
|
77
|
+
elsif breakpoint_number
|
|
78
|
+
output = client.send_command("delete #{breakpoint_number}")
|
|
79
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
80
|
+
else
|
|
81
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
82
|
+
text: "Error: Provide 'all: true', 'file' + 'line', 'method', 'exception_class', or 'breakpoint_number'." }])
|
|
83
|
+
end
|
|
84
|
+
rescue DebugMcp::Error => e
|
|
85
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def remove_all_breakpoints(client, manager)
|
|
91
|
+
bp_list = client.send_command("info breakpoints")
|
|
92
|
+
nums = collect_matching_bp_numbers(bp_list) { |_| true }
|
|
93
|
+
|
|
94
|
+
if nums.empty?
|
|
95
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No breakpoints to remove." }])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
delete_breakpoints_reversed(client, nums)
|
|
99
|
+
manager.clear_breakpoint_specs
|
|
100
|
+
|
|
101
|
+
MCP::Tool::Response.new([{ type: "text",
|
|
102
|
+
text: "Deleted all #{nums.size} breakpoint(s)." }])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def remove_by_location(client, manager, file, line)
|
|
106
|
+
bp_list = client.send_command("info breakpoints")
|
|
107
|
+
target = "#{file}:#{line}"
|
|
108
|
+
|
|
109
|
+
nums = collect_matching_bp_numbers(bp_list) { |bp_line| bp_line.include?(target) }
|
|
110
|
+
delete_breakpoints_reversed(client, nums)
|
|
111
|
+
|
|
112
|
+
if nums.any?
|
|
113
|
+
manager.remove_breakpoint_specs_matching(target)
|
|
114
|
+
"Deleted breakpoint ##{nums.join(', #')} at #{target}."
|
|
115
|
+
else
|
|
116
|
+
"No breakpoint found at #{target}.\n\nCurrent breakpoints:\n#{bp_list}"
|
|
117
|
+
end.then { |text| MCP::Tool::Response.new([{ type: "text", text: text }]) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def remove_method_breakpoint(client, manager, method)
|
|
121
|
+
bp_list = client.send_command("info breakpoints")
|
|
122
|
+
|
|
123
|
+
nums = collect_matching_bp_numbers(bp_list) do |bp_line|
|
|
124
|
+
bp_line.include?("BP - Method") && bp_line.match?(/BP - Method\s+#{Regexp.escape(method)}\b/)
|
|
125
|
+
end
|
|
126
|
+
delete_breakpoints_reversed(client, nums)
|
|
127
|
+
|
|
128
|
+
if nums.any?
|
|
129
|
+
manager.remove_breakpoint_specs_matching(method)
|
|
130
|
+
"Deleted method breakpoint ##{nums.join(', #')} for '#{method}'."
|
|
131
|
+
else
|
|
132
|
+
"No method breakpoint found for '#{method}'.\n\nCurrent breakpoints:\n#{bp_list}"
|
|
133
|
+
end.then { |text| MCP::Tool::Response.new([{ type: "text", text: text }]) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def remove_catch_breakpoint(client, manager, exception_class)
|
|
137
|
+
bp_list = client.send_command("info breakpoints")
|
|
138
|
+
|
|
139
|
+
nums = collect_matching_bp_numbers(bp_list) do |bp_line|
|
|
140
|
+
bp_line.include?("BP - Catch") && bp_line.include?("\"#{exception_class}\"")
|
|
141
|
+
end
|
|
142
|
+
delete_breakpoints_reversed(client, nums)
|
|
143
|
+
|
|
144
|
+
if nums.any?
|
|
145
|
+
manager.remove_breakpoint_specs_matching("catch #{exception_class}")
|
|
146
|
+
"Deleted catch breakpoint ##{nums.join(', #')} for '#{exception_class}'."
|
|
147
|
+
else
|
|
148
|
+
"No catch breakpoint found for '#{exception_class}'.\n\nCurrent breakpoints:\n#{bp_list}"
|
|
149
|
+
end.then { |text| MCP::Tool::Response.new([{ type: "text", text: text }]) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Collect breakpoint numbers from info output that match the given block condition.
|
|
153
|
+
def collect_matching_bp_numbers(bp_list)
|
|
154
|
+
nums = []
|
|
155
|
+
bp_list.each_line do |bp_line|
|
|
156
|
+
next unless (match = bp_line.match(/#(\d+)/))
|
|
157
|
+
next unless yield(bp_line)
|
|
158
|
+
|
|
159
|
+
nums << match[1].to_i
|
|
160
|
+
end
|
|
161
|
+
nums
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Delete breakpoints in reverse numerical order to prevent number shifting.
|
|
165
|
+
# The debug gem renumbers breakpoints after each deletion, so deleting
|
|
166
|
+
# higher numbers first ensures lower numbers remain stable.
|
|
167
|
+
def delete_breakpoints_reversed(client, nums)
|
|
168
|
+
nums.sort.reverse_each { |num| client.send_command("delete #{num}") }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class RunDebugCommand < MCP::Tool
|
|
8
|
+
description "[Control] Execute a raw debugger command for advanced operations not covered " \
|
|
9
|
+
"by other tools. Examples: 'up'/'down' (move stack frames), " \
|
|
10
|
+
"'info threads', 'watch @name'. " \
|
|
11
|
+
"Note: For catching exceptions, prefer set_breakpoint(exception_class: 'NoMethodError') instead."
|
|
12
|
+
|
|
13
|
+
annotations(
|
|
14
|
+
title: "Run Debug Command",
|
|
15
|
+
read_only_hint: false,
|
|
16
|
+
destructive_hint: false,
|
|
17
|
+
open_world_hint: false,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
input_schema(
|
|
21
|
+
properties: {
|
|
22
|
+
command: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Debugger command to execute (e.g., 'finish', 'up', 'down', " \
|
|
25
|
+
"'frame 3', 'info threads', 'watch @name', 'catch NoMethodError')",
|
|
26
|
+
},
|
|
27
|
+
session_id: {
|
|
28
|
+
type: "string",
|
|
29
|
+
description: "Debug session ID (uses default session if omitted)",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
required: ["command"],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def call(command:, session_id: nil, server_context:)
|
|
37
|
+
manager = server_context[:session_manager]
|
|
38
|
+
client = manager.client(session_id)
|
|
39
|
+
client.auto_repause!
|
|
40
|
+
|
|
41
|
+
output = client.send_command(command)
|
|
42
|
+
|
|
43
|
+
# Track catch breakpoints for preservation across sessions
|
|
44
|
+
if command.strip =~ /\Acatch\s+(\S+)/
|
|
45
|
+
manager.record_breakpoint(command.strip)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
MCP::Tool::Response.new([{ type: "text", text: output }])
|
|
49
|
+
rescue DebugMcp::Error => e
|
|
50
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.message}" }])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|