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,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(/&nbsp;/, " ")
360
+ .gsub(/&amp;/, "&")
361
+ .gsub(/&lt;/, "<")
362
+ .gsub(/&gt;/, ">")
363
+ .gsub(/&quot;/, '"')
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DebugMcp
4
+ VERSION = "0.1.2"
5
+ 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