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,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