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,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugMcp
|
|
4
|
+
module ExitMessageBuilder
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Build a detailed exit message with exception detection.
|
|
8
|
+
# Parses stderr and debugger output to determine whether the program
|
|
9
|
+
# exited normally or due to an unhandled exception.
|
|
10
|
+
def build_exit_message(header, final_output, client)
|
|
11
|
+
# Wait for the process to fully exit so all output is flushed to files.
|
|
12
|
+
# wait_thread is set by run_script; nil for connect sessions.
|
|
13
|
+
exit_status = wait_for_process(client)
|
|
14
|
+
|
|
15
|
+
stderr = client&.read_stderr_output
|
|
16
|
+
stdout = client&.read_stdout_output
|
|
17
|
+
|
|
18
|
+
# Try to detect exception from stderr first, then fall back to debugger output
|
|
19
|
+
exception_info = detect_exception(stderr) || detect_exception(final_output)
|
|
20
|
+
|
|
21
|
+
parts = []
|
|
22
|
+
|
|
23
|
+
# Build a clear header with exit status
|
|
24
|
+
if exit_status
|
|
25
|
+
if exit_status.success?
|
|
26
|
+
parts << "#{header}\nExit status: 0 (success)"
|
|
27
|
+
elsif exit_status.signaled?
|
|
28
|
+
parts << "#{header}\nKilled by signal #{exit_status.termsig}"
|
|
29
|
+
else
|
|
30
|
+
parts << "#{header}\nExit status: #{exit_status.exitstatus} (error)"
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
parts << header
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if exception_info
|
|
37
|
+
parts << "Unhandled exception: #{exception_info}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
parts << "Debugger output:\n#{final_output}" if final_output
|
|
41
|
+
parts << "Program output (stdout):\n#{stdout}" if stdout
|
|
42
|
+
parts << "Process stderr:\n#{stderr}" if stderr
|
|
43
|
+
|
|
44
|
+
if stdout.nil? && stderr.nil?
|
|
45
|
+
# Connect session: no captured output, guide toward run_script
|
|
46
|
+
tip = "stdout/stderr are not captured for sessions started with 'connect'."
|
|
47
|
+
if exception_info
|
|
48
|
+
tip += "\nCheck the terminal where the debug process was started for the full stack trace."
|
|
49
|
+
else
|
|
50
|
+
tip += "\nThe program may have exited due to an unhandled exception — " \
|
|
51
|
+
"check the terminal where the debug process was started for details."
|
|
52
|
+
end
|
|
53
|
+
tip += "\n\nTo get better diagnostics next time:\n" \
|
|
54
|
+
" - Use 'run_script' instead of 'connect' to capture stdout/stderr automatically\n" \
|
|
55
|
+
" - Use set_breakpoint(exception_class: 'NoMethodError') to stop BEFORE " \
|
|
56
|
+
"an exception crashes the process"
|
|
57
|
+
parts << tip
|
|
58
|
+
else
|
|
59
|
+
# run_script session: session is over, guide toward restart
|
|
60
|
+
tip = "This debug session has ended."
|
|
61
|
+
rerun_hint = build_rerun_hint(client)
|
|
62
|
+
if exception_info
|
|
63
|
+
exc_class = exception_info.split(":").first
|
|
64
|
+
tip += "\n\nTo debug the crash:\n" \
|
|
65
|
+
" 1. #{rerun_hint}\n" \
|
|
66
|
+
" 2. set_breakpoint(exception_class: '#{exc_class}') to catch the exception before it crashes"
|
|
67
|
+
else
|
|
68
|
+
tip += "\n\nTo restart: #{rerun_hint}"
|
|
69
|
+
end
|
|
70
|
+
parts << tip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
parts.join("\n\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Wait for the spawned process to exit (up to 5 seconds).
|
|
77
|
+
# Returns Process::Status or nil.
|
|
78
|
+
def wait_for_process(client)
|
|
79
|
+
return nil unless client&.wait_thread
|
|
80
|
+
|
|
81
|
+
client.wait_thread.join(5)
|
|
82
|
+
client.wait_thread.value
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Build a concrete run_script hint with the exact file/args from the session.
|
|
88
|
+
def build_rerun_hint(client)
|
|
89
|
+
script_file = client&.script_file
|
|
90
|
+
return "run_script(file: '...', restore_breakpoints: true)" unless script_file
|
|
91
|
+
|
|
92
|
+
args_part = client.script_args&.any? ? ", args: #{client.script_args.inspect}" : ""
|
|
93
|
+
"run_script(file: '#{script_file}'#{args_part}, restore_breakpoints: true)"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Detect Ruby exception from output text.
|
|
97
|
+
# Returns "ExceptionClass: message" string, or nil if no exception found.
|
|
98
|
+
def detect_exception(output)
|
|
99
|
+
return nil unless output && !output.empty?
|
|
100
|
+
|
|
101
|
+
# Ruby stack trace format:
|
|
102
|
+
# /path/to/file.rb:10:in `method': message (ExceptionClass)
|
|
103
|
+
if output =~ /:\d+:in `.+': (.+) \((\w+(?:::\w+)*)\)/
|
|
104
|
+
"#{$2}: #{$1}"
|
|
105
|
+
# Alternative format (e.g., from raise without stack trace context):
|
|
106
|
+
# ExceptionClass: message
|
|
107
|
+
elsif output =~ /\A\s*((?:\w+::)*\w+(?:Error|Exception)): (.+)/
|
|
108
|
+
"#{$1}: #{$2.strip}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DebugMcp
|
|
4
|
+
module PendingHttpHelper
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Check for pending HTTP request status and return a note string.
|
|
8
|
+
# Returns nil when there is no pending HTTP or when it is still running (normal state).
|
|
9
|
+
def pending_http_note(client)
|
|
10
|
+
pending = client.pending_http
|
|
11
|
+
return nil unless pending
|
|
12
|
+
|
|
13
|
+
holder = pending[:holder]
|
|
14
|
+
return nil unless holder[:done]
|
|
15
|
+
|
|
16
|
+
if holder[:error]
|
|
17
|
+
"Note: HTTP request (#{pending[:method]} #{pending[:url]}) failed: #{holder[:error].message}. " \
|
|
18
|
+
"Use 'continue_execution' to resume."
|
|
19
|
+
elsif holder[:response]
|
|
20
|
+
"Note: HTTP response received (#{holder[:response][:status]}). " \
|
|
21
|
+
"Use 'continue_execution' to see the full response."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module DebugMcp
|
|
6
|
+
module RailsHelper
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Verify that the connected process is a Rails application.
|
|
10
|
+
# Raises SessionError if Rails is not defined.
|
|
11
|
+
def require_rails!(client)
|
|
12
|
+
result = client.send_command("p defined?(Rails)")
|
|
13
|
+
unless result.strip.sub(/\A=> /, "").include?("constant")
|
|
14
|
+
raise DebugMcp::SessionError, "Not a Rails application. This tool requires a connected Rails process."
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Check if Rails is available without raising.
|
|
19
|
+
# Returns true if the connected process has Rails loaded.
|
|
20
|
+
def rails?(client)
|
|
21
|
+
result = client.send_command("p defined?(Rails)")
|
|
22
|
+
result.strip.sub(/\A=> /, "").include?("constant")
|
|
23
|
+
rescue DebugMcp::Error
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if the client is in signal trap context.
|
|
28
|
+
# Returns true if thread operations are restricted.
|
|
29
|
+
def trap_context?(client)
|
|
30
|
+
client.respond_to?(:in_trap_context?) && client.in_trap_context?
|
|
31
|
+
rescue DebugMcp::Error
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- Base64 script execution (NOT trap-safe) ---
|
|
36
|
+
|
|
37
|
+
# Execute a multi-line Ruby script via Base64 encoding in the target process.
|
|
38
|
+
# Returns the cleaned string result, or nil if the script returned nil/empty.
|
|
39
|
+
# Raises DebugMcp::Error on communication failure.
|
|
40
|
+
def run_base64_script(client, code, timeout: 15)
|
|
41
|
+
encoded = Base64.strict_encode64(code.encode(Encoding::UTF_8))
|
|
42
|
+
command = "require 'base64'; eval(::Base64.decode64('#{encoded}').force_encoding('UTF-8'))"
|
|
43
|
+
output = client.send_command(command, timeout: timeout)
|
|
44
|
+
clean_script_output(output)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Clean debug gem output from a script that returns a string value.
|
|
48
|
+
# Strips "=> " prefix, removes surrounding quotes, and unescapes \\n.
|
|
49
|
+
def clean_script_output(output)
|
|
50
|
+
cleaned = output.strip.sub(/\A=> /, "")
|
|
51
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
52
|
+
|
|
53
|
+
if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
54
|
+
cleaned = cleaned[1..-2].gsub('\\n', "\n").gsub('\\"', '"')
|
|
55
|
+
end
|
|
56
|
+
cleaned.empty? ? nil : cleaned
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# --- Lightweight methods (trap-safe, no Base64/require/puts) ---
|
|
60
|
+
|
|
61
|
+
# Evaluate a simple `p` expression and return the cleaned string result.
|
|
62
|
+
# Uses `p` (not `puts`) because `p` output is captured as the expression
|
|
63
|
+
# result by the debug gem, which works even in signal trap context.
|
|
64
|
+
# Returns nil if the result is nil or evaluation fails.
|
|
65
|
+
def eval_expr(client, expr)
|
|
66
|
+
result = client.send_command("p #{expr}")
|
|
67
|
+
cleaned = result.strip.sub(/\A=> /, "")
|
|
68
|
+
return nil if cleaned == "nil" || cleaned.empty?
|
|
69
|
+
|
|
70
|
+
if cleaned.start_with?('"') && cleaned.end_with?('"')
|
|
71
|
+
cleaned = cleaned[1..-2]
|
|
72
|
+
cleaned = cleaned.gsub('\\n', "\n").gsub('\\"', '"').gsub("\\\\", "\\")
|
|
73
|
+
end
|
|
74
|
+
cleaned.empty? ? nil : cleaned
|
|
75
|
+
rescue DebugMcp::Error
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Fetch routes using a single `p` expression (trap-safe).
|
|
80
|
+
# Returns { count: Integer, lines: String } or nil on failure.
|
|
81
|
+
def lightweight_routes(client, controller: nil, path: nil, limit: 200)
|
|
82
|
+
filter_parts = ["r.defaults[:controller].to_s!=''"]
|
|
83
|
+
filter_parts << "r.defaults[:controller].to_s.include?(#{controller.inspect})" if controller
|
|
84
|
+
filter_parts << "r.path.spec.to_s.include?(#{path.inspect})" if path
|
|
85
|
+
filter = filter_parts.join(" && ")
|
|
86
|
+
|
|
87
|
+
count_output = eval_expr(client,
|
|
88
|
+
"Rails.application.routes.routes.count{|r|r.defaults[:controller].to_s!=''}")
|
|
89
|
+
return nil if count_output.nil? # eval failed — can't access routes
|
|
90
|
+
|
|
91
|
+
count = count_output.to_i
|
|
92
|
+
|
|
93
|
+
expr = "Rails.application.routes.routes.select{|r|#{filter}}." \
|
|
94
|
+
"first(#{limit}).map{|r|" \
|
|
95
|
+
"r.verb.to_s.ljust(7)+' '+" \
|
|
96
|
+
"r.path.spec.to_s.sub('(.:format)','')+' '+" \
|
|
97
|
+
"r.defaults[:controller].to_s+'#'+r.defaults[:action].to_s+" \
|
|
98
|
+
"(r.name.to_s.empty? ? '' : ' ('+r.name.to_s+')')}.join(\"\\n\")"
|
|
99
|
+
lines = eval_expr(client, expr)
|
|
100
|
+
|
|
101
|
+
{ count: count, lines: lines || "" }
|
|
102
|
+
rescue DebugMcp::Error
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Fetch a compact route summary for connect output (trap-safe).
|
|
107
|
+
# Returns { count: Integer, samples: [String] } or nil.
|
|
108
|
+
def route_summary(client, limit: 5)
|
|
109
|
+
count_output = eval_expr(client,
|
|
110
|
+
"Rails.application.routes.routes.count{|r|r.defaults[:controller].to_s!=''}")
|
|
111
|
+
return nil if count_output.nil?
|
|
112
|
+
|
|
113
|
+
count = count_output.to_i
|
|
114
|
+
|
|
115
|
+
sample_expr = "Rails.application.routes.routes.select{|r|r.defaults[:controller].to_s!=''}." \
|
|
116
|
+
"first(#{limit}).map{|r|" \
|
|
117
|
+
"r.verb.to_s.ljust(7)+' '+" \
|
|
118
|
+
"r.path.spec.to_s.sub('(.:format)','')+' '+" \
|
|
119
|
+
"r.defaults[:controller].to_s+'#'+r.defaults[:action].to_s}.join(\"\\n\")"
|
|
120
|
+
samples = eval_expr(client, sample_expr)
|
|
121
|
+
|
|
122
|
+
{ count: count, samples: samples&.split("\n") || [] }
|
|
123
|
+
rescue DebugMcp::Error
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# List model files from app/models/ using Dir.glob (trap-safe).
|
|
128
|
+
# Returns array of model file names (e.g., ["user", "post", "admin/account"]) or nil.
|
|
129
|
+
def model_files(client)
|
|
130
|
+
output = eval_expr(client,
|
|
131
|
+
"Dir.glob(Rails.root.join('app','models','**','*.rb').to_s)." \
|
|
132
|
+
"sort.map{|f|f.split('/models/').last.sub('.rb','')}.reject{|f|f=='application_record'}.join(', ')")
|
|
133
|
+
return nil if output.nil? || output.empty?
|
|
134
|
+
|
|
135
|
+
output.split(", ")
|
|
136
|
+
rescue DebugMcp::Error
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Get the path to the Rails log file (trap-safe).
|
|
141
|
+
# Returns the absolute path string or nil if not determinable.
|
|
142
|
+
def log_file_path(client)
|
|
143
|
+
root = eval_expr(client, "Rails.root.to_s")
|
|
144
|
+
env = eval_expr(client, "Rails.env")
|
|
145
|
+
return nil unless root && env
|
|
146
|
+
|
|
147
|
+
"#{root}/log/#{env}.log"
|
|
148
|
+
rescue DebugMcp::Error
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
TRAP_CONTEXT_HINT = "Note: The process may be in signal trap context (common with Puma). " \
|
|
153
|
+
"Set a breakpoint and use trigger_request to escape trap context first."
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
require_relative "tools/list_debug_sessions"
|
|
5
|
+
require_relative "tools/connect"
|
|
6
|
+
require_relative "tools/evaluate_code"
|
|
7
|
+
require_relative "tools/inspect_object"
|
|
8
|
+
require_relative "tools/get_context"
|
|
9
|
+
require_relative "tools/get_source"
|
|
10
|
+
require_relative "tools/read_file"
|
|
11
|
+
require_relative "tools/list_files"
|
|
12
|
+
require_relative "tools/continue_execution"
|
|
13
|
+
require_relative "tools/set_breakpoint"
|
|
14
|
+
require_relative "tools/remove_breakpoint"
|
|
15
|
+
require_relative "tools/step"
|
|
16
|
+
require_relative "tools/next"
|
|
17
|
+
require_relative "tools/finish"
|
|
18
|
+
require_relative "tools/run_debug_command"
|
|
19
|
+
require_relative "tools/run_script"
|
|
20
|
+
require_relative "tools/trigger_request"
|
|
21
|
+
require_relative "tools/list_paused_sessions"
|
|
22
|
+
require_relative "tools/disconnect"
|
|
23
|
+
require_relative "tools/rails_info"
|
|
24
|
+
require_relative "tools/rails_routes"
|
|
25
|
+
require_relative "tools/rails_model"
|
|
26
|
+
|
|
27
|
+
module DebugMcp
|
|
28
|
+
class Server
|
|
29
|
+
# Base tools: always available
|
|
30
|
+
BASE_TOOLS = [
|
|
31
|
+
# Discovery & connection
|
|
32
|
+
Tools::ListDebugSessions,
|
|
33
|
+
Tools::Connect,
|
|
34
|
+
Tools::ListPausedSessions,
|
|
35
|
+
# Investigation
|
|
36
|
+
Tools::EvaluateCode,
|
|
37
|
+
Tools::InspectObject,
|
|
38
|
+
Tools::GetContext,
|
|
39
|
+
Tools::GetSource,
|
|
40
|
+
Tools::ReadFile,
|
|
41
|
+
Tools::ListFiles,
|
|
42
|
+
# Control
|
|
43
|
+
Tools::SetBreakpoint,
|
|
44
|
+
Tools::RemoveBreakpoint,
|
|
45
|
+
Tools::ContinueExecution,
|
|
46
|
+
Tools::Step,
|
|
47
|
+
Tools::Next,
|
|
48
|
+
Tools::Finish,
|
|
49
|
+
Tools::RunDebugCommand,
|
|
50
|
+
Tools::Disconnect,
|
|
51
|
+
# Entry points
|
|
52
|
+
Tools::RunScript,
|
|
53
|
+
Tools::TriggerRequest,
|
|
54
|
+
].freeze
|
|
55
|
+
|
|
56
|
+
# Rails tools: dynamically added when a Rails process is detected
|
|
57
|
+
RAILS_TOOLS = [
|
|
58
|
+
Tools::RailsInfo,
|
|
59
|
+
Tools::RailsRoutes,
|
|
60
|
+
Tools::RailsModel,
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# All tools (used in tests and for reference)
|
|
64
|
+
TOOLS = (BASE_TOOLS + RAILS_TOOLS).freeze
|
|
65
|
+
|
|
66
|
+
DEFAULT_HTTP_PORT = 6029
|
|
67
|
+
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
68
|
+
|
|
69
|
+
INSTRUCTIONS = <<~TEXT
|
|
70
|
+
debug-mcp is an MCP server that connects LLM agents to Ruby's debug gem. \
|
|
71
|
+
It lets you attach to live Ruby processes, inspect variables, evaluate code, \
|
|
72
|
+
set breakpoints, and control execution.
|
|
73
|
+
|
|
74
|
+
Use these tools when the user asks to debug a Ruby program, investigate runtime behavior, \
|
|
75
|
+
or inspect the state of a running process.
|
|
76
|
+
|
|
77
|
+
Typical workflow:
|
|
78
|
+
1. run_script to launch a Ruby script under the debugger (recommended — captures stdout/stderr). \
|
|
79
|
+
Use connect only when attaching to an already-running process (e.g., Rails server).
|
|
80
|
+
2. get_context to see the current state (variables, call stack, breakpoints)
|
|
81
|
+
3. evaluate_code / inspect_object to investigate specific values
|
|
82
|
+
4. set_breakpoint / next / step / continue_execution to control the flow
|
|
83
|
+
|
|
84
|
+
When to use get_context:
|
|
85
|
+
- After connecting or run_script — to understand the initial stop point
|
|
86
|
+
- After continue_execution hits a breakpoint — the stop output shows source and stack, \
|
|
87
|
+
but get_context gives you local/instance variables and the full breakpoint list
|
|
88
|
+
- When you need to check what breakpoints are currently set
|
|
89
|
+
- When variables or call stack context would help decide the next debugging action
|
|
90
|
+
- You do NOT need get_context after every next/step if the output already shows \
|
|
91
|
+
the information you need (source listing and stop location are included in the response)
|
|
92
|
+
- For a quick breakpoint check without fetching all context, use \
|
|
93
|
+
run_debug_command(command: "info breakpoints")
|
|
94
|
+
|
|
95
|
+
IMPORTANT — connect pauses the target process:
|
|
96
|
+
When you use 'connect', the target process is PAUSED. It will not serve requests or \
|
|
97
|
+
respond to Ctrl+C until you resume it. Always use 'continue_execution' when done \
|
|
98
|
+
investigating, or 'disconnect' to detach (which also resumes the process). \
|
|
99
|
+
Never leave a connected session idle without resuming — the user won't be able to \
|
|
100
|
+
interact with the target process.
|
|
101
|
+
|
|
102
|
+
Signal trap context (Puma/threaded servers):
|
|
103
|
+
When connecting to a process like Puma, the debug gem pauses it via SIGURG. \
|
|
104
|
+
This puts the process in a signal trap context where thread operations (Mutex, \
|
|
105
|
+
DB connection pools, autoloading) fail with ThreadError. \
|
|
106
|
+
Simple expressions (variables, constants, p/pp) still work in trap context. \
|
|
107
|
+
The 'connect' tool automatically detects and tries to escape this. \
|
|
108
|
+
Additionally, after 'continue_execution', investigation tools (evaluate_code, \
|
|
109
|
+
get_context, etc.) automatically re-pause and re-escape trap context — \
|
|
110
|
+
you do not need to manually set breakpoints again to escape. \
|
|
111
|
+
If auto-escape fails (common when the process is blocked on IO like IO.select): \
|
|
112
|
+
1. set_breakpoint on a line in your controller/action \
|
|
113
|
+
2. trigger_request to send an HTTP request — this auto-resumes the process \
|
|
114
|
+
3. Once stopped at the breakpoint, all operations work normally \
|
|
115
|
+
Do NOT manually call continue_execution before trigger_request — \
|
|
116
|
+
trigger_request handles resuming the process automatically.
|
|
117
|
+
|
|
118
|
+
Rails debugging:
|
|
119
|
+
When you connect to a Rails process, additional Rails-specific tools become available \
|
|
120
|
+
automatically (rails_info, rails_routes, rails_model). These tools are NOT shown \
|
|
121
|
+
when debugging plain Ruby scripts.
|
|
122
|
+
|
|
123
|
+
Rails debugging workflow:
|
|
124
|
+
1. Start the Rails server with debugging: RUBY_DEBUG_OPEN=true bin/rails server
|
|
125
|
+
2. connect to attach to the Rails process (auto-detects trap context)
|
|
126
|
+
3. set_breakpoint on a controller action (e.g., app/controllers/users_controller.rb:10)
|
|
127
|
+
4. trigger_request to send an HTTP request — this auto-resumes the paused process, \
|
|
128
|
+
sends the request, and waits for the breakpoint to hit. \
|
|
129
|
+
CSRF protection is automatically disabled for non-GET requests. \
|
|
130
|
+
You do NOT need to call continue_execution first.
|
|
131
|
+
5. When the breakpoint hits, use get_context, evaluate_code, and rails_model to \
|
|
132
|
+
inspect the current state and understand model structures
|
|
133
|
+
6. continue_execution to let the request complete and see the response
|
|
134
|
+
7. To debug another request, set new breakpoints and call trigger_request again
|
|
135
|
+
8. When done debugging, use 'disconnect' to detach and resume the server
|
|
136
|
+
|
|
137
|
+
Note: rails_info, rails_routes, and rails_model may not work in trap context. \
|
|
138
|
+
Use them after hitting a breakpoint via trigger_request.
|
|
139
|
+
|
|
140
|
+
Docker / containerized processes:
|
|
141
|
+
When the debug target runs inside a Docker container, use connect with a TCP port \
|
|
142
|
+
or a Unix socket volume mount. \
|
|
143
|
+
TCP: connect(port: 12345) — works out of the box. \
|
|
144
|
+
Unix socket: connect(path: "/shared/rdbg.sock", remote: true) — you MUST pass \
|
|
145
|
+
remote: true because the socket file is local but the process is in a different \
|
|
146
|
+
PID namespace, so OS signals cannot reach it. Without remote: true, pause/resume \
|
|
147
|
+
will fail silently.
|
|
148
|
+
|
|
149
|
+
Security — proper use of evaluate_code:
|
|
150
|
+
evaluate_code is designed EXCLUSIVELY for investigating the runtime state of the debugged \
|
|
151
|
+
process (inspecting variables, checking object state, testing expressions in context). \
|
|
152
|
+
It must NOT be used as a general-purpose code execution tool.
|
|
153
|
+
|
|
154
|
+
PROHIBITED uses of evaluate_code:
|
|
155
|
+
- File I/O: File.write, File.delete, FileUtils, IO.write \
|
|
156
|
+
→ Use your agent's own file tools (Read, Write, Edit) instead
|
|
157
|
+
- System commands: system(), exec(), backtick, %x{}, Open3, spawn \
|
|
158
|
+
→ Use your agent's own Bash/shell tool instead
|
|
159
|
+
- Network requests: Net::HTTP, open-uri, TCPSocket, HTTP client gems \
|
|
160
|
+
→ Use your agent's own HTTP/network tools instead
|
|
161
|
+
- Process manipulation: Process.kill, fork, exit, abort
|
|
162
|
+
- Destructive data operations: destroy_all, delete_all, DROP/TRUNCATE SQL
|
|
163
|
+
|
|
164
|
+
IMPORTANT: If your agent's tools are restricted for a particular operation, \
|
|
165
|
+
you must NOT use evaluate_code to circumvent those restrictions.
|
|
166
|
+
|
|
167
|
+
Quick tool selection guide:
|
|
168
|
+
- "Where am I? What are the variables and breakpoints?" → get_context
|
|
169
|
+
- "Execute a Ruby expression or test a fix" → evaluate_code
|
|
170
|
+
- "See an object's full structure (class, ivars, value)" → inspect_object
|
|
171
|
+
- "Read the source of a method or class" → get_source
|
|
172
|
+
- "Read a file from the debugged process's machine" → read_file
|
|
173
|
+
- "List files or explore directory structure" → list_files
|
|
174
|
+
- "Step to the next line (stay in current method)" → next
|
|
175
|
+
- "Step into a method call" → step
|
|
176
|
+
- "Run until current method/block returns" → finish
|
|
177
|
+
- "Resume until next breakpoint" → continue_execution
|
|
178
|
+
- "Send an HTTP request and wait for breakpoint" → trigger_request
|
|
179
|
+
|
|
180
|
+
Breakpoints in blocks/loops (each, map, select, etc.):
|
|
181
|
+
Line breakpoints inside a block fire on EVERY iteration. If you only need to stop once, \
|
|
182
|
+
use one_shot: true when setting the breakpoint — it auto-removes after the first hit.
|
|
183
|
+
|
|
184
|
+
Typical pattern for Rails debugging:
|
|
185
|
+
set_breakpoint → trigger_request → get_context → evaluate_code → continue_execution
|
|
186
|
+
TEXT
|
|
187
|
+
|
|
188
|
+
# Register Rails tools on an MCP server instance and notify connected clients.
|
|
189
|
+
# Safe to call multiple times — skips already-registered tools.
|
|
190
|
+
def self.register_rails_tools(mcp_server)
|
|
191
|
+
tools_hash = mcp_server.instance_variable_get(:@tools)
|
|
192
|
+
tool_names = mcp_server.instance_variable_get(:@tool_names)
|
|
193
|
+
added = false
|
|
194
|
+
|
|
195
|
+
RAILS_TOOLS.each do |tool_class|
|
|
196
|
+
name = tool_class.name_value
|
|
197
|
+
next if tools_hash.key?(name)
|
|
198
|
+
|
|
199
|
+
tools_hash[name] = tool_class
|
|
200
|
+
tool_names << name
|
|
201
|
+
added = true
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
mcp_server.notify_tools_list_changed if added
|
|
205
|
+
added
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def initialize(transport: nil, port: nil, host: nil, session_timeout: nil, **_)
|
|
209
|
+
@transport_type = transport || "stdio"
|
|
210
|
+
@http_port = port || DEFAULT_HTTP_PORT
|
|
211
|
+
@http_host = host || DEFAULT_HTTP_HOST
|
|
212
|
+
@session_manager = SessionManager.new(
|
|
213
|
+
**(session_timeout ? { timeout: session_timeout } : {}),
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def start
|
|
218
|
+
server_context = { session_manager: @session_manager }
|
|
219
|
+
|
|
220
|
+
server = MCP::Server.new(
|
|
221
|
+
name: "debug-mcp",
|
|
222
|
+
version: DebugMcp::VERSION,
|
|
223
|
+
instructions: INSTRUCTIONS,
|
|
224
|
+
tools: TOOLS,
|
|
225
|
+
server_context: server_context,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Safety net: resume connected processes when the server exits for any reason.
|
|
229
|
+
# This covers cases where Claude Code exits without calling 'disconnect',
|
|
230
|
+
# stdin closes unexpectedly, or the MCP gem calls Kernel.exit directly.
|
|
231
|
+
# disconnect_all is idempotent, so multiple calls (at_exit + ensure + signal) are safe.
|
|
232
|
+
at_exit { @session_manager.disconnect_all }
|
|
233
|
+
|
|
234
|
+
setup_signal_handlers
|
|
235
|
+
|
|
236
|
+
case @transport_type
|
|
237
|
+
when "stdio"
|
|
238
|
+
start_stdio(server)
|
|
239
|
+
when "http"
|
|
240
|
+
start_http(server)
|
|
241
|
+
else
|
|
242
|
+
raise ArgumentError, "Unknown transport: #{@transport_type}"
|
|
243
|
+
end
|
|
244
|
+
ensure
|
|
245
|
+
@session_manager.disconnect_all
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def start_stdio(server)
|
|
251
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
252
|
+
transport.open
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def start_http(server)
|
|
256
|
+
require "webrick"
|
|
257
|
+
|
|
258
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
259
|
+
|
|
260
|
+
webrick = WEBrick::HTTPServer.new(
|
|
261
|
+
Port: @http_port,
|
|
262
|
+
BindAddress: @http_host,
|
|
263
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
|
|
264
|
+
AccessLog: [],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
webrick.mount_proc("/mcp") do |req, res|
|
|
268
|
+
env = build_rack_env(req)
|
|
269
|
+
rack_request = RackRequestAdapter.new(env)
|
|
270
|
+
status, headers, body = transport.handle_request(rack_request)
|
|
271
|
+
|
|
272
|
+
res.status = status
|
|
273
|
+
headers.each { |k, v| res[k] = v }
|
|
274
|
+
|
|
275
|
+
if body.respond_to?(:each)
|
|
276
|
+
output = +""
|
|
277
|
+
body.each { |chunk| output << chunk }
|
|
278
|
+
res.body = output
|
|
279
|
+
elsif body.respond_to?(:call)
|
|
280
|
+
# SSE streaming body
|
|
281
|
+
rd, wr = IO.pipe
|
|
282
|
+
res["Content-Type"] = headers["Content-Type"] || "text/event-stream"
|
|
283
|
+
res["Cache-Control"] = "no-cache"
|
|
284
|
+
res["Connection"] = "keep-alive"
|
|
285
|
+
res.body = rd
|
|
286
|
+
|
|
287
|
+
Thread.new do
|
|
288
|
+
body.call(wr)
|
|
289
|
+
rescue IOError, Errno::EPIPE
|
|
290
|
+
# Client disconnected
|
|
291
|
+
ensure
|
|
292
|
+
wr.close unless wr.closed?
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
$stderr.puts "debug-mcp HTTP server listening on http://#{@http_host}:#{@http_port}/mcp"
|
|
298
|
+
|
|
299
|
+
setup_http_signal_handlers(webrick)
|
|
300
|
+
webrick.start
|
|
301
|
+
ensure
|
|
302
|
+
transport&.close
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Minimal Rack::Request-compatible adapter for WEBrick
|
|
306
|
+
class RackRequestAdapter
|
|
307
|
+
attr_reader :env
|
|
308
|
+
|
|
309
|
+
def initialize(env)
|
|
310
|
+
@env = env
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def body
|
|
314
|
+
@env["rack.input"]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def build_rack_env(req)
|
|
319
|
+
env = {
|
|
320
|
+
"REQUEST_METHOD" => req.request_method,
|
|
321
|
+
"PATH_INFO" => req.path,
|
|
322
|
+
"QUERY_STRING" => req.query_string || "",
|
|
323
|
+
"SERVER_NAME" => @http_host,
|
|
324
|
+
"SERVER_PORT" => @http_port.to_s,
|
|
325
|
+
"rack.input" => StringIO.new(req.body || ""),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
# Map HTTP headers to Rack convention
|
|
329
|
+
req.header.each do |key, values|
|
|
330
|
+
rack_key = "HTTP_#{key.tr("-", "_").upcase}"
|
|
331
|
+
env[rack_key] = values.join(", ")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Ensure key headers are mapped correctly
|
|
335
|
+
env["CONTENT_TYPE"] = req.content_type if req.content_type
|
|
336
|
+
env["HTTP_ACCEPT"] = req["Accept"] if req["Accept"]
|
|
337
|
+
env["HTTP_MCP_SESSION_ID"] = req["Mcp-Session-Id"] if req["Mcp-Session-Id"]
|
|
338
|
+
|
|
339
|
+
env
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def setup_signal_handlers
|
|
343
|
+
%w[INT TERM HUP].each do |sig|
|
|
344
|
+
trap(sig) do
|
|
345
|
+
@session_manager.disconnect_all
|
|
346
|
+
exit(0)
|
|
347
|
+
end
|
|
348
|
+
rescue ArgumentError
|
|
349
|
+
# Signal not supported on this platform (e.g., HUP on Windows)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def setup_http_signal_handlers(webrick)
|
|
354
|
+
%w[INT TERM HUP].each do |sig|
|
|
355
|
+
trap(sig) do
|
|
356
|
+
@session_manager.disconnect_all
|
|
357
|
+
webrick.shutdown
|
|
358
|
+
end
|
|
359
|
+
rescue ArgumentError
|
|
360
|
+
# Signal not supported on this platform
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|