Skip to content

Latest commit

 

History

History
261 lines (202 loc) · 11.6 KB

File metadata and controls

261 lines (202 loc) · 11.6 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: RDBG_PATH may point to either the rdbg.bat/rdbg.cmd shim or the extensionless rdbg Ruby script installed beside it. mcp-debugger runs the extensionless script through your Ruby interpreter because Node.js cannot spawn either form directly. No configuration is required for standard 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

No-build alternative: examples/kubernetes/ruby-app.yaml deploys a debuggable Ruby target from stock ruby:3.3-slim (rdbg is a bundled gem) with the source in a ConfigMap — no docker build or kind load required. See the Kubernetes debugging recipe and its attach presets.

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:

  • Use a shared target-side path. When the client can see the source at the exact same absolute path as rdbg (for example, both containers bind it at /app), opt into rdbg's shared-filesystem mode and set breakpoints with that path:

    attach_to_process {
      "sessionId": "...",
      "host": "ruby-target",
      "port": 12345,
      "adapterConfig": { "localfs": true }
    }
    set_breakpoint { "sessionId": "...", "file": "/app/app.rb", "line": 18 }
    

    mcp-debugger infers localfs from a loopback host only when it is omitted; an explicit boolean wins, which is useful for sibling containers and necessary when a loopback port forward still crosses a filesystem boundary.

  • Or map paths with localfsMap. rdbg accepts a localfsMap attach option ("remote_prefix:local_prefix", comma-separated for multiple pairs) that translates client paths to target paths. Pass it inside adapterConfig:

    attach_to_process {
      "sessionId": "...",
      "host": "127.0.0.1",
      "port": 12345,
      "adapterConfig": {
        "localfsMap": "/app:/home/user/project"
      }
    }
    

    localfsMap uses colons to separate each target/client prefix pair. A Windows drive letter also contains a colon, so mappings involving paths such as C:/work/app are ambiguous in rdbg's current format. For a Windows target, prefer a shared identical path with localfs: true where possible.

  • 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
Launch warns 'initialized' event arrived but the 'initialize' response is still missing rdbg intermittently drops the initialize response frame; the session recovers automatically and continues (adapter capabilities unknown for that run). See the case study
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 If both sides share paths, pass adapterConfig.localfs: true and use the target path. Otherwise pass adapterConfig.localfsMap as target_prefix:client_prefix; Windows drive-letter mappings are ambiguous in rdbg's colon-separated format
Locals empty Make sure the session is paused (breakpoint hit or explicit pause); rdbg reports locals only while stopped

Additional resources