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 @@ -51,7 +51,9 @@ claude mcp add dbgp -- dbgp mcp

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 so far: `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`.
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`.

Results are kept small enough for an agent's context: stack frames and variables come in pages of 50, output and long values in parts of 32 KB, warnings 50 at a time with repeats counted once, and long source lines are cut at 500 bytes. The 50 most recent ended sessions are kept, with their output.

## Testing

Expand Down
42 changes: 42 additions & 0 deletions cmd/dbgp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"sync"
"syscall"
"unicode/utf8"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
Expand All @@ -28,6 +29,18 @@ going on, listen before starting the PHP code to debug, and unlisten when done.
Remove each breakpoint with remove_breakpoint as soon as you no longer need it:
breakpoints apply to every later session and slow it down.

A typical run: add_breakpoint, listen, start the PHP code, wait_for_session,
then continue to run to the breakpoint. Start PHP in the background, or send
the web request without waiting for its answer: PHP waits for dbgp at every
stop, so it does not finish while you are debugging it. Once stopped, look
around with stack, variables, variable and source, and step or run on with
continue. output and warnings show what the script printed and the PHP
warnings it raised. End every session with stop or detach when you are done
with it: until then its PHP process or web server worker is held.

Long results come in parts: lists in pages, see page and pages, and long text
when more or next is set. Ask only for the parts you need.

Before starting PHP, check which debugger it has with php -v: it prints
"with PHP Debugger" or "with Xdebug". PHP Debugger is always in debug mode and
starts debugging every request by itself, so do not set xdebug.mode or
Expand Down Expand Up @@ -191,6 +204,35 @@ func outputSchema[T any](lists ...string) *jsonschema.Schema {
return schema
}

// Tool results are kept small enough for an agent's context: longer text
// and lists are returned in parts, which the agent asks for one at a time.
const (
maxTextBytes = 32 << 10 // output, and values from variable_value
maxLineBytes = 500 // each line from source
maxListItems = 50 // stack frames, variables and warnings
)

// cutText returns at most limit bytes of s, cut at a character boundary.
func cutText(s string, limit int) string {
if len(s) <= limit {
return s
}
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
return s[:limit]
}

// pageOf returns page n of items, maxListItems per page, and how many pages
// there are; there is always at least one, maybe empty.
func pageOf[T any](items []T, n int) ([]T, int, error) {
pages := max(1, (len(items)+maxListItems-1)/maxListItems)
if n < 0 || n >= pages {
return nil, pages, fmt.Errorf("page must be from 0 to %d", pages-1)
}
return items[n*maxListItems : min(len(items), (n+1)*maxListItems)], pages, nil
}

// tools implements the MCP tools on a dbgp server.
type tools struct {
srv *dbgp.Server
Expand Down
128 changes: 128 additions & 0 deletions cmd/dbgp/mcp_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"context"
"net"
"os/exec"
"path/filepath"
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

// A whole debugging run through the dbgp binary over stdio, as an agent
// does it: from listening to a finished script and nothing left behind.
func TestMCPEndToEnd(t *testing.T) {
requireEngine(t)
bin := buildBinary(t)
script, err := filepath.Abs("../../testdata/php/basic.php")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
transport := &mcp.CommandTransport{Command: exec.CommandContext(ctx, bin, "mcp", "-addr", "127.0.0.1:0")}
cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, transport, nil)
if err != nil {
t.Fatal(err)
}
defer func() { _ = cs.Close() }()

var bp addBreakpointOutput
callTool(t, cs, "add_breakpoint", map[string]any{"file": script, "line": 24}, &bp)
var listening listenOutput
callTool(t, cs, "listen", nil, &listening)
_, port, err := net.SplitHostPort(listening.Address)
if err != nil {
t.Fatal(err)
}

// The script runs in the background while it is debugged.
php := exec.Command("php", "-dxdebug.mode=debug", "-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1", "-dxdebug.client_port="+port, "-ddisplay_errors=0", script)
if err := php.Start(); err != nil {
t.Fatal(err)
}
exited := make(chan error, 1)
go func() { exited <- php.Wait() }()
defer func() { _ = php.Process.Kill() }()

var waited waitForSessionOutput
callTool(t, cs, "wait_for_session", map[string]any{"wait": 10}, &waited)
if !waited.Connected || waited.Session.Script != script {
t.Fatalf("wait_for_session = %+v", waited)
}
var st stateOutput
callTool(t, cs, "continue", nil, &st)
if st.Status != "break" || st.File != script || st.Line != 24 {
t.Fatalf("continue = %+v, want break at line 24", st)
}

var stack stackOutput
callTool(t, cs, "stack", nil, &stack)
if len(stack.Frames) != 2 || stack.Frames[0].Function != "add" || stack.Frames[1].Line != 36 {
t.Errorf("stack = %+v", stack.Frames)
}
var vars variablesOutput
callTool(t, cs, "variables", nil, &vars)
if a := byName(vars.Variables)["$a"]; a.Value != "2" {
t.Errorf("$a = %+v", a)
}
var numbers variableOutput
callTool(t, cs, "variable", map[string]any{"name": "$numbers", "depth": 1}, &numbers)
if numbers.Variable.Size == nil || *numbers.Variable.Size != 40 {
t.Errorf("$numbers = %+v", numbers.Variable)
}
var result evalOutput
callTool(t, cs, "eval", map[string]any{"code": "$a + $b"}, &result)
if result.Result == nil || result.Result.Value != "5" {
t.Errorf("eval $a + $b = %+v", result.Result)
}
callTool(t, cs, "set_variable", map[string]any{"name": "$b", "value": "30"}, &setVariableOutput{})
var source sourceOutput
callTool(t, cs, "source", nil, &source)
if len(source.Lines) == 0 {
t.Error("source returned no lines")
}

callTool(t, cs, "continue", nil, &st)
if st.Status != "stopping" {
t.Fatalf("continue to the end = %+v, want stopping", st)
}
var printed outputOutput
callTool(t, cs, "output", nil, &printed)
if printed.Output != "x=32\ndone\n" {
t.Errorf("output = %q, want x=32 from the changed $b", printed.Output)
}
var raised warningsOutput
callTool(t, cs, "warnings", nil, &raised)
if len(raised.Warnings) != 1 || raised.Warnings[0].Line != 42 {
t.Errorf("warnings = %+v", raised.Warnings)
}

// Cleaning up leaves nothing behind: PHP exits, and dbgp is idle.
callTool(t, cs, "stop", nil, &st)
if st.Status != "ended" {
t.Errorf("stop = %+v", st)
}
select {
case err := <-exited:
if err != nil {
t.Errorf("php: %v", err)
}
case <-time.After(10 * time.Second):
t.Error("php still running after stop")
}
callTool(t, cs, "remove_breakpoint", map[string]any{"id": bp.Breakpoint.ID}, &breakpointsOutput{})
callTool(t, cs, "unlisten", nil, &unlistenOutput{})
var status statusOutput
callTool(t, cs, "status", nil, &status)
if status.Listening || len(status.Breakpoints) != 0 || len(status.Sessions) != 1 || status.Sessions[0].Status != "ended" {
t.Errorf("status after cleaning up = %+v", status)
}
if conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, time.Second); err == nil {
_ = conn.Close()
t.Errorf("port %s still open after unlisten", port)
}
}
48 changes: 38 additions & 10 deletions cmd/dbgp/mcp_inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import (
func (t *tools) addInspectTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "stack",
Description: "Show the call stack of a stopped session, innermost first.",
Description: "Show the call stack of a stopped session, innermost first, 50 frames per page.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
OutputSchema: outputSchema[stackOutput]("frames"),
}, t.stack)
mcp.AddTool(server, &mcp.Tool{
Name: "variables",
Description: "List the variables of a stack frame of a stopped session. Arrays and objects are " +
Description: "List the variables of a stack frame of a stopped session, 50 per page. Arrays and objects are " +
"listed without their contents, with their size or class: use variable to look inside one.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
OutputSchema: outputSchema[variablesOutput]("variables"),
Expand All @@ -35,7 +35,7 @@ func (t *tools) addInspectTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "variable_value",
Description: "Show the whole value of a variable whose value variables or variable returned truncated, " +
"such as a long string.",
"such as a long string. A value longer than 32 KB is returned in parts: call again with offset set to next.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, t.variableValue)
}
Expand All @@ -47,12 +47,19 @@ type frameInfo struct {
Line int `json:"line" jsonschema:"the line being executed in this frame"`
}

type stackInput struct {
Session int `json:"session,omitempty" jsonschema:"the session id; defaults to the newest session that has not ended"`
Page int `json:"page,omitempty" jsonschema:"the page of frames, from 0 (the default); see pages"`
}

type stackOutput struct {
Session int `json:"session" jsonschema:"the session id"`
Frames []frameInfo `json:"frames" jsonschema:"innermost first: depth 0 is where execution stopped"`
Frames []frameInfo `json:"frames" jsonschema:"one page of frames, innermost first: depth 0 is where execution stopped"`
Page int `json:"page" jsonschema:"this page, from 0"`
Pages int `json:"pages" jsonschema:"how many pages of frames there are; call stack with page for the others"`
}

func (t *tools) stack(_ context.Context, _ *mcp.CallToolRequest, in sessionInput) (*mcp.CallToolResult, stackOutput, error) {
func (t *tools) stack(_ context.Context, _ *mcp.CallToolRequest, in stackInput) (*mcp.CallToolResult, stackOutput, error) {
s, err := t.session(in.Session)
if err != nil {
return nil, stackOutput{}, err
Expand All @@ -61,7 +68,11 @@ func (t *tools) stack(_ context.Context, _ *mcp.CallToolRequest, in sessionInput
if err != nil {
return nil, stackOutput{}, sessionError(s, err)
}
out := stackOutput{Session: s.ID(), Frames: []frameInfo{}}
frames, pages, err := pageOf(frames, in.Page)
if err != nil {
return nil, stackOutput{}, err
}
out := stackOutput{Session: s.ID(), Frames: []frameInfo{}, Page: in.Page, Pages: pages}
for _, f := range frames {
out.Frames = append(out.Frames, frameInfo{Depth: f.Level, Function: f.Where, File: f.Filename, Line: f.Lineno})
}
Expand All @@ -73,6 +84,7 @@ type frameInput struct {
Session int `json:"session,omitempty" jsonschema:"the session id; defaults to the newest session that has not ended"`
Depth int `json:"depth,omitempty" jsonschema:"the stack frame: 0 (the default) is where execution stopped, 1 its caller, and so on; see stack"`
Context int `json:"context,omitempty" jsonschema:"0 (the default) local variables, 1 superglobals such as $_SERVER and $_GET, 2 user-defined constants"`
Page int `json:"page,omitempty" jsonschema:"the page of variables, from 0 (the default); see pages"`
}

type variableInfo struct {
Expand Down Expand Up @@ -126,7 +138,9 @@ type variablesOutput struct {
Session int `json:"session" jsonschema:"the session id"`
Depth int `json:"depth" jsonschema:"the stack frame"`
Context int `json:"context" jsonschema:"0 local variables, 1 superglobals, 2 user-defined constants"`
Variables []variableInfo `json:"variables" jsonschema:"the variables, without the contents of arrays and objects"`
Variables []variableInfo `json:"variables" jsonschema:"one page of the variables, without the contents of arrays and objects"`
Page int `json:"page" jsonschema:"this page, from 0"`
Pages int `json:"pages" jsonschema:"how many pages of variables there are; call variables with page for the others"`
}

func (t *tools) variables(_ context.Context, _ *mcp.CallToolRequest, in frameInput) (*mcp.CallToolResult, variablesOutput, error) {
Expand All @@ -138,7 +152,11 @@ func (t *tools) variables(_ context.Context, _ *mcp.CallToolRequest, in frameInp
if err != nil {
return nil, variablesOutput{}, sessionError(s, err)
}
out := variablesOutput{Session: s.ID(), Depth: in.Depth, Context: in.Context, Variables: []variableInfo{}}
props, pages, err := pageOf(props, in.Page)
if err != nil {
return nil, variablesOutput{}, err
}
out := variablesOutput{Session: s.ID(), Depth: in.Depth, Context: in.Context, Variables: []variableInfo{}, Page: in.Page, Pages: pages}
for _, p := range props {
out.Variables = append(out.Variables, toVariable(p))
}
Expand Down Expand Up @@ -190,11 +208,14 @@ type variableValueInput struct {
Name string `json:"name" jsonschema:"the variable, or a path into one, e.g. $html or $page[\"body\"]"`
Depth int `json:"depth,omitempty" jsonschema:"the stack frame: 0 (the default) is where execution stopped, 1 its caller, and so on; see stack"`
Context int `json:"context,omitempty" jsonschema:"0 (the default) local variables, 1 superglobals such as $_SERVER and $_GET, 2 user-defined constants"`
Offset int `json:"offset,omitempty" jsonschema:"the byte of the value to start from, 0 by default; see next"`
}

type variableValueOutput struct {
Name string `json:"name" jsonschema:"the variable"`
Value string `json:"value" jsonschema:"its whole value"`
Value string `json:"value" jsonschema:"its value from offset, at most 32 KB of it"`
Size int `json:"size" jsonschema:"the length of the whole value, in bytes"`
Next int `json:"next,omitempty" jsonschema:"where the rest of the value starts: call variable_value again with this offset; absent when the value is complete"`
}

func (t *tools) variableValue(_ context.Context, _ *mcp.CallToolRequest, in variableValueInput) (*mcp.CallToolResult, variableValueOutput, error) {
Expand All @@ -209,5 +230,12 @@ func (t *tools) variableValue(_ context.Context, _ *mcp.CallToolRequest, in vari
if err != nil {
return nil, variableValueOutput{}, inspectError(s, in.Name, in.Depth, err)
}
return nil, variableValueOutput{Name: in.Name, Value: value}, nil
if in.Offset < 0 || in.Offset > len(value) {
return nil, variableValueOutput{}, fmt.Errorf("offset must be from 0 to %d, the size of %s", len(value), in.Name)
}
out := variableValueOutput{Name: in.Name, Value: cutText(value[in.Offset:], maxTextBytes), Size: len(value)}
if end := in.Offset + len(out.Value); end < len(value) {
out.Next = end
}
return nil, out, nil
}
15 changes: 11 additions & 4 deletions cmd/dbgp/mcp_inspect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ import (
// stoppedInAdd starts the debuggee through MCP and runs it to the
// breakpoint in add(), at line 24.
func stoppedInAdd(t *testing.T) (*mcp.ClientSession, *dbgp.Server, string) {
t.Helper()
return stoppedAt(t, "basic.php", 24)
}

// stoppedAt starts a script from testdata/php through MCP and runs it to a
// breakpoint on line.
func stoppedAt(t *testing.T, name string, line int) (*mcp.ClientSession, *dbgp.Server, string) {
t.Helper()
requireEngine(t)
cs, srv := newTestMCP(t)
script, err := filepath.Abs("../../testdata/php/basic.php")
script, err := filepath.Abs("../../testdata/php/" + name)
if err != nil {
t.Fatal(err)
}
callTool(t, cs, "add_breakpoint", map[string]any{"file": script, "line": 24}, &addBreakpointOutput{})
callTool(t, cs, "add_breakpoint", map[string]any{"file": script, "line": line}, &addBreakpointOutput{})
callTool(t, cs, "listen", nil, &listenOutput{})
startPHP(t, srv.Port(), script)
var waited waitForSessionOutput
Expand All @@ -29,8 +36,8 @@ func stoppedInAdd(t *testing.T) (*mcp.ClientSession, *dbgp.Server, string) {
}
var st stateOutput
callTool(t, cs, "continue", nil, &st)
if st.Status != "break" || st.Line != 24 {
t.Fatalf("continue = %+v, want break at line 24", st)
if st.Status != "break" || st.Line != line {
t.Fatalf("continue = %+v, want break at line %d", st, line)
}
return cs, srv, script
}
Expand Down
Loading
Loading