You run PHP in Docker. A bug shows up in an API endpoint — a POST with a JSON
body and an Authorization: Bearer … header. You open PhpStorm, click
"listen for debug connections", fire a request from Postman, hit the
breakpoint, step, inspect, eval, fix. Now imagine doing all of that without
leaving the chat — you describe the bug to Claude Code, opencode, Cursor, or
any MCP-aware agent, and the agent debugs it for you: sets the breakpoint,
fires the real request, reads the stack, inspects variables, steps, evals,
proposes a fix. That's xdbg.
xdbg is an MCP server that connects to Xdebug running in a Docker container
and exposes it as tools — so any AI agent that speaks MCP (Claude Code,
opencode, Cursor) can fire HTTP requests (curl-style) and run CLI commands
inside the container, then drive the resulting debug session: breakpoints,
stack, variables, eval, stepping. All from the chat.
PhpStorm ships its own Xdebug MCP tools, but they only fire GET requests
and don't let you set headers (cookies, auth tokens, Content-Type). For
real API work — POST/PUT/PATCH with JSON bodies and JWT/auth headers — you end
up juggling port forwards and a second terminal. xdbg closes that gap: the
same MCP-driven flow, but with full control over method, headers, and body,
plus CLI/Symfony command debugging and host↔container path translation.
| Pain | Before | With xdbg |
|---|---|---|
| AI can't debug POST/PUT/PATCH | PhpStorm MCP = GET only | xdbg_request takes method, headers, body |
| Auth / cookies / JWT | Nowhere to put the header | Pass headers: {"Authorization": "Bearer …"} (or read from a file to keep secrets out of the chat) |
| CLI / Symfony commands | No MCP path at all | xdbg_run_command "bin/console app:foo" pauses at the breakpoint |
| Host ↔ container paths | Breakpoints need container paths; stacks show container paths | Set breakpoints with host paths; stacks come back as host paths |
| Port conflicts | Two debuggers fight over 9003 | Detects the holder (lsof), waits its turn, tells you who's blocking |
| Port 9003 always busy | Debugger holds the port all session | Binds only during a tool call (xdbg_request, xdbg_run_command, xdbg_listen); releases immediately after — PhpStorm, browser Xdebug, and other tools work freely between calls |
- The container's Xdebug is a DBGp engine. With
xdebug.start_with_request=yesit dials out tohost.docker.internal:9003on every request and waits for commands. xdbglistens on0.0.0.0:9003and drives the engine — set breakpoints, step, eval, read the stack.- The MCP server is one long-lived process, so the session survives across tool calls. Your agent sets a breakpoint, fires the request, inspects variables, steps — all in one conversation.
- The DBGp port is ephemeral — bound only during a tool call and released immediately after. Between calls, port 9003 is free for PhpStorm, browser Xdebug, or any other debugger.
(Hand-drawn SVG — edit docs/xdbg-overview.svg directly if you need
to tweak layout.)
For the full request round-trip with step-by-step sequence, see the architecture diagram (source).
The fastest way: paste this into your chat with Claude Code or opencode:
Install and configure this MCP server for me: https://raw.githubusercontent.com/crazy-goat/xdbg/main/install.mdThe agent will read the guide and walk you through every step.
xdbg also ships a dedicated AI skill (
skills/xdebug-docker-debug/SKILL.md) that gives the agent context on debugging workflows, failure recovery, and best practices — making adoption smoother. The agent will ask you before installing it.Full guide: install.md
go install github.com/crazy-goat/xdbg@latestNote:
proxy.golang.orgcaches versions for a few minutes. If the command fails with a module-path mismatch, install the latest commit directly:GOPROXY=direct go install github.com/crazy-goat/xdbg@main
This puts xdbg in $(go env GOPATH)/bin (default ~/go/bin).
Add it to your PATH (one-time):
export PATH="$(go env GOPATH)/bin:$PATH" # add to ~/.zshrc / ~/.bashrcVerify: xdbg --help.
git clone https://github.com/crazy-goat/xdbg.git
cd xdbg
make install # builds and copies to ~/.local/bin/xdbg(make build produces ./xdbg without installing.)
- Go 1.21+ (only needed for
go install/make) - Docker (or any container runtime) running your PHP app
- Xdebug installed inside the container (the engine), enabled on demand
xdbg is an MCP stdio server: the agent spawns it as a child process and talks JSON-RPC over stdin/stdout. You register it once in your agent's MCP config.
Add an entry under mcp in ~/.config/opencode/opencode.json (or your
project's opencode.json):
Restart opencode (or reconnect MCP). Tools appear as xdbg_*.
Drop a .mcp.json in your project root (or ~/.claude.json for global):
{
"mcpServers": {
"xdbg": {
"command": "xdbg",
"args": [
"mcp",
"--dbg-port", "9003",
"--local-root", "/Users/you/work/your-app",
"--docker-root", "/var/www/your-app",
"--xdebug-enable-cmd", "docker compose exec -T php set-xdebug-on",
"--xdebug-disable-cmd", "docker compose exec -T php set-xdebug-off",
"--xdebug-status-cmd", "docker compose exec -T php get-xdebug-status",
"--container-exec", "docker compose exec -T php"
]
}
}
}Reconnect MCP in Claude Code. Tools appear as mcp__xdbg__*.
| Flag | Default | Purpose |
|---|---|---|
--dbg-port |
9003 |
Port Xdebug dials into (the listener binds 0.0.0.0:<port>) |
--local-root |
— | Host project root (for path translation) |
--docker-root |
— | Container project root (for path translation) |
--xdebug-enable-cmd |
— | Shell command to enable Xdebug in the container |
--xdebug-disable-cmd |
— | Shell command to disable Xdebug in the container |
--xdebug-status-cmd |
— | Shell command to check Xdebug status in the container |
--container-exec |
docker compose exec -T php |
Prefix for running CLI commands inside the container |
Xdebug should be off by default for performance. When you configure
--xdebug-enable-cmd, --xdebug-disable-cmd and --xdebug-status-cmd, the
agent gets three MCP tools — xdbg_container_enable, xdbg_container_disable
and xdbg_container_status — and can turn Xdebug on only for the debug
session, then off again when it's done. No shell access, no manual steps —
the agent handles the full lifecycle itself.
Returns the current debugger state (no session, started, break,
stopping), the file and line where execution is paused (or - when not
paused), and the number of queued breakpoints. Use it as the first call
after firing a request or running a command to see whether the session was
adopted and where the engine stopped. It's safe to call any time, with or
without an active session. It does not advance execution or mutate state.
file is a host path (absolute or project-relative, auto-translated to the
container path); line is 1-based. Sets a line breakpoint. If a session is
already active, the breakpoint is applied immediately and its engine-assigned
id is returned. If no session is active, the breakpoint is queued and applied
automatically on the next session (the next xdbg_request or
xdbg_run_command). Multiple breakpoints can be set before triggering the
request; all of them are applied when the engine connects.
Lists all breakpoints known to the engine, with their ids, state
(enabled/disabled), file (translated back to a host path) and line. When
no session is active, lists the queued breakpoints instead. Use it to verify
what's armed before firing a request. Safe to call any time.
id is the breakpoint id returned by xdbg_set_breakpoint or shown by
xdbg_breakpoint_list. Removes a single breakpoint — both from the engine
(if a session is active) and from the local queue. Returns removed <id> on
success. Call xdbg_breakpoint_list first to find the id.
Removes every breakpoint: queued (not yet applied) and applied (active in the engine). Safe to call with or without an active session. Use it to reset state between debugging scenarios. Returns the number of breakpoints cleared.
url is required; method defaults to GET; headers is an object of
string→string; body is a raw string; timeoutMs defaults to 15000. Fires
an HTTP request at the app with full control over method, headers, and body.
Because the container has xdebug.start_with_request=yes, the request makes
php-fpm dial the DBGp port; xdbg adopts the connection, applies queued
breakpoints, and pauses at the first break. When no breakpoints are set, the
script runs to completion and the request returns. To debug interactively,
set breakpoints first, then call xdbg_request — the tool returns once the
session is paused, and you drive it with xdbg_run / xdbg_step_* / etc.
xdbg_request_from_files(string url, string? method, string? headers_file, string? body_file, int? timeoutMs)
url is required; headers_file is a path to a file with Name: Value
lines (blank lines and # comments ignored — or a JSON object); body_file
is a path to raw body bytes; timeoutMs defaults to 15000. Like
xdbg_request but reads headers and body from disk. Use it when headers
contain sensitive values (JWT tokens, cookies, API keys) that should not
appear inline in the chat or tool arguments. The files are read once, at
call time.
timeoutMs defaults to 30000. Arms the DBGp listener and blocks until the
next engine connection is adopted, then returns. Use it for CLI / Symfony
command debugging: call xdbg_listen first, then launch the command
separately (e.g. docker compose exec -T php php bin/console app:cmd). Once
the tool returns, the session is paused at the script start with breakpoints
applied — drive it with xdbg_run / xdbg_step_* / etc. If no engine
connects within the timeout, returns an error.
command is required (e.g. "bin/console app:my-command --option=value");
timeoutMs defaults to 30000. Runs the command inside the container (prefixed
with --container-exec) and waits for the resulting Xdebug connection. When
no breakpoints are set, the script runs to completion and the command output
is returned. When breakpoints are set, the session pauses at the first break
and the caller drives it with xdbg_run / xdbg_step_* — the command output
is not available until the script finishes. This is the CLI equivalent of
xdbg_request.
Resumes execution after a break — the engine runs until the next breakpoint
or until the script finishes. Returns the new state (break/stopping) and
the current location. When the script finishes, the state becomes stopping
and the response notes (script finished). Call it repeatedly to step
through breakpoints.
Steps into the next line — if the next line is a function call, execution
pauses at the first line inside the called function. Returns the new state
and location. Use it to follow execution into callees. When there's nothing
to step into, behaves like xdbg_step_over.
Steps over the next line — if the line is a function call, the function runs to completion and execution pauses on the next line of the caller. Returns the new state and location. Use it to advance without descending into callees.
Steps out of the current function — execution runs until the current function returns, then pauses at the caller. Returns the new state and location. Use it to escape a function you stepped into by mistake.
Breaks (pauses) execution immediately, as if a breakpoint were hit at the
current line. Returns the new state (break) and location. Use it to
interrupt a long-running xdbg_run and regain control. Only meaningful while
a session is active and running.
Returns the call stack at the current pause point, with each frame's depth,
function name, file (translated to a host path) and line. Returns
(no stack — not paused?) when no session is paused. Use it to understand
how you got to the current location. Safe to call any time, but only
meaningful while paused.
stackDepth defaults to 0 (the top frame). Returns the variables in scope
at the given stack frame, with their names, types, and a summarized value.
Use it to inspect the local state at the current pause point. For nested
values, the summary shows the type and child count (e.g.
object {3 children}) — use xdbg_property_get to drill in.
expression is a PHP expression (e.g. $foo->bar() or count($items)).
Evaluates the expression in the current scope and returns the result. Use it
to test hypotheses, call methods, inspect computed values, or probe framework
state. The expression is base64-encoded and sent via the DBGp eval command.
Returns an error if the expression throws.
name is a variable name (e.g. $foo); stackDepth defaults to 0. Returns
the value of one variable or property in the given stack frame. Use it to
drill into a variable you saw in xdbg_context — for nested structures, it
returns the child properties. Returns (not found) when the name doesn't
exist in scope.
name is a variable name; value is a PHP literal (e.g. "bar" or 42).
Sets the variable to the given value in the current scope. Use it to test how
the code behaves with different inputs without editing the source. The value
is base64-encoded and sent via the DBGp property_set command. Returns
<name> = <value> on success.
Detaches from the engine: lets the script finish on its own and drops the
session. The DBGp listener closes, freeing port 9003. Use it when you're done
debugging but want the request/command to complete normally. Returns
detached.
Stops the debugged script immediately — the engine terminates the PHP
process and the session ends. The listener closes, freeing port 9003. Use it
to abort a stuck request or command. Returns stopped.
No arguments. Available only when the corresponding --xdebug-*-cmd flag is
configured. _status runs the status command and returns its output (e.g.
whether Xdebug is currently on or off). _enable / _disable run the
enable/disable commands to toggle Xdebug in the container at runtime, so the
agent can turn it on for a debug session and off again afterwards without
shell access.
Web (POST/GET/…) — the tool fires the request itself:
xdbg_container_status— check Xdebug is enabled in the containerxdbg_container_enable— turn it on if it's offxdbg_set_breakpoint{file:"src/.../FooController.php", line:42}xdbg_request{url:"http://127.0.0.1:8090/api/foo", method:"POST", headers:{"Content-Type":"application/json","Authorization":"Bearer …"}, body:"{…}"}→ breaks atFooController:42xdbg_stack/_context/_eval/_step_*/_run— inspect and stepxdbg_detachorxdbg_stop— end the session and free port 9003xdbg_container_disable— turn Xdebug off (restore container performance)
CLI / Symfony command (manual launch):
xdbg_container_status— check Xdebug is enabled in the containerxdbg_container_enable— turn it on if it's offxdbg_set_breakpoint…xdbg_listen(arms; returns when the engine connects)- launch separately:
docker compose exec -T php php bin/console app:cmd - drive with
_run/_step_*/_stack/_context/_eval xdbg_detachorxdbg_stop— end the session and free port 9003xdbg_container_disable— turn Xdebug off (restore container performance)
CLI / Symfony command (agent-driven):
xdbg_container_status— check Xdebug is enabled in the containerxdbg_container_enable— turn it on if it's offxdbg_set_breakpoint…xdbg_run_command{command:"bin/console app:my-command --option=value"}→ pauses at the breakpointxdbg_stack/_context/_eval/_step_*/_run— inspect and stepxdbg_detachorxdbg_stop— end the session and free port 9003xdbg_container_disable— turn Xdebug off (restore container performance)
MIT — see LICENSE.

{ "mcp": { "xdbg": { "enabled": true, "type": "local", "command": [ "xdbg", "mcp", "--dbg-port", "9003", "--local-root", "/Users/you/work/your-app", "--docker-root", "/var/www/your-app", "--xdebug-enable-cmd", "docker compose exec -T php set-xdebug-on", "--xdebug-disable-cmd", "docker compose exec -T php set-xdebug-off", "--xdebug-status-cmd", "docker compose exec -T php get-xdebug-status", "--container-exec", "docker compose exec -T php" ] } } }