Skip to content

Latest commit

 

History

History
233 lines (178 loc) · 9.82 KB

File metadata and controls

233 lines (178 loc) · 9.82 KB

Ruby Debugging with mcp-debugger

mcp-debugger supports Ruby debugging through rdbg, the CLI of Ruby's official debug gem, speaking the Debug Adapter Protocol over TCP.

MCP client ──> mcp-debugger ──> DAP proxy ──TCP/DAP──> rdbg ──> your Ruby program

Two modes are supported:

  • Launch — mcp-debugger starts your script under rdbg and debugs it from the first line.
  • Attach — your program is already running under rdbg --open (locally, in a container, or in a Kubernetes pod) and mcp-debugger connects to its DAP socket directly. No adapter process is spawned; the proxy connects straight to the listening debugger.

Prerequisites

  1. Ruby 2.7+ (Ruby 3.1+ recommended — it bundles the debug gem)
  2. debug gem 1.7+ providing rdbg:
    gem install debug
  3. Verify:
    ruby --version
    rdbg --version

mcp-debugger auto-detects ruby and rdbg from PATH plus common install locations (RubyInstaller C:\RubyXX-x64\bin on Windows, Homebrew, system paths). Override with the RUBY_PATH (or its alias RUBY_EXECUTABLE) and RDBG_PATH environment variables when needed.

Windows note: gem executables are .bat shims, which Node.js refuses to spawn directly. mcp-debugger automatically runs the sibling rdbg Ruby script via your Ruby interpreter instead — no configuration required for RubyInstaller layouts.

Launch mode

create_debug_session  { "language": "ruby", "name": "My Session" }
set_breakpoint        { "sessionId": "...", "file": "/abs/path/app.rb", "line": 15 }
start_debugging       { "sessionId": "...", "scriptPath": "/abs/path/app.rb" }

Under the hood mcp-debugger runs:

rdbg --open --host 127.0.0.1 --port <free-port> -c -- ruby /abs/path/app.rb

rdbg suspends the script at load and waits for the debugger to connect, so breakpoints are configured before any code runs — even for scripts that finish in milliseconds. With stopOnEntry: false (the default) the entry pause is released automatically and execution runs to your first breakpoint; with stopOnEntry: true you get control at the first line.

Conditional breakpoints are supported:

set_breakpoint { "sessionId": "...", "file": "/abs/path/app.rb", "line": 15, "condition": "i == 6" }

