Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ Then start PHP with debugging enabled (e.g. `php -dxdebug.mode=debug -dxdebug.st

## MCP server for AI agents

`dbgp mcp` runs the debugger as an [MCP](https://modelcontextprotocol.io) server on stdin and stdout, so an AI agent can debug PHP. To add it to Claude Code:
`dbgp mcp` runs the debugger as an [MCP](https://modelcontextprotocol.io) server on stdin and stdout, so an AI agent can debug PHP. Agents start it themselves; to add it to Claude Code:

```sh
claude mcp add dbgp -- dbgp mcp
```

It can also run in the background, so that it keeps its state between agent sessions and agents share it: `dbgp mcp start` starts it and returns, `dbgp mcp status` shows whether it is running and `dbgp mcp stop` stops it. While it runs, `dbgp mcp` started by an agent passes the agent on to it, and the options given to `dbgp mcp start` apply. The background server serves MCP over HTTP on `127.0.0.1:9004` (change it with `-http`); its log is `mcp.log` in dbgp's cache directory (e.g. `~/.cache/dbgp` on Linux, `~/Library/Caches/dbgp` on macOS).

Unlike the interactive mode, it does not listen for PHP connections until the agent calls the `listen` tool, so PHP runs at full speed until there is something to debug; `-listen` starts listening right away. `-addr`, `-idekey` and `-map LOCAL=REMOTE` work as in the interactive mode.

Tools: `status` (listening state, sessions, breakpoints and path mappings), `listen` and `unlisten`; `add_breakpoint`, `remove_breakpoint` and `breakpoints`; `add_path_mapping` and `path_mappings`; `sessions`, `wait_for_session`, `continue` (run or step), `wait_for_stop`, `stop` and `detach`; `stack`, `variables`, `variable` (one variable, a page of its contents at a time) and `variable_value`; `source`, `output` and `warnings` (what the script printed and the PHP warnings it raised, each read incrementally); `eval` and `set_variable`, which run PHP code in the debugged script and can be left out with `-no-eval`.
Expand Down
73 changes: 63 additions & 10 deletions cmd/dbgp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,47 @@ This is the user's choice, not a missing feature or an outdated build: do not
suggest rebuilding or restarting dbgp to get them. Read values with variables,
variable and variable_value instead, and work out results from them.`

// runMCP runs dbgp as an MCP server on stdin and stdout until the client
// disconnects. stdout carries the protocol, so diagnostics go to stderr.
// runMCP runs dbgp as an MCP server. Agents start it as dbgp mcp and talk
// to it on stdin and stdout: it serves them itself, or passes them on to the
// background server when one is running, so they share it. dbgp mcp start,
// stop and status manage the background server, which serves MCP over HTTP.
func runMCP(args []string) int {
command := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
command, args = args[0], args[1:]
}
switch command {
case "", "start", serveCommand:
case "stop", "status":
if len(args) > 0 {
fmt.Fprintf(os.Stderr, "dbgp mcp %s takes no options\n", command)
return 2
}
if command == "stop" {
return stopDaemon()
}
return daemonStatus()
default:
fmt.Fprintf(os.Stderr, "unknown command %q: use dbgp mcp, dbgp mcp start, stop or status\n", command)
return 2
}

fs := flag.NewFlagSet("dbgp mcp", flag.ContinueOnError)
addr := fs.String("addr", dbgp.DefaultAddr, "address to listen on for PHP connections")
idekey := fs.String("idekey", "", "accept only sessions with this IDE key")
listen := fs.Bool("listen", false, "listen for PHP connections from the start, instead of waiting for the listen tool")
noEval := fs.Bool("no-eval", false, "leave out the eval and set_variable tools, which run PHP code in the debugged script")
httpAddr := fs.String("http", defaultHTTPAddr, "with start: the address agents connect to over HTTP")
var maps listFlag
fs.Var(&maps, "map", "path mapping `LOCAL=REMOTE` for PHP in a container or on a server (repeatable)")
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "Usage: dbgp mcp [options]\n\n"+
"Runs dbgp as an MCP server on stdin and stdout, for AI agents.\n"+
"It does not listen for PHP connections until asked to.\n\nOptions:\n")
fmt.Fprintf(fs.Output(), "Usage: dbgp mcp [options]\n dbgp mcp start [options]\n dbgp mcp stop\n dbgp mcp status\n\n"+
"dbgp mcp runs dbgp as an MCP server for AI agents, on stdin and stdout:\n"+
"agents start it themselves. When the background server is running, it\n"+
"connects the agent to that instead, and the background server's options apply.\n"+
"dbgp mcp start starts the background server, which agents share, and returns;\n"+
"dbgp mcp stop stops it and dbgp mcp status shows whether it is running.\n"+
"dbgp does not listen for PHP connections until asked to.\n\nOptions:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
Expand All @@ -85,12 +112,35 @@ func runMCP(args []string) int {
}
return 2
}

if fs.NArg() > 0 {
fmt.Fprintf(os.Stderr, "unexpected argument %q\n", fs.Arg(0))
return 2
}
pathMap, err := parsePathMap(maps)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
switch command {
case "start":
return startDaemon(args)
case "":
if st, err := running(); err == nil && st != nil {
if len(args) > 0 {
fmt.Fprintf(os.Stderr, "dbgp: using the background server at %s, with its own options: %s ignored\n",
st.URL, strings.Join(args, " "))
}
if err := bridge(ctx, st.URL); err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
}

srv, err := dbgp.NewServer(dbgp.Config{Addr: *addr, IDEKey: *idekey, PathMap: pathMap})
if err != nil {
fmt.Fprintln(os.Stderr, err)
Expand All @@ -103,10 +153,13 @@ func runMCP(args []string) int {
return 1
}
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := newMCPServer(srv, !*noEval).Run(ctx, &mcp.StdioTransport{}); err != nil && ctx.Err() == nil {
server := newMCPServer(srv, !*noEval)
if command == serveCommand {
err = serveHTTP(ctx, server, *httpAddr)
} else {
err = server.Run(ctx, &mcp.StdioTransport{})
}
if err != nil && ctx.Err() == nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
Expand Down
Loading
Loading