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