While paused, the usual inspection tools work: get_stack_trace, get_scopes (rdbg reports a Local variables scope), get_local_variables, evaluate_expression (evaluated in rdbg's repl context — expressions can read and modify program state), step_over / step_into / step_out, and continue_execution.

Program output

rdbg -c runs the script as a child of the adapter process with inherited stdio, so the program's puts/warn output lands on the adapter's pipes rather than in DAP output events. The proxy forwards those lines as synthesized stdout/stderr entries, so get_output (and the debug://sessions/{id}/output resource) returns the script's output as usual; rdbg's own DEBUGGER: stderr banners are excluded and only appear in the session log.

Because Ruby block-buffers $stdout when it is a pipe, launch mode also injects a small prelude (via ruby -r) that sets $stdout.sync = true and $stderr.sync = true before the script runs. Without it, puts output would only appear in get_output after the process exits; with it, output streams in near-real-time — including while the session is paused at a breakpoint (issue #317). A script that genuinely needs buffered stdout can set $stdout.sync = false itself. Attach mode connects to a process the server did not start, so no prelude is injected there — set $stdout.sync = true in your program if you need mid-run output while attached.

Environment variables and working directory

Launch mode applies dapLaunchArgs.env and dapLaunchArgs.cwd to the debuggee's process at spawn time (because rdbg -c starts the script immediately, the later DAP launch request cannot carry them). An explicit env value there wins over the server's inherited environment; without cwd the debuggee inherits the server's working directory. Attach mode cannot set either — the target process is already running; configure it before starting.

Bundler projects

Pass useBundler through the launch configuration to run the target via bundle exec:

start_debugging {
  "sessionId": "...",
  "scriptPath": "/abs/path/bin/rspec",
  "adapterLaunchConfig": { "useBundler": true }
}

Attach mode

Start your program with an rdbg DAP listener:

# Suspended at load, waiting for a debugger (good for debugging startup):
rdbg --open --host 127.0.0.1 --port 12345 app.rb

# Running immediately, debugger can attach at any time (good for services):
rdbg --open --host 127.0.0.1 --port 12345 --nonstop app.rb

Then attach:

create_debug_session { "language": "ruby", "name": "Attach Session" }
attach_to_process    { "sessionId": "...", "host": "127.0.0.1", "port": 12345 }

Attach pauses the target (mcp-debugger issues an explicit pause if the program was already running), so you can set breakpoints and inspect immediately. Detach with:

detach_from_process { "sessionId": "...", "terminateProcess": false }

The target keeps running after detach, and rdbg keeps listening — you can re-attach later. Pass terminateProcess: true to kill the target instead.

Note: in attach mode there is no adapter process between mcp-debugger and the target, so get_output captures nothing — the program's stdio stays on whatever terminal (or pod log) the process was started in.

Remote attach (containers and Kubernetes)

Because attach connects directly to rdbg's TCP socket, anything that forwards a TCP port gives you remote debugging. A working demo lives in examples/ruby/remote-attach/.

⚠️ Security: the rdbg DAP socket is unauthenticated and allows arbitrary code execution in the target process. Never expose it on a public interface. Reach it only through localhost port mappings, kubectl port-forward, or an SSH tunnel (ssh -L 12345:127.0.0.1:12345 user@host).

Docker

docker build -t ruby-remote-attach:demo examples/ruby/remote-attach
docker run --rm -d --name ruby-demo -p 12345:12345 ruby-remote-attach:demo
create_debug_session { "language": "ruby" }
attach_to_process    { "sessionId": "...", "host": "127.0.0.1", "port": 12345 }
set_breakpoint       { "sessionId": "...", "file": "/app/app.rb", "line": 18 }
continue_execution   { "sessionId": "..." }

Use the container's source paths for breakpoints (/app/app.rb, as reported by get_stack_trace) — the debugger resolves paths against its own filesystem. For attach sessions mcp-debugger skips host-side file existence checks for exactly this reason.

Kubernetes

The same flow works against a pod through kubectl port-forward (verified with a kind cluster and the manifest in the demo directory):

docker build -t ruby-remote-attach:demo examples/ruby/remote-attach
kind create cluster --name ruby-debug-demo
kind load docker-image ruby-remote-attach:demo --name ruby-debug-demo
kubectl apply -f examples/ruby/remote-attach/pod.yaml
kubectl port-forward pod/ruby-remote-attach 12399:12345
attach_to_process { "sessionId": "...", "host": "127.0.0.1", "port": 12399 }

Breakpoints, conditional breakpoints, locals, and expression evaluation all work against the pod exactly as they do locally. For a pod on a real cluster, the only difference is where kubectl port-forward points.

Attaching across a container boundary

Attach sessions send breakpoint paths to the remote rdbg verbatim — rdbg checks them against the debug target's filesystem, not yours. When the debugger and the debuggee see the project at different paths (host mcp-debugger + containerized app, or a containerized mcp-debugger + host app), a path that is valid on your side can fail on the target with <path> is not available. That warning is topology, not breakage:

  • Prefer target-side paths. Set breakpoints using the path the debuggee sees (/app/app.rb inside the container, as reported by get_stack_trace), not the path on your machine.

  • Or map paths with localfsMap. rdbg accepts a localfsMap attach option ("remote_prefix:local_prefix", comma-separated for multiple pairs) that translates paths between the two filesystems. Pass it through attach_to_process:

    attach_to_process {
      "sessionId": "...",
      "host": "127.0.0.1",
      "port": 12345,
      "localfsMap": "/app:/home/user/project"
    }
    
  • Reaching a host-side rdbg from a containerized mcp-debugger: use host.docker.internal as the attach host (add --add-host=host.docker.internal:host-gateway on Linux Docker), and remember the target-side rule still applies — the host rdbg needs host paths.

Troubleshooting

Symptom Likely cause / fix
rdbg not found gem install debug, or set RDBG_PATH to the rdbg executable
Connect timeout on launch Ruby startup can take a few seconds; check the session log under the temp directory for the spawn command and rdbg's stderr
Connect refused on attach Verify the target was started with --open --host --port and the port is reachable (rdbg prints Debugger can attach via TCP/IP)
Breakpoint not verified on attach Use the path as the debuggee sees it (e.g. /app/app.rb in a container), not the host path
Locals empty Make sure the session is paused (breakpoint hit or explicit pause); rdbg reports locals only while stopped

Additional resources