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,515 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "../rails_helper"
|
|
8
|
+
|
|
9
|
+
module DebugMcp
|
|
10
|
+
module Tools
|
|
11
|
+
class TriggerRequest < MCP::Tool
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
HTTP_BREAKPOINT_TIMEOUT = 300
|
|
14
|
+
HTTP_JOIN_TIMEOUT = 5
|
|
15
|
+
|
|
16
|
+
description "[Entry Point] Send an HTTP request to a Rails app running under the debugger. " \
|
|
17
|
+
"If a breakpoint is set, execution pauses there and you can inspect the state. " \
|
|
18
|
+
"If no breakpoint is hit, the HTTP response is returned. " \
|
|
19
|
+
"Use this with 'set_breakpoint' to debug specific Rails controller actions. " \
|
|
20
|
+
"IMPORTANT: This tool automatically resumes the paused process before sending the request. " \
|
|
21
|
+
"You do NOT need to call 'continue_execution' first — just set your breakpoints, then call this tool. " \
|
|
22
|
+
"For non-GET requests to Rails, CSRF protection is automatically disabled during the request."
|
|
23
|
+
|
|
24
|
+
annotations(
|
|
25
|
+
title: "Trigger HTTP Request",
|
|
26
|
+
read_only_hint: false,
|
|
27
|
+
destructive_hint: false,
|
|
28
|
+
open_world_hint: true,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
input_schema(
|
|
32
|
+
properties: {
|
|
33
|
+
method: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
36
|
+
description: "HTTP method",
|
|
37
|
+
},
|
|
38
|
+
url: {
|
|
39
|
+
type: "string",
|
|
40
|
+
description: "Request URL (e.g., 'http://localhost:3000/users/1')",
|
|
41
|
+
},
|
|
42
|
+
headers: {
|
|
43
|
+
type: "object",
|
|
44
|
+
description: "HTTP headers as key-value pairs",
|
|
45
|
+
},
|
|
46
|
+
body: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Request body (for POST/PUT/PATCH). JSON bodies are auto-detected.",
|
|
49
|
+
},
|
|
50
|
+
cookies: {
|
|
51
|
+
type: "object",
|
|
52
|
+
description: "Cookies to send as key-value pairs (e.g., {\"_session_id\": \"abc123\"})",
|
|
53
|
+
},
|
|
54
|
+
skip_csrf: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
description: "Control CSRF handling: true=always disable, false=never disable, omit=auto-detect Rails",
|
|
57
|
+
},
|
|
58
|
+
timeout: {
|
|
59
|
+
type: "integer",
|
|
60
|
+
description: "Request timeout in seconds (default: #{DEFAULT_TIMEOUT})",
|
|
61
|
+
},
|
|
62
|
+
session_id: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Debug session ID to monitor for breakpoint hits (uses default if omitted)",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ["method", "url"],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
MAX_LOG_BYTES = 4000
|
|
72
|
+
|
|
73
|
+
def call(method:, url:, headers: {}, body: nil, cookies: nil, skip_csrf: nil,
|
|
74
|
+
timeout: nil, session_id: nil, server_context:)
|
|
75
|
+
manager = server_context[:session_manager]
|
|
76
|
+
timeout_sec = timeout || DEFAULT_TIMEOUT
|
|
77
|
+
|
|
78
|
+
# Auto-detect Content-Type if body is present and no Content-Type header set
|
|
79
|
+
headers = (headers || {}).dup
|
|
80
|
+
if body && !headers.any? { |k, _| k.to_s.downcase == "content-type" }
|
|
81
|
+
headers["Content-Type"] = detect_content_type(body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build Cookie header from cookies hash
|
|
85
|
+
if cookies && !cookies.empty?
|
|
86
|
+
cookie_str = cookies.map { |k, v| "#{k}=#{v}" }.join("; ")
|
|
87
|
+
existing = headers.find { |k, _| k.to_s.downcase == "cookie" }
|
|
88
|
+
if existing
|
|
89
|
+
headers[existing[0]] = "#{existing[1]}; #{cookie_str}"
|
|
90
|
+
else
|
|
91
|
+
headers["Cookie"] = cookie_str
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# CSRF handling: disable forgery protection for non-GET requests on Rails
|
|
96
|
+
csrf_disabled = false
|
|
97
|
+
client = nil
|
|
98
|
+
log_capture = nil
|
|
99
|
+
begin
|
|
100
|
+
client = manager.client(session_id)
|
|
101
|
+
# Recover paused state if a previous timeout left @paused=false
|
|
102
|
+
# while the process is actually still paused in the debugger.
|
|
103
|
+
begin
|
|
104
|
+
client.auto_repause!
|
|
105
|
+
rescue DebugMcp::Error
|
|
106
|
+
# Best-effort: proceed with current paused state
|
|
107
|
+
end
|
|
108
|
+
# Only send debug commands if the process is paused (at an input prompt).
|
|
109
|
+
# After a continue_execution timeout, the process is running and sending
|
|
110
|
+
# commands would violate the debug protocol, causing connection loss.
|
|
111
|
+
if client.paused
|
|
112
|
+
if method != "GET" && should_disable_csrf?(skip_csrf, client)
|
|
113
|
+
csrf_disabled = temporarily_disable_csrf(client)
|
|
114
|
+
end
|
|
115
|
+
# Snapshot log file position before request for Rails log capture
|
|
116
|
+
log_capture = start_log_capture(client)
|
|
117
|
+
end
|
|
118
|
+
rescue DebugMcp::SessionError
|
|
119
|
+
client = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
begin
|
|
123
|
+
response = if client&.connected?
|
|
124
|
+
handle_with_debug_session(client, method, url, headers, body, timeout_sec)
|
|
125
|
+
else
|
|
126
|
+
handle_without_session(method, url, headers, body, timeout_sec)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
append_captured_logs(response, log_capture)
|
|
130
|
+
ensure
|
|
131
|
+
# Only restore CSRF when the process is paused (at a breakpoint).
|
|
132
|
+
# If the process is running (interrupted/timeout), sending commands
|
|
133
|
+
# would corrupt the debug protocol and cause session disconnection.
|
|
134
|
+
if csrf_disabled && client&.connected? && client.paused
|
|
135
|
+
restore_csrf(client)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
MCP::Tool::Response.new([{ type: "text", text: "Error: #{e.class}: #{e.message}" }])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def handle_with_debug_session(client, method, url, headers, body, timeout)
|
|
145
|
+
http_holder = { response: nil, error: nil, done: false }
|
|
146
|
+
|
|
147
|
+
if client.paused
|
|
148
|
+
# Start HTTP request in a background thread (concurrent with continue).
|
|
149
|
+
# Use a long HTTP timeout so the request survives while the user
|
|
150
|
+
# investigates at a breakpoint (the BP wait timeout is separate).
|
|
151
|
+
http_thread = start_http_thread(method, url, headers, body, HTTP_BREAKPOINT_TIMEOUT, http_holder)
|
|
152
|
+
|
|
153
|
+
pending_output = client.ensure_paused(timeout: 2)
|
|
154
|
+
|
|
155
|
+
if pending_output&.include?("Stop by")
|
|
156
|
+
return build_breakpoint_response(client, method, url, pending_output,
|
|
157
|
+
http_thread: http_thread, http_holder: http_holder)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Process is confirmed paused. Resume and wait for breakpoint.
|
|
161
|
+
# The HTTP request (sent concurrently) will trigger the breakpoint.
|
|
162
|
+
result = client.continue_and_wait(timeout: timeout) { http_holder[:done] }
|
|
163
|
+
else
|
|
164
|
+
# Process is running (e.g., after continue_execution timeout).
|
|
165
|
+
# Start HTTP request, then wait for the breakpoint to be hit.
|
|
166
|
+
http_thread = start_http_thread(method, url, headers, body, HTTP_BREAKPOINT_TIMEOUT, http_holder)
|
|
167
|
+
result = client.wait_for_breakpoint(timeout: timeout) { http_holder[:done] }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
handle_debug_result(result, client, method, url, http_thread, http_holder, timeout)
|
|
171
|
+
rescue DebugMcp::SessionError, DebugMcp::ConnectionError => e
|
|
172
|
+
# Debug session died — wait for HTTP response
|
|
173
|
+
http_thread&.join(timeout)
|
|
174
|
+
if http_holder[:done] && http_holder[:response]
|
|
175
|
+
text = "Debug session lost: #{e.message}\n\n#{format_response(http_holder[:response])}"
|
|
176
|
+
elsif http_holder[:done] && http_holder[:error]
|
|
177
|
+
text = "Debug session lost: #{e.message}\nHTTP error: #{http_holder[:error].message}"
|
|
178
|
+
else
|
|
179
|
+
text = "Error: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def handle_without_session(method, url, headers, body, timeout)
|
|
185
|
+
response = send_http_request(method, url, headers, body, timeout)
|
|
186
|
+
text = format_response(response)
|
|
187
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
MCP::Tool::Response.new([{ type: "text", text: "Request error: #{e.message}" }])
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def handle_debug_result(result, client, method, url, http_thread, http_holder, timeout)
|
|
193
|
+
case result[:type]
|
|
194
|
+
when :breakpoint
|
|
195
|
+
build_breakpoint_response(client, method, url, result[:output],
|
|
196
|
+
http_thread: http_thread, http_holder: http_holder)
|
|
197
|
+
|
|
198
|
+
when :interrupted
|
|
199
|
+
# HTTP response triggered the interrupt — wait for thread to finish
|
|
200
|
+
http_thread.join(HTTP_JOIN_TIMEOUT)
|
|
201
|
+
repaused = attempt_repause_after_no_hit(client)
|
|
202
|
+
build_http_done_response(method, url, http_holder, repaused: repaused)
|
|
203
|
+
|
|
204
|
+
when :timeout, :timeout_with_output
|
|
205
|
+
# Neither breakpoint nor HTTP response in time
|
|
206
|
+
http_thread.join(HTTP_JOIN_TIMEOUT) # Give HTTP a bit more time
|
|
207
|
+
if http_holder[:done]
|
|
208
|
+
repaused = attempt_repause_after_no_hit(client)
|
|
209
|
+
build_http_done_response(method, url, http_holder, repaused: repaused)
|
|
210
|
+
else
|
|
211
|
+
recovery_note = attempt_timeout_recovery(client)
|
|
212
|
+
text = "HTTP #{method} #{url}\n\n" \
|
|
213
|
+
"No breakpoint was hit and the request has not completed after #{timeout}s.\n" \
|
|
214
|
+
"Possible causes:\n" \
|
|
215
|
+
" - No breakpoints are set on the code path for this request\n" \
|
|
216
|
+
" - The URL may be incorrect (check the path and port)\n" \
|
|
217
|
+
" - The server may be processing a long-running operation\n" \
|
|
218
|
+
" - The 'c' command may not have been processed by the debug gem\n\n"
|
|
219
|
+
text += recovery_note unless recovery_note.empty?
|
|
220
|
+
text += breakpoint_diagnostics(client)
|
|
221
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def build_breakpoint_response(client, method, url, bp_output,
|
|
227
|
+
http_thread: nil, http_holder: nil)
|
|
228
|
+
client.cleanup_one_shot_breakpoints(bp_output)
|
|
229
|
+
bp_output = StopEventAnnotator.annotate_breakpoint_hit(bp_output)
|
|
230
|
+
bp_output = StopEventAnnotator.enrich_stop_context(bp_output, client)
|
|
231
|
+
|
|
232
|
+
# Save pending HTTP info so continue_execution can retrieve the response
|
|
233
|
+
if http_thread && http_holder
|
|
234
|
+
client.pending_http = { thread: http_thread, holder: http_holder,
|
|
235
|
+
method: method, url: url, started_at: Time.now }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
text = "HTTP #{method} #{url} — request sent.\n\n" \
|
|
239
|
+
"Breakpoint hit:\n#{bp_output}\n\n" \
|
|
240
|
+
"The request is paused at the breakpoint. " \
|
|
241
|
+
"Use 'get_context' to inspect variables, " \
|
|
242
|
+
"then 'continue_execution' to let the request complete and see the HTTP response."
|
|
243
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
RUNNING_HINT = "The process is now running — to debug further, " \
|
|
247
|
+
"set breakpoints and use 'trigger_request' again, " \
|
|
248
|
+
"or use 'connect' to reconnect (current session will be replaced)."
|
|
249
|
+
|
|
250
|
+
REPAUSED_HINT = "The process has been re-paused. " \
|
|
251
|
+
"You can set breakpoints, evaluate code, or use 'trigger_request' again."
|
|
252
|
+
|
|
253
|
+
RUNNING_DIAGNOSTICS_HINT = "The process is running and cannot be inspected directly.\n" \
|
|
254
|
+
"To retry: verify breakpoint paths with 'set_breakpoint', then call 'trigger_request' again.\n" \
|
|
255
|
+
"If the process seems stuck, use 'disconnect' to detach and 'connect' to re-attach.\n"
|
|
256
|
+
|
|
257
|
+
def build_http_done_response(method, url, http_holder, repaused: false)
|
|
258
|
+
hint = repaused ? REPAUSED_HINT : RUNNING_HINT
|
|
259
|
+
if http_holder[:error]
|
|
260
|
+
text = "HTTP #{method} #{url}\n\nRequest error: #{http_holder[:error].message}\n\n#{hint}"
|
|
261
|
+
elsif http_holder[:response]
|
|
262
|
+
text = "HTTP #{method} #{url}\n\nNo breakpoint hit. #{hint}\n\n#{format_response(http_holder[:response])}"
|
|
263
|
+
else
|
|
264
|
+
text = "HTTP #{method} #{url}\n\nUnexpected state: request completed without response.\n\n#{hint}"
|
|
265
|
+
end
|
|
266
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def detect_content_type(body)
|
|
270
|
+
stripped = body.strip
|
|
271
|
+
if stripped.start_with?("{") || stripped.start_with?("[")
|
|
272
|
+
"application/json"
|
|
273
|
+
else
|
|
274
|
+
"application/x-www-form-urlencoded"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def should_disable_csrf?(skip_csrf, client)
|
|
279
|
+
return skip_csrf unless skip_csrf.nil?
|
|
280
|
+
|
|
281
|
+
# Auto-detect: disable if connected to a Rails app
|
|
282
|
+
RailsHelper.rails?(client)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def temporarily_disable_csrf(client)
|
|
286
|
+
result = client.send_command(
|
|
287
|
+
"p defined?(ActionController::Base) && ActionController::Base.allow_forgery_protection",
|
|
288
|
+
)
|
|
289
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
290
|
+
return false unless cleaned == "true"
|
|
291
|
+
|
|
292
|
+
client.send_command("ActionController::Base.allow_forgery_protection = false")
|
|
293
|
+
true
|
|
294
|
+
rescue DebugMcp::Error
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def restore_csrf(client)
|
|
299
|
+
client.send_command("ActionController::Base.allow_forgery_protection = true")
|
|
300
|
+
rescue DebugMcp::Error
|
|
301
|
+
# Best-effort: session may have ended
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def format_response(resp)
|
|
305
|
+
parts = []
|
|
306
|
+
status = resp[:status]
|
|
307
|
+
parts << "HTTP #{status}"
|
|
308
|
+
|
|
309
|
+
# Show redirect location prominently
|
|
310
|
+
headers = resp[:headers] || {}
|
|
311
|
+
location = headers["location"]&.first
|
|
312
|
+
parts << "Location: #{location}" if location
|
|
313
|
+
|
|
314
|
+
# Show Set-Cookie headers
|
|
315
|
+
set_cookies = headers["set-cookie"]
|
|
316
|
+
if set_cookies && !set_cookies.empty?
|
|
317
|
+
parts << "Set-Cookie: #{set_cookies.join("; ")}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
parts << ""
|
|
321
|
+
|
|
322
|
+
# Format body based on content type
|
|
323
|
+
body = resp[:body]
|
|
324
|
+
content_type = headers["content-type"]&.first || ""
|
|
325
|
+
|
|
326
|
+
if body.nil? || body.empty?
|
|
327
|
+
parts << "(empty body)"
|
|
328
|
+
elsif content_type.include?("application/json")
|
|
329
|
+
parts << format_json_body(body)
|
|
330
|
+
elsif content_type.include?("text/html")
|
|
331
|
+
parts << format_html_body(body)
|
|
332
|
+
else
|
|
333
|
+
parts << body
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
parts.join("\n")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def format_json_body(body)
|
|
340
|
+
parsed = JSON.parse(body)
|
|
341
|
+
JSON.pretty_generate(parsed)
|
|
342
|
+
rescue JSON::ParserError
|
|
343
|
+
body
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def format_html_body(body)
|
|
347
|
+
parts = []
|
|
348
|
+
parts << "Content-Length: #{body.bytesize}"
|
|
349
|
+
|
|
350
|
+
# Extract title
|
|
351
|
+
if (match = body.match(/<title[^>]*>(.*?)<\/title>/im))
|
|
352
|
+
parts << "Title: #{match[1].strip}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Strip tags and extract text content
|
|
356
|
+
text = body.gsub(/<script[^>]*>.*?<\/script>/im, " ")
|
|
357
|
+
.gsub(/<style[^>]*>.*?<\/style>/im, " ")
|
|
358
|
+
.gsub(/<[^>]+>/, " ")
|
|
359
|
+
.gsub(/ /, " ")
|
|
360
|
+
.gsub(/&/, "&")
|
|
361
|
+
.gsub(/</, "<")
|
|
362
|
+
.gsub(/>/, ">")
|
|
363
|
+
.gsub(/"/, '"')
|
|
364
|
+
.gsub(/&#\d+;/, "")
|
|
365
|
+
.gsub(/\s+/, " ")
|
|
366
|
+
.strip
|
|
367
|
+
|
|
368
|
+
if text.empty?
|
|
369
|
+
parts << "Body: (no text content)"
|
|
370
|
+
elsif text.length > 500
|
|
371
|
+
parts << "Body text (first 500 chars):\n#{text[0, 500]}..."
|
|
372
|
+
else
|
|
373
|
+
parts << "Body text:\n#{text}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
parts.join("\n")
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Attempt to re-pause the process after HTTP completed with no breakpoint hit.
|
|
380
|
+
# Returns true if the process was successfully re-paused, false otherwise.
|
|
381
|
+
def attempt_repause_after_no_hit(client)
|
|
382
|
+
return false unless client&.connected?
|
|
383
|
+
return true if client.paused # Already paused
|
|
384
|
+
|
|
385
|
+
client.auto_repause!
|
|
386
|
+
client.paused
|
|
387
|
+
rescue DebugMcp::Error
|
|
388
|
+
false
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Attempt to recover the session after a double timeout
|
|
392
|
+
# (both breakpoint wait and HTTP request timed out).
|
|
393
|
+
# Tries auto_repause! to re-establish control of the process.
|
|
394
|
+
# Returns a recovery note string (may be empty).
|
|
395
|
+
def attempt_timeout_recovery(client)
|
|
396
|
+
return "" unless client&.connected?
|
|
397
|
+
return "" if client.paused # Already paused, no recovery needed
|
|
398
|
+
|
|
399
|
+
client.auto_repause!
|
|
400
|
+
"Recovery: Successfully re-paused the process. " \
|
|
401
|
+
"You can set breakpoints and retry, or use 'disconnect' to detach.\n\n"
|
|
402
|
+
rescue DebugMcp::Error
|
|
403
|
+
""
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Build diagnostic info about current breakpoints for timeout/no-hit messages.
|
|
407
|
+
def breakpoint_diagnostics(client)
|
|
408
|
+
return RUNNING_DIAGNOSTICS_HINT unless client&.connected? && client.paused
|
|
409
|
+
|
|
410
|
+
bp_list = client.send_command("info breakpoints")
|
|
411
|
+
cleaned = bp_list.strip
|
|
412
|
+
if cleaned.empty? || cleaned.include?("No breakpoints")
|
|
413
|
+
"Current breakpoints: (none set)\n" \
|
|
414
|
+
"Hint: Use 'set_breakpoint' to add a breakpoint before calling trigger_request.\n"
|
|
415
|
+
else
|
|
416
|
+
"Current breakpoints:\n#{cleaned}\n\n" \
|
|
417
|
+
"Verify that the breakpoint file paths match your request's code path.\n"
|
|
418
|
+
end
|
|
419
|
+
rescue DebugMcp::Error
|
|
420
|
+
RUNNING_DIAGNOSTICS_HINT
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Snapshot the Rails log file position before the request.
|
|
424
|
+
# Returns { path:, position: } or nil if not available.
|
|
425
|
+
def start_log_capture(client)
|
|
426
|
+
return nil unless RailsHelper.rails?(client)
|
|
427
|
+
|
|
428
|
+
log_path = RailsHelper.log_file_path(client)
|
|
429
|
+
return nil unless log_path && File.exist?(log_path)
|
|
430
|
+
|
|
431
|
+
{ path: log_path, position: File.size(log_path) }
|
|
432
|
+
rescue StandardError
|
|
433
|
+
nil
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Read new log entries since the snapshot and append to the response.
|
|
437
|
+
def append_captured_logs(response, log_capture)
|
|
438
|
+
return response unless log_capture
|
|
439
|
+
|
|
440
|
+
logs = read_log_diff(log_capture[:path], log_capture[:position])
|
|
441
|
+
return response if logs.nil? || logs.empty?
|
|
442
|
+
|
|
443
|
+
# Append log section to the existing response text
|
|
444
|
+
existing = response.content.first
|
|
445
|
+
return response unless existing.is_a?(Hash) && existing[:type] == "text"
|
|
446
|
+
|
|
447
|
+
log_section = "\n\n--- Server Log ---\n#{logs}"
|
|
448
|
+
updated_text = existing[:text] + log_section
|
|
449
|
+
MCP::Tool::Response.new([{ type: "text", text: updated_text }])
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Read log file content from a saved position.
|
|
453
|
+
# Returns the new log content (truncated if too long) or nil.
|
|
454
|
+
def read_log_diff(log_path, start_position)
|
|
455
|
+
return nil unless File.exist?(log_path)
|
|
456
|
+
|
|
457
|
+
current_size = File.size(log_path)
|
|
458
|
+
return nil if current_size <= start_position
|
|
459
|
+
|
|
460
|
+
bytes_to_read = current_size - start_position
|
|
461
|
+
content = File.binread(log_path, bytes_to_read, start_position)
|
|
462
|
+
content = content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
463
|
+
content.strip!
|
|
464
|
+
return nil if content.empty?
|
|
465
|
+
|
|
466
|
+
if content.length > MAX_LOG_BYTES
|
|
467
|
+
content = content[0, MAX_LOG_BYTES] + "\n... (log truncated, #{bytes_to_read} bytes total)"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
content
|
|
471
|
+
rescue StandardError
|
|
472
|
+
nil
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def start_http_thread(method, url, headers, body, timeout, http_holder)
|
|
476
|
+
Thread.new do
|
|
477
|
+
http_holder[:response] = send_http_request(method, url, headers, body, timeout)
|
|
478
|
+
rescue StandardError => e
|
|
479
|
+
http_holder[:error] = e
|
|
480
|
+
ensure
|
|
481
|
+
http_holder[:done] = true
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def send_http_request(method, url, headers, body, timeout)
|
|
486
|
+
uri = URI.parse(url)
|
|
487
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
488
|
+
http.open_timeout = timeout
|
|
489
|
+
http.read_timeout = timeout
|
|
490
|
+
http.use_ssl = uri.scheme == "https"
|
|
491
|
+
|
|
492
|
+
request_class = {
|
|
493
|
+
"GET" => Net::HTTP::Get,
|
|
494
|
+
"POST" => Net::HTTP::Post,
|
|
495
|
+
"PUT" => Net::HTTP::Put,
|
|
496
|
+
"PATCH" => Net::HTTP::Patch,
|
|
497
|
+
"DELETE" => Net::HTTP::Delete,
|
|
498
|
+
}[method]
|
|
499
|
+
|
|
500
|
+
request = request_class.new(uri)
|
|
501
|
+
headers.each { |k, v| request[k] = v } if headers
|
|
502
|
+
request.body = body if body
|
|
503
|
+
|
|
504
|
+
response = http.request(request)
|
|
505
|
+
|
|
506
|
+
{
|
|
507
|
+
status: "#{response.code} #{response.message}",
|
|
508
|
+
headers: response.to_hash,
|
|
509
|
+
body: response.body&.force_encoding("UTF-8"),
|
|
510
|
+
}
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
data/lib/debug_mcp.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "debug_mcp/version"
|
|
4
|
+
require_relative "debug_mcp/debug_client"
|
|
5
|
+
require_relative "debug_mcp/session_manager"
|
|
6
|
+
require_relative "debug_mcp/exit_message_builder"
|
|
7
|
+
require_relative "debug_mcp/stop_event_annotator"
|
|
8
|
+
require_relative "debug_mcp/tcp_session_discovery"
|
|
9
|
+
require_relative "debug_mcp/server"
|
|
10
|
+
|
|
11
|
+
module DebugMcp
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
class ConnectionError < Error
|
|
15
|
+
attr_reader :final_output
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, final_output: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@final_output = final_output
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class SessionError < Error
|
|
24
|
+
attr_reader :final_output
|
|
25
|
+
|
|
26
|
+
def initialize(message = nil, final_output: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@final_output = final_output
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class TimeoutError < Error
|
|
33
|
+
attr_reader :final_output
|
|
34
|
+
|
|
35
|
+
def initialize(message = nil, final_output: nil)
|
|
36
|
+
super(message)
|
|
37
|
+
@final_output = final_output
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|