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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ 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`. More inspection tools follow.
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). Tools that run code in the script follow.

## Testing

Expand Down
8 changes: 8 additions & 0 deletions cmd/dbgp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"

"github.com/google/jsonschema-go/jsonschema"
Expand Down Expand Up @@ -152,6 +153,7 @@ func newMCPServer(srv *dbgp.Server) *mcp.Server {
}, t.pathMappings)
t.addSessionTools(server)
t.addInspectTools(server)
t.addOutputTools(server)
return server
}

Expand All @@ -173,6 +175,12 @@ func outputSchema[T any](lists ...string) *jsonschema.Schema {
// tools implements the MCP tools on a dbgp server.
type tools struct {
srv *dbgp.Server

// mu guards the read positions of output and warnings, per session id:
// those tools return only what is new since their last call.
mu sync.Mutex
outputFrom map[int]int
warningsFrom map[int]int
}

type noInput struct{}
Expand Down
201 changes: 201 additions & 0 deletions cmd/dbgp/mcp_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package main

import (
"context"
"errors"
"fmt"
"strings"

"github.com/modelcontextprotocol/go-sdk/mcp"
dbgp "github.com/php-debugger/dbgp-client"
)

// Source and output tools: the code around a location, what the script
// printed, and the PHP warnings it raised.

// How many lines source shows on each side of a line.
const (
defaultAround = 5
maxAround = 100
)

func (t *tools) addOutputTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{
Name: "source",
Description: "Show numbered source lines around where a session stopped, or around another file and line. " +
"Read through PHP, so it also works for PHP in a container or on another machine.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
OutputSchema: outputSchema[sourceOutput]("lines"),
}, t.source)
mcp.AddTool(server, &mcp.Tool{
Name: "output",
Description: "Show what a session's script has printed since the last call, so nothing is shown twice; " +
"all returns everything kept so far. Works after the session has ended.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
}, t.output)
mcp.AddTool(server, &mcp.Tool{
Name: "warnings",
Description: "Show the PHP warnings, notices and other errors a session's script has raised since the last call; " +
"all returns every one kept so far. Works after the session has ended.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true},
OutputSchema: outputSchema[warningsOutput]("warnings"),
}, t.warnings)
}

// latestSession returns the session with the given id, or by default the
// newest session, even one that has ended: what it printed is still there.
func (t *tools) latestSession(id int) (*dbgp.Session, error) {
if id != 0 {
return t.session(id)
}
sessions := t.srv.Sessions()
if len(sessions) == 0 {
return nil, errors.New("no session: call listen, start the PHP code, then wait_for_session")
}
return sessions[len(sessions)-1], nil
}

type sourceInput struct {
Session int `json:"session,omitempty" jsonschema:"the session id; defaults to the newest session that has not ended"`
File string `json:"file,omitempty" jsonschema:"the file, as a path on this machine; defaults to where the session stopped, or its script"`
Line int `json:"line,omitempty" jsonschema:"the line to show the code around; defaults to where the session stopped, or 1 for another file"`
Around int `json:"around,omitempty" jsonschema:"how many lines to show before and after the line: 5 by default, at most 100"`
}

type sourceLine struct {
Line int `json:"line" jsonschema:"the line number"`
Text string `json:"text" jsonschema:"the line's code"`
Current bool `json:"current,omitempty" jsonschema:"set on the line where execution is stopped"`
}

type sourceOutput struct {
File string `json:"file" jsonschema:"local path of the file"`
Lines []sourceLine `json:"lines" jsonschema:"the source lines, in order"`
}

func (t *tools) source(_ context.Context, _ *mcp.CallToolRequest, in sourceInput) (*mcp.CallToolResult, sourceOutput, error) {
s, err := t.session(in.Session)
if err != nil {
return nil, sourceOutput{}, err
}
around := in.Around
switch {
case around == 0:
around = defaultAround
case around < 0 || around > maxAround:
return nil, sourceOutput{}, fmt.Errorf("around must be between 1 and %d", maxAround)
}

st := s.State()
file, line := in.File, in.Line
if file == "" {
file = st.File
if line == 0 {
line = st.Line
}
if file == "" {
file = s.Script()
}
} else if file, err = absolutePath(file); err != nil {
return nil, sourceOutput{}, err
}
if line <= 0 {
line = 1
}

begin := max(1, line-around)
text, err := s.GetSource(file, begin, line+around)
if err != nil {
var engineErr *dbgp.EngineError
if errors.As(err, &engineErr) && engineErr.Code == 100 {
return nil, sourceOutput{}, fmt.Errorf("PHP cannot read %s", file)
}
return nil, sourceOutput{}, sessionError(s, err)
}
out := sourceOutput{File: file, Lines: []sourceLine{}}
if text == "" {
return nil, out, nil
}
for i, code := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") {
n := begin + i
out.Lines = append(out.Lines, sourceLine{
Line: n, Text: code,
Current: st.Status == dbgp.StatusBreak && st.File == file && st.Line == n,
})
}
return nil, out, nil
}

type readInput struct {
Session int `json:"session,omitempty" jsonschema:"the session id; defaults to the newest session, even one that has ended"`
All bool `json:"all,omitempty" jsonschema:"return everything kept so far, not only what is new since the last call"`
}

// readFrom runs read from where the last read of a session's output or
// warnings ended, or from the start for all, and records where it ends.
func (t *tools) readFrom(positions *map[int]int, id int, all bool, read func(from int) int) {
t.mu.Lock()
defer t.mu.Unlock()
if *positions == nil {
*positions = map[int]int{}
}
from := (*positions)[id]
if all {
from = 0
}
(*positions)[id] = read(from)
}

type outputOutput struct {
Session int `json:"session" jsonschema:"the session id"`
Output string `json:"output" jsonschema:"what the script printed; empty if nothing new"`
Truncated bool `json:"truncated,omitempty" jsonschema:"earlier output was dropped: only the most recent 1 MB is kept"`
}

func (t *tools) output(_ context.Context, _ *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, outputOutput, error) {
s, err := t.latestSession(in.Session)
if err != nil {
return nil, outputOutput{}, err
}
out := outputOutput{Session: s.ID()}
t.readFrom(&t.outputFrom, s.ID(), in.All, func(from int) int {
text, next, truncated := s.Output(from)
out.Output, out.Truncated = text, truncated
return next
})
return nil, out, nil
}

type warningInfo struct {
Type string `json:"type" jsonschema:"the kind of error, e.g. Warning, Notice, Deprecated or Fatal error"`
Message string `json:"message" jsonschema:"PHP's message"`
File string `json:"file" jsonschema:"local path of the file where it was raised"`
Line int `json:"line" jsonschema:"the line where it was raised"`
}

type warningsOutput struct {
Session int `json:"session" jsonschema:"the session id"`
Warnings []warningInfo `json:"warnings" jsonschema:"the warnings, notices and errors, oldest first; empty if nothing new"`
}

func (t *tools) warnings(_ context.Context, _ *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, warningsOutput, error) {
s, err := t.latestSession(in.Session)
if err != nil {
return nil, warningsOutput{}, err
}
out := warningsOutput{Session: s.ID(), Warnings: []warningInfo{}}
t.readFrom(&t.warningsFrom, s.ID(), in.All, func(from int) int {
notes, next := s.Notifications(from)
for _, n := range notes {
// Other notifications, such as breakpoint_resolved, are routine.
if n.Name == "error" && n.Message != nil {
out.Warnings = append(out.Warnings, warningInfo{
Type: n.Message.Type, Message: strings.TrimSpace(n.Message.Text),
File: n.Message.Filename, Line: n.Message.Lineno,
})
}
}
return next
})
return nil, out, nil
}
102 changes: 102 additions & 0 deletions cmd/dbgp/mcp_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"strings"
"testing"
)

func TestMCPOutputToolsWithoutSession(t *testing.T) {
cs, _ := newTestMCP(t)
for _, tool := range []string{"source", "output", "warnings"} {
if msg := callToolError(t, cs, tool, nil); !strings.Contains(msg, "no session") {
t.Errorf("%s: %q", tool, msg)
}
}
}

func TestMCPSource(t *testing.T) {
cs, _, script := stoppedInAdd(t)

var out sourceOutput
callTool(t, cs, "source", nil, &out)
if out.File != script || len(out.Lines) != 11 || out.Lines[0].Line != 19 || out.Lines[10].Line != 29 {
t.Fatalf("source = %s, lines %d to %d", out.File, out.Lines[0].Line, out.Lines[len(out.Lines)-1].Line)
}
for _, l := range out.Lines {
if l.Current != (l.Line == 24) {
t.Errorf("line %d current = %v", l.Line, l.Current)
}
}
// Indentation is kept.
if out.Lines[5].Text != " $sum = $a + $b;" {
t.Errorf("line 24 = %q", out.Lines[5].Text)
}

callTool(t, cs, "source", map[string]any{"line": 36, "around": 1}, &out)
if len(out.Lines) != 3 || out.Lines[1].Text != "$x = add(2, 3);" || out.Lines[1].Current {
t.Errorf("source around line 36 = %+v", out.Lines)
}
callTool(t, cs, "source", map[string]any{"file": "../../testdata/php/basic.php", "line": 1, "around": 2}, &out)
if out.File != script || len(out.Lines) != 3 || out.Lines[0].Line != 1 || out.Lines[0].Text != "<?php" {
t.Errorf("source at the start, relative path = %s %+v", out.File, out.Lines)
}

for _, tc := range []struct {
args map[string]any
want string
}{
{map[string]any{"around": 101}, "around must be between 1 and 100"},
{map[string]any{"around": -1}, "around must be between 1 and 100"},
{map[string]any{"file": "/no/such/file.php"}, "PHP cannot read /no/such/file.php"},
} {
if msg := callToolError(t, cs, "source", tc.args); !strings.Contains(msg, tc.want) {
t.Errorf("source(%v): %q, want %q", tc.args, msg, tc.want)
}
}
}

func TestMCPOutputAndWarnings(t *testing.T) {
cs, _, script := stoppedInAdd(t)

var printed outputOutput
callTool(t, cs, "output", nil, &printed)
if printed.Output != "" {
t.Errorf("output before the script printed anything = %q", printed.Output)
}
var st stateOutput
callTool(t, cs, "continue", nil, &st)
if st.Status != "stopping" {
t.Fatalf("continue = %+v, want stopping", st)
}

callTool(t, cs, "output", nil, &printed)
if printed.Output != "x=5\ndone\n" || printed.Session != 1 || printed.Truncated {
t.Errorf("output = %+v, want x=5 and done", printed)
}
callTool(t, cs, "output", nil, &printed)
if printed.Output != "" {
t.Errorf("second output = %q, want nothing new", printed.Output)
}

var raised warningsOutput
callTool(t, cs, "warnings", nil, &raised)
want := warningInfo{Type: "Warning", Message: "Undefined variable $undefinedVariable", File: script, Line: 42}
if len(raised.Warnings) != 1 || raised.Warnings[0] != want {
t.Errorf("warnings = %+v, want %+v", raised.Warnings, want)
}
callTool(t, cs, "warnings", nil, &raised)
if raised.Warnings == nil || len(raised.Warnings) != 0 {
t.Errorf("second warnings = %+v, want an empty list", raised.Warnings)
}

// Both still work once the session has ended, by default.
callTool(t, cs, "stop", nil, &st)
callTool(t, cs, "output", map[string]any{"all": true}, &printed)
if printed.Output != "x=5\ndone\n" {
t.Errorf("output all after stop = %q", printed.Output)
}
callTool(t, cs, "warnings", map[string]any{"all": true}, &raised)
if len(raised.Warnings) != 1 {
t.Errorf("warnings all after stop = %+v", raised.Warnings)
}
}
4 changes: 2 additions & 2 deletions cmd/dbgp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func TestMCPTools(t *testing.T) {
}
names := []string{"status", "listen", "unlisten", "add_breakpoint", "remove_breakpoint", "breakpoints",
"add_path_mapping", "path_mappings", "sessions", "wait_for_session", "continue", "wait_for_stop", "stop", "detach",
"stack", "variables", "variable", "variable_value"}
"stack", "variables", "variable", "variable_value", "source", "output", "warnings"}
for _, name := range names {
if got[name] == nil {
t.Errorf("tool %s missing", name)
Expand All @@ -106,7 +106,7 @@ func TestMCPTools(t *testing.T) {
t.Errorf("got %d tools, want %d", len(got), len(names))
}
for _, name := range []string{"status", "breakpoints", "path_mappings", "sessions", "wait_for_session", "wait_for_stop",
"stack", "variables", "variable", "variable_value"} {
"stack", "variables", "variable", "variable_value", "source", "output", "warnings"} {
if a := got[name].Annotations; a == nil || !a.ReadOnlyHint {
t.Errorf("%s is not marked read-only", name)
}
Expand Down
Loading