From a90271e78f98a135b7c623cff5309a45bb387d65 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Tue, 6 Oct 2026 16:28:24 +0200 Subject: [PATCH] Add MCP source, output and warnings tools --- README.md | 2 +- cmd/dbgp/mcp.go | 8 ++ cmd/dbgp/mcp_output.go | 201 ++++++++++++++++++++++++++++++++++++ cmd/dbgp/mcp_output_test.go | 102 ++++++++++++++++++ cmd/dbgp/mcp_test.go | 4 +- 5 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 cmd/dbgp/mcp_output.go create mode 100644 cmd/dbgp/mcp_output_test.go diff --git a/README.md b/README.md index d376f7b..0ba1b4e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/dbgp/mcp.go b/cmd/dbgp/mcp.go index 5c7ecfc..414d65f 100644 --- a/cmd/dbgp/mcp.go +++ b/cmd/dbgp/mcp.go @@ -9,6 +9,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" "github.com/google/jsonschema-go/jsonschema" @@ -152,6 +153,7 @@ func newMCPServer(srv *dbgp.Server) *mcp.Server { }, t.pathMappings) t.addSessionTools(server) t.addInspectTools(server) + t.addOutputTools(server) return server } @@ -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{} diff --git a/cmd/dbgp/mcp_output.go b/cmd/dbgp/mcp_output.go new file mode 100644 index 0000000..8a7c1b8 --- /dev/null +++ b/cmd/dbgp/mcp_output.go @@ -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 +} diff --git a/cmd/dbgp/mcp_output_test.go b/cmd/dbgp/mcp_output_test.go new file mode 100644 index 0000000..09b19b4 --- /dev/null +++ b/cmd/dbgp/mcp_output_test.go @@ -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 != "