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.
Files changed (122) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +83 -0
  4. data/LICENSE +21 -0
  5. data/README.ja.md +383 -0
  6. data/README.md +384 -0
  7. data/examples/01_simple_bug.rb +43 -0
  8. data/examples/02_data_pipeline.rb +93 -0
  9. data/examples/03_recursion.rb +96 -0
  10. data/examples/RAILS_SCENARIOS.md +350 -0
  11. data/examples/SCENARIOS.md +142 -0
  12. data/examples/rails_test_app/setup.sh +428 -0
  13. data/examples/rails_test_app/testapp/.dockerignore +10 -0
  14. data/examples/rails_test_app/testapp/.ruby-version +1 -0
  15. data/examples/rails_test_app/testapp/Dockerfile +23 -0
  16. data/examples/rails_test_app/testapp/Gemfile +17 -0
  17. data/examples/rails_test_app/testapp/README.md +65 -0
  18. data/examples/rails_test_app/testapp/Rakefile +6 -0
  19. data/examples/rails_test_app/testapp/app/assets/images/.keep +0 -0
  20. data/examples/rails_test_app/testapp/app/assets/stylesheets/application.css +1 -0
  21. data/examples/rails_test_app/testapp/app/controllers/application_controller.rb +4 -0
  22. data/examples/rails_test_app/testapp/app/controllers/concerns/.keep +0 -0
  23. data/examples/rails_test_app/testapp/app/controllers/dashboard_controller.rb +38 -0
  24. data/examples/rails_test_app/testapp/app/controllers/health_controller.rb +11 -0
  25. data/examples/rails_test_app/testapp/app/controllers/orders_controller.rb +100 -0
  26. data/examples/rails_test_app/testapp/app/controllers/posts_controller.rb +82 -0
  27. data/examples/rails_test_app/testapp/app/controllers/sessions_controller.rb +25 -0
  28. data/examples/rails_test_app/testapp/app/controllers/users_controller.rb +44 -0
  29. data/examples/rails_test_app/testapp/app/helpers/application_helper.rb +2 -0
  30. data/examples/rails_test_app/testapp/app/models/application_record.rb +3 -0
  31. data/examples/rails_test_app/testapp/app/models/comment.rb +8 -0
  32. data/examples/rails_test_app/testapp/app/models/concerns/.keep +0 -0
  33. data/examples/rails_test_app/testapp/app/models/order.rb +56 -0
  34. data/examples/rails_test_app/testapp/app/models/order_item.rb +16 -0
  35. data/examples/rails_test_app/testapp/app/models/post.rb +29 -0
  36. data/examples/rails_test_app/testapp/app/models/user.rb +34 -0
  37. data/examples/rails_test_app/testapp/app/services/order_report_service.rb +40 -0
  38. data/examples/rails_test_app/testapp/app/views/layouts/application.html.erb +28 -0
  39. data/examples/rails_test_app/testapp/app/views/pwa/manifest.json.erb +22 -0
  40. data/examples/rails_test_app/testapp/app/views/pwa/service-worker.js +26 -0
  41. data/examples/rails_test_app/testapp/bin/ci +6 -0
  42. data/examples/rails_test_app/testapp/bin/dev +2 -0
  43. data/examples/rails_test_app/testapp/bin/rails +4 -0
  44. data/examples/rails_test_app/testapp/bin/rake +4 -0
  45. data/examples/rails_test_app/testapp/bin/setup +35 -0
  46. data/examples/rails_test_app/testapp/config/application.rb +42 -0
  47. data/examples/rails_test_app/testapp/config/boot.rb +3 -0
  48. data/examples/rails_test_app/testapp/config/ci.rb +14 -0
  49. data/examples/rails_test_app/testapp/config/database.yml +32 -0
  50. data/examples/rails_test_app/testapp/config/environment.rb +5 -0
  51. data/examples/rails_test_app/testapp/config/environments/development.rb +54 -0
  52. data/examples/rails_test_app/testapp/config/environments/production.rb +67 -0
  53. data/examples/rails_test_app/testapp/config/environments/test.rb +42 -0
  54. data/examples/rails_test_app/testapp/config/initializers/content_security_policy.rb +29 -0
  55. data/examples/rails_test_app/testapp/config/initializers/filter_parameter_logging.rb +8 -0
  56. data/examples/rails_test_app/testapp/config/initializers/inflections.rb +16 -0
  57. data/examples/rails_test_app/testapp/config/locales/en.yml +31 -0
  58. data/examples/rails_test_app/testapp/config/puma.rb +39 -0
  59. data/examples/rails_test_app/testapp/config/routes.rb +34 -0
  60. data/examples/rails_test_app/testapp/config.ru +6 -0
  61. data/examples/rails_test_app/testapp/db/migrate/20260216002916_create_users.rb +12 -0
  62. data/examples/rails_test_app/testapp/db/migrate/20260216002919_create_posts.rb +13 -0
  63. data/examples/rails_test_app/testapp/db/migrate/20260216002922_create_comments.rb +11 -0
  64. data/examples/rails_test_app/testapp/db/migrate/20260222000001_create_orders.rb +14 -0
  65. data/examples/rails_test_app/testapp/db/migrate/20260222000002_create_order_items.rb +13 -0
  66. data/examples/rails_test_app/testapp/db/schema.rb +71 -0
  67. data/examples/rails_test_app/testapp/db/seeds.rb +85 -0
  68. data/examples/rails_test_app/testapp/docker-compose.yml +21 -0
  69. data/examples/rails_test_app/testapp/docker-entrypoint.sh +10 -0
  70. data/examples/rails_test_app/testapp/lib/tasks/.keep +0 -0
  71. data/examples/rails_test_app/testapp/log/.keep +0 -0
  72. data/examples/rails_test_app/testapp/public/400.html +135 -0
  73. data/examples/rails_test_app/testapp/public/404.html +135 -0
  74. data/examples/rails_test_app/testapp/public/406-unsupported-browser.html +135 -0
  75. data/examples/rails_test_app/testapp/public/422.html +135 -0
  76. data/examples/rails_test_app/testapp/public/500.html +135 -0
  77. data/examples/rails_test_app/testapp/public/icon.png +0 -0
  78. data/examples/rails_test_app/testapp/public/icon.svg +3 -0
  79. data/examples/rails_test_app/testapp/public/robots.txt +1 -0
  80. data/examples/rails_test_app/testapp/script/.keep +0 -0
  81. data/examples/rails_test_app/testapp/storage/.keep +0 -0
  82. data/examples/rails_test_app/testapp/tmp/.keep +0 -0
  83. data/examples/rails_test_app/testapp/tmp/pids/.keep +0 -0
  84. data/examples/rails_test_app/testapp/tmp/storage/.keep +0 -0
  85. data/examples/rails_test_app/testapp/vendor/.keep +0 -0
  86. data/exe/debug-mcp +39 -0
  87. data/exe/debug-rails +127 -0
  88. data/lib/debug_mcp/client_cleanup.rb +102 -0
  89. data/lib/debug_mcp/code_safety_analyzer.rb +124 -0
  90. data/lib/debug_mcp/debug_client.rb +1143 -0
  91. data/lib/debug_mcp/exit_message_builder.rb +112 -0
  92. data/lib/debug_mcp/pending_http_helper.rb +25 -0
  93. data/lib/debug_mcp/rails_helper.rb +155 -0
  94. data/lib/debug_mcp/server.rb +364 -0
  95. data/lib/debug_mcp/session_manager.rb +436 -0
  96. data/lib/debug_mcp/stop_event_annotator.rb +152 -0
  97. data/lib/debug_mcp/tcp_session_discovery.rb +226 -0
  98. data/lib/debug_mcp/tools/connect.rb +669 -0
  99. data/lib/debug_mcp/tools/continue_execution.rb +161 -0
  100. data/lib/debug_mcp/tools/disconnect.rb +169 -0
  101. data/lib/debug_mcp/tools/evaluate_code.rb +354 -0
  102. data/lib/debug_mcp/tools/finish.rb +84 -0
  103. data/lib/debug_mcp/tools/get_context.rb +217 -0
  104. data/lib/debug_mcp/tools/get_source.rb +193 -0
  105. data/lib/debug_mcp/tools/inspect_object.rb +107 -0
  106. data/lib/debug_mcp/tools/list_debug_sessions.rb +60 -0
  107. data/lib/debug_mcp/tools/list_files.rb +189 -0
  108. data/lib/debug_mcp/tools/list_paused_sessions.rb +108 -0
  109. data/lib/debug_mcp/tools/next.rb +70 -0
  110. data/lib/debug_mcp/tools/rails_info.rb +200 -0
  111. data/lib/debug_mcp/tools/rails_model.rb +362 -0
  112. data/lib/debug_mcp/tools/rails_routes.rb +186 -0
  113. data/lib/debug_mcp/tools/read_file.rb +214 -0
  114. data/lib/debug_mcp/tools/remove_breakpoint.rb +173 -0
  115. data/lib/debug_mcp/tools/run_debug_command.rb +55 -0
  116. data/lib/debug_mcp/tools/run_script.rb +293 -0
  117. data/lib/debug_mcp/tools/set_breakpoint.rb +206 -0
  118. data/lib/debug_mcp/tools/step.rb +67 -0
  119. data/lib/debug_mcp/tools/trigger_request.rb +515 -0
  120. data/lib/debug_mcp/version.rb +5 -0
  121. data/lib/debug_mcp.rb +40 -0
  122. 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