1301 lines
34 KiB
Markdown
1301 lines
34 KiB
Markdown
|
|
# xdebug-mcp Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Build a Go MCP server that parses Xdebug cachegrind profiling files and exposes three analysis tools: `analyze_profile`, `get_callers`, `get_callees`.
|
||
|
|
|
||
|
|
**Architecture:** Full in-memory parse + LRU cache (capacity 2). Each tool receives `file_path` as a direct parameter. The mcp-framework bootstrap CLI wraps the MCP server; `mark3labs/mcp-go` handles the JSON-RPC stdio protocol. Business logic (Analyze/Callers/Callees functions) is separated from I/O to keep tests simple.
|
||
|
|
|
||
|
|
**Tech Stack:** Go 1.22+, `mark3labs/mcp-go` (MCP protocol), `forge.lclr.dev/AI/mcp-framework` (bootstrap CLI + manifest), `github.com/stretchr/testify` (tests)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Map
|
||
|
|
|
||
|
|
| File | Role |
|
||
|
|
|------|------|
|
||
|
|
| `go.mod` | Module definition |
|
||
|
|
| `mcp.toml` | Manifest source (read by mcp-framework generate) |
|
||
|
|
| `mcpgen/manifest.go` | Generated — manifest loader |
|
||
|
|
| `mcpgen/metadata.go` | Generated — BinaryName, DefaultDescription |
|
||
|
|
| `mcpgen/update.go` | Generated — update helpers |
|
||
|
|
| `mcpgen/secretstore.go` | Generated — secret store helpers |
|
||
|
|
| `cmd/xdebug-mcp/main.go` | Entry point — delegates to app.Run |
|
||
|
|
| `internal/cachegrind/model.go` | Profile, Function, Call types |
|
||
|
|
| `internal/cachegrind/parser.go` | Streaming parser, gzip support |
|
||
|
|
| `internal/cachegrind/parser_test.go` | Parser tests with inline fixture |
|
||
|
|
| `internal/cache/lru.go` | LRU cache with modtime invalidation |
|
||
|
|
| `internal/cache/lru_test.go` | Cache tests |
|
||
|
|
| `internal/tools/shared.go` | loadProfile, findFunctions, formatCosts helpers |
|
||
|
|
| `internal/tools/analyze.go` | AnalyzeTool, AnalyzeHandler, Analyze function |
|
||
|
|
| `internal/tools/callers.go` | CallersTool, CallersHandler, Callers function |
|
||
|
|
| `internal/tools/callees.go` | CalleesTool, CalleesHandler, Callees function |
|
||
|
|
| `internal/tools/tools_test.go` | Tools tests (no file I/O — uses constructed Profile) |
|
||
|
|
| `internal/app/app.go` | Wires bootstrap + MCP server + tools |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Project scaffold
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `go.mod`
|
||
|
|
- Create: `mcp.toml`
|
||
|
|
- Create: `mcpgen/` (generated)
|
||
|
|
|
||
|
|
- [ ] **Init go module**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd /home/leclere/Projets/IA/xdebug-mcp
|
||
|
|
go mod init forge.lclr.dev/AI/xdebug-mcp
|
||
|
|
```
|
||
|
|
Expected: `go.mod` created with `module forge.lclr.dev/AI/xdebug-mcp`
|
||
|
|
|
||
|
|
- [ ] **Create mcp.toml**
|
||
|
|
|
||
|
|
```toml
|
||
|
|
binary_name = "xdebug-mcp"
|
||
|
|
|
||
|
|
[bootstrap]
|
||
|
|
description = "MCP server for Xdebug profiling files"
|
||
|
|
|
||
|
|
[profiles]
|
||
|
|
default = "prod"
|
||
|
|
known = ["dev", "prod"]
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Install mcp-framework CLI and generate mcpgen**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||
|
|
mcp-framework generate
|
||
|
|
ls mcpgen/
|
||
|
|
```
|
||
|
|
Expected: `manifest.go metadata.go secretstore.go update.go`
|
||
|
|
|
||
|
|
- [ ] **Add dependencies**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go get forge.lclr.dev/AI/mcp-framework
|
||
|
|
go get github.com/mark3labs/mcp-go
|
||
|
|
go get github.com/stretchr/testify
|
||
|
|
go mod tidy
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add go.mod go.sum mcp.toml mcpgen/
|
||
|
|
git commit -m "chore: scaffold project, generate mcpgen, add dependencies"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Data model
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `internal/cachegrind/model.go`
|
||
|
|
|
||
|
|
- [ ] **Create model.go**
|
||
|
|
|
||
|
|
```go
|
||
|
|
package cachegrind
|
||
|
|
|
||
|
|
// Profile holds the parsed contents of a cachegrind file.
|
||
|
|
type Profile struct {
|
||
|
|
Cmd string
|
||
|
|
Events []string
|
||
|
|
Functions []*Function
|
||
|
|
ByName map[string][]*Function // one name may appear in multiple files
|
||
|
|
}
|
||
|
|
|
||
|
|
// Function represents a single profiled function with aggregated inclusive costs.
|
||
|
|
type Function struct {
|
||
|
|
Name string
|
||
|
|
File string
|
||
|
|
Costs []int64 // one entry per Profile.Events; inclusive (includes callees)
|
||
|
|
Calls []*Call // outgoing call edges
|
||
|
|
CalledBy []*Call // incoming call edges
|
||
|
|
}
|
||
|
|
|
||
|
|
// Call is a directed call edge between two functions.
|
||
|
|
type Call struct {
|
||
|
|
Caller *Function
|
||
|
|
Callee *Function
|
||
|
|
Count int64
|
||
|
|
Costs []int64
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Verify compilation**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go build ./internal/cachegrind/...
|
||
|
|
```
|
||
|
|
Expected: no output (success)
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/cachegrind/model.go
|
||
|
|
git commit -m "feat: add cachegrind data model"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: Cachegrind parser (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `internal/cachegrind/parser_test.go`
|
||
|
|
- Create: `internal/cachegrind/parser.go`
|
||
|
|
|
||
|
|
- [ ] **Write the failing test** (`internal/cachegrind/parser_test.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package cachegrind_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
const simpleFixture = `version: 1
|
||
|
|
creator: test
|
||
|
|
cmd: index.php
|
||
|
|
part: 1
|
||
|
|
positions: line
|
||
|
|
|
||
|
|
events: Time_(10ns) Memory_(bytes)
|
||
|
|
|
||
|
|
fl=(1) index.php
|
||
|
|
fn=(1) main
|
||
|
|
1 2000 1000
|
||
|
|
cfl=(1)
|
||
|
|
cfn=(2)
|
||
|
|
calls=1 0 0
|
||
|
|
2 1500 700
|
||
|
|
|
||
|
|
fl=(1)
|
||
|
|
fn=(2) query
|
||
|
|
10 1500 700
|
||
|
|
cfl=(1)
|
||
|
|
cfn=(3)
|
||
|
|
calls=2 0 0
|
||
|
|
15 500 200
|
||
|
|
|
||
|
|
fl=(1)
|
||
|
|
fn=(3) connect
|
||
|
|
20 250 100
|
||
|
|
`
|
||
|
|
|
||
|
|
func TestParseSimple(t *testing.T) {
|
||
|
|
p, err := cachegrind.Parse(strings.NewReader(simpleFixture))
|
||
|
|
require.NoError(t, err)
|
||
|
|
|
||
|
|
assert.Equal(t, "index.php", p.Cmd)
|
||
|
|
assert.Equal(t, []string{"Time_(10ns)", "Memory_(bytes)"}, p.Events)
|
||
|
|
assert.Len(t, p.Functions, 3)
|
||
|
|
|
||
|
|
main := p.ByName["main"]
|
||
|
|
require.Len(t, main, 1)
|
||
|
|
// main costs: self (2000,1000) + call to query (1500,700) = (3500,1700)
|
||
|
|
assert.Equal(t, []int64{3500, 1700}, main[0].Costs)
|
||
|
|
assert.Len(t, main[0].Calls, 1)
|
||
|
|
assert.Equal(t, int64(1), main[0].Calls[0].Count)
|
||
|
|
assert.Equal(t, "query", main[0].Calls[0].Callee.Name)
|
||
|
|
|
||
|
|
query := p.ByName["query"]
|
||
|
|
require.Len(t, query, 1)
|
||
|
|
assert.Equal(t, []int64{2000, 900}, query[0].Costs)
|
||
|
|
assert.Len(t, query[0].CalledBy, 1)
|
||
|
|
assert.Len(t, query[0].Calls, 1)
|
||
|
|
assert.Equal(t, int64(2), query[0].Calls[0].Count)
|
||
|
|
|
||
|
|
connect := p.ByName["connect"]
|
||
|
|
require.Len(t, connect, 1)
|
||
|
|
assert.Equal(t, []int64{250, 100}, connect[0].Costs)
|
||
|
|
assert.Empty(t, connect[0].Calls)
|
||
|
|
assert.Len(t, connect[0].CalledBy, 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseFile_gzip(t *testing.T) {
|
||
|
|
path := "/home/leclere/Uppler/apps/uppler1/docker/tmp/xdebug/cachegrind.out.45.gz"
|
||
|
|
p, err := cachegrind.ParseFile(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Skipf("real file unavailable: %v", err)
|
||
|
|
}
|
||
|
|
assert.NotEmpty(t, p.Events)
|
||
|
|
assert.NotEmpty(t, p.Functions)
|
||
|
|
assert.NotEmpty(t, p.Cmd)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run test — verify it fails**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/cachegrind/... -v -run TestParseSimple
|
||
|
|
```
|
||
|
|
Expected: FAIL — `cachegrind.Parse undefined`
|
||
|
|
|
||
|
|
- [ ] **Create parser.go** (`internal/cachegrind/parser.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package cachegrind
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"compress/gzip"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ParseFile opens a cachegrind file (plain text or gzip) and returns a Profile.
|
||
|
|
func ParseFile(path string) (*Profile, error) {
|
||
|
|
f, err := os.Open(path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("open %s: %w", path, err)
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
var r io.Reader = f
|
||
|
|
if strings.HasSuffix(path, ".gz") {
|
||
|
|
gr, gzErr := gzip.NewReader(f)
|
||
|
|
if gzErr != nil {
|
||
|
|
// fall back to plain text
|
||
|
|
if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil {
|
||
|
|
return nil, fmt.Errorf("gzip open %s: %w", path, gzErr)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
defer gr.Close()
|
||
|
|
r = gr
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return Parse(r)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse reads cachegrind format from r and returns a Profile.
|
||
|
|
// All cost lines for a function are summed into inclusive costs (includes callees).
|
||
|
|
func Parse(r io.Reader) (*Profile, error) {
|
||
|
|
p := &Profile{ByName: make(map[string][]*Function)}
|
||
|
|
|
||
|
|
flTable := map[int]string{}
|
||
|
|
fnTable := map[int]*Function{}
|
||
|
|
currentFile := ""
|
||
|
|
var currentFn *Function
|
||
|
|
var pendingCallee *Function
|
||
|
|
pendingCallCount := int64(0)
|
||
|
|
inCallCtx := false
|
||
|
|
nEvents := 0
|
||
|
|
|
||
|
|
scanner := bufio.NewScanner(r)
|
||
|
|
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
|
||
|
|
|
||
|
|
for scanner.Scan() {
|
||
|
|
line := scanner.Text()
|
||
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
switch {
|
||
|
|
case strings.HasPrefix(line, "events:"):
|
||
|
|
p.Events = strings.Fields(strings.TrimPrefix(line, "events:"))
|
||
|
|
nEvents = len(p.Events)
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "cmd:"):
|
||
|
|
p.Cmd = strings.TrimSpace(strings.TrimPrefix(line, "cmd:"))
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "version:"), strings.HasPrefix(line, "creator:"),
|
||
|
|
strings.HasPrefix(line, "part:"), strings.HasPrefix(line, "positions:"),
|
||
|
|
strings.HasPrefix(line, "ob="), strings.HasPrefix(line, "totals:"),
|
||
|
|
strings.HasPrefix(line, "summary:"):
|
||
|
|
// header/summary fields — skip
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "fl="):
|
||
|
|
alias, name := parseAlias(line[3:])
|
||
|
|
if name != "" {
|
||
|
|
flTable[alias] = name
|
||
|
|
}
|
||
|
|
currentFile = flTable[alias]
|
||
|
|
inCallCtx = false
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "fn="):
|
||
|
|
alias, name := parseAlias(line[3:])
|
||
|
|
if name != "" {
|
||
|
|
fn := &Function{
|
||
|
|
Name: name,
|
||
|
|
File: currentFile,
|
||
|
|
Costs: make([]int64, nEvents),
|
||
|
|
}
|
||
|
|
fnTable[alias] = fn
|
||
|
|
p.Functions = append(p.Functions, fn)
|
||
|
|
p.ByName[name] = append(p.ByName[name], fn)
|
||
|
|
}
|
||
|
|
currentFn = fnTable[alias]
|
||
|
|
inCallCtx = false
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "cfl="):
|
||
|
|
// called file alias — not needed for P0 (already known from fn= alias)
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "cfn="):
|
||
|
|
alias, _ := parseAlias(line[4:])
|
||
|
|
pendingCallee = fnTable[alias]
|
||
|
|
|
||
|
|
case strings.HasPrefix(line, "calls="):
|
||
|
|
parts := strings.Fields(line[6:])
|
||
|
|
if len(parts) > 0 {
|
||
|
|
pendingCallCount, _ = strconv.ParseInt(parts[0], 10, 64)
|
||
|
|
}
|
||
|
|
inCallCtx = true
|
||
|
|
|
||
|
|
default:
|
||
|
|
if len(line) == 0 || line[0] < '0' || line[0] > '9' {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
costs, err := parseCostLine(line, nEvents)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if inCallCtx && currentFn != nil && pendingCallee != nil {
|
||
|
|
call := &Call{
|
||
|
|
Caller: currentFn,
|
||
|
|
Callee: pendingCallee,
|
||
|
|
Count: pendingCallCount,
|
||
|
|
Costs: costs,
|
||
|
|
}
|
||
|
|
currentFn.Calls = append(currentFn.Calls, call)
|
||
|
|
pendingCallee.CalledBy = append(pendingCallee.CalledBy, call)
|
||
|
|
pendingCallee = nil
|
||
|
|
pendingCallCount = 0
|
||
|
|
inCallCtx = false
|
||
|
|
}
|
||
|
|
if currentFn != nil {
|
||
|
|
addCosts(currentFn.Costs, costs)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return p, scanner.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseAlias parses "(N)" or "(N) name" from the value portion of fl=/fn=.
|
||
|
|
func parseAlias(s string) (int, string) {
|
||
|
|
if !strings.HasPrefix(s, "(") {
|
||
|
|
return 0, ""
|
||
|
|
}
|
||
|
|
end := strings.Index(s, ")")
|
||
|
|
if end < 0 {
|
||
|
|
return 0, ""
|
||
|
|
}
|
||
|
|
alias, _ := strconv.Atoi(s[1:end])
|
||
|
|
name := ""
|
||
|
|
if len(s) > end+1 {
|
||
|
|
name = strings.TrimSpace(s[end+1:])
|
||
|
|
}
|
||
|
|
return alias, name
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseCostLine parses "linenum cost1 cost2 ..." and returns the cost slice.
|
||
|
|
func parseCostLine(line string, nEvents int) ([]int64, error) {
|
||
|
|
parts := strings.Fields(line)
|
||
|
|
if len(parts) < 2 {
|
||
|
|
return nil, fmt.Errorf("short cost line: %q", line)
|
||
|
|
}
|
||
|
|
costs := make([]int64, nEvents)
|
||
|
|
for i := 0; i < nEvents && i+1 < len(parts); i++ {
|
||
|
|
v, err := strconv.ParseInt(parts[i+1], 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
costs[i] = v
|
||
|
|
}
|
||
|
|
return costs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func addCosts(dst, src []int64) {
|
||
|
|
for i := range src {
|
||
|
|
if i < len(dst) {
|
||
|
|
dst[i] += src[i]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run test — verify it passes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/cachegrind/... -v
|
||
|
|
```
|
||
|
|
Expected: PASS (both TestParseSimple and TestParseFile_gzip; the gzip test may SKIP if the file is unavailable)
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/cachegrind/
|
||
|
|
git commit -m "feat: add cachegrind parser with gzip support"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: LRU cache (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `internal/cache/lru_test.go`
|
||
|
|
- Create: `internal/cache/lru.go`
|
||
|
|
|
||
|
|
- [ ] **Write the failing test** (`internal/cache/lru_test.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package cache_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestLRU_GetSet(t *testing.T) {
|
||
|
|
c := cache.New(2)
|
||
|
|
t0 := time.Now()
|
||
|
|
p := &cachegrind.Profile{Cmd: "p1"}
|
||
|
|
|
||
|
|
c.Set("/a", p, t0)
|
||
|
|
got, ok := c.Get("/a", t0)
|
||
|
|
require.True(t, ok)
|
||
|
|
assert.Equal(t, p, got)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLRU_Eviction(t *testing.T) {
|
||
|
|
c := cache.New(2)
|
||
|
|
t0 := time.Now()
|
||
|
|
|
||
|
|
p1 := &cachegrind.Profile{Cmd: "p1"}
|
||
|
|
p2 := &cachegrind.Profile{Cmd: "p2"}
|
||
|
|
p3 := &cachegrind.Profile{Cmd: "p3"}
|
||
|
|
|
||
|
|
c.Set("/a", p1, t0)
|
||
|
|
c.Set("/b", p2, t0)
|
||
|
|
c.Get("/a", t0) // access /a → /b becomes LRU
|
||
|
|
c.Set("/c", p3, t0) // should evict /b
|
||
|
|
|
||
|
|
_, ok := c.Get("/b", t0)
|
||
|
|
assert.False(t, ok, "b should have been evicted")
|
||
|
|
|
||
|
|
got, ok := c.Get("/a", t0)
|
||
|
|
require.True(t, ok)
|
||
|
|
assert.Equal(t, p1, got)
|
||
|
|
|
||
|
|
got, ok = c.Get("/c", t0)
|
||
|
|
require.True(t, ok)
|
||
|
|
assert.Equal(t, p3, got)
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLRU_ModtimeInvalidation(t *testing.T) {
|
||
|
|
c := cache.New(2)
|
||
|
|
t1 := time.Now()
|
||
|
|
t2 := t1.Add(time.Second)
|
||
|
|
|
||
|
|
c.Set("/a", &cachegrind.Profile{Cmd: "old"}, t1)
|
||
|
|
_, ok := c.Get("/a", t2) // newer modtime → stale
|
||
|
|
assert.False(t, ok)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run test — verify it fails**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/cache/... -v
|
||
|
|
```
|
||
|
|
Expected: FAIL — `cache.New undefined`
|
||
|
|
|
||
|
|
- [ ] **Create lru.go** (`internal/cache/lru.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package cache
|
||
|
|
|
||
|
|
import (
|
||
|
|
"container/list"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
const DefaultCapacity = 2
|
||
|
|
|
||
|
|
type entry struct {
|
||
|
|
key string
|
||
|
|
profile *cachegrind.Profile
|
||
|
|
modTime time.Time
|
||
|
|
elem *list.Element
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cache is a thread-safe LRU cache keyed by absolute file path.
|
||
|
|
type Cache struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
capacity int
|
||
|
|
items map[string]*entry
|
||
|
|
order list.List
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(capacity int) *Cache {
|
||
|
|
return &Cache{
|
||
|
|
capacity: capacity,
|
||
|
|
items: make(map[string]*entry),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get returns the cached Profile if key exists and modTime matches.
|
||
|
|
func (c *Cache) Get(key string, modTime time.Time) (*cachegrind.Profile, bool) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
|
||
|
|
e, ok := c.items[key]
|
||
|
|
if !ok {
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
if !e.modTime.Equal(modTime) {
|
||
|
|
c.evict(e)
|
||
|
|
return nil, false
|
||
|
|
}
|
||
|
|
c.order.MoveToFront(e.elem)
|
||
|
|
return e.profile, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set stores a Profile, evicting the least-recently-used entry if at capacity.
|
||
|
|
func (c *Cache) Set(key string, profile *cachegrind.Profile, modTime time.Time) {
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
|
||
|
|
if e, ok := c.items[key]; ok {
|
||
|
|
c.evict(e)
|
||
|
|
}
|
||
|
|
for len(c.items) >= c.capacity {
|
||
|
|
back := c.order.Back()
|
||
|
|
if back == nil {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
c.evict(back.Value.(*entry))
|
||
|
|
}
|
||
|
|
e := &entry{key: key, profile: profile, modTime: modTime}
|
||
|
|
e.elem = c.order.PushFront(e)
|
||
|
|
c.items[key] = e
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Cache) evict(e *entry) {
|
||
|
|
c.order.Remove(e.elem)
|
||
|
|
delete(c.items, e.key)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run test — verify it passes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/cache/... -v
|
||
|
|
```
|
||
|
|
Expected: PASS (3 tests)
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/cache/
|
||
|
|
git commit -m "feat: add LRU cache for parsed profiles"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: Shared tool helpers
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `internal/tools/shared.go`
|
||
|
|
|
||
|
|
- [ ] **Create shared.go** (`internal/tools/shared.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package tools
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
// loadProfile returns a parsed Profile from cache or by parsing the file.
|
||
|
|
func loadProfile(filePath string, c *cache.Cache) (*cachegrind.Profile, error) {
|
||
|
|
abs, err := filepath.Abs(filePath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("resolve path %s: %w", filePath, err)
|
||
|
|
}
|
||
|
|
info, err := os.Stat(abs)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("file not found: %s", abs)
|
||
|
|
}
|
||
|
|
if p, ok := c.Get(abs, info.ModTime()); ok {
|
||
|
|
return p, nil
|
||
|
|
}
|
||
|
|
p, err := cachegrind.ParseFile(abs)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
c.Set(abs, p, info.ModTime())
|
||
|
|
return p, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// findFunctions returns functions matching name (exact first, then contains).
|
||
|
|
// Returns a non-empty errMsg if nothing is found.
|
||
|
|
func findFunctions(p *cachegrind.Profile, name string) ([]*cachegrind.Function, string) {
|
||
|
|
if fns, ok := p.ByName[name]; ok {
|
||
|
|
return fns, ""
|
||
|
|
}
|
||
|
|
var matches []*cachegrind.Function
|
||
|
|
for _, fn := range p.Functions {
|
||
|
|
if strings.Contains(fn.Name, name) {
|
||
|
|
matches = append(matches, fn)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(matches) == 0 {
|
||
|
|
return nil, fmt.Sprintf("function %q not found in profile (no exact or contains match)", name)
|
||
|
|
}
|
||
|
|
return matches, ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// sortedByTime returns a copy of fns sorted by Costs[0] descending.
|
||
|
|
func sortedByTime(fns []*cachegrind.Function) []*cachegrind.Function {
|
||
|
|
sorted := make([]*cachegrind.Function, len(fns))
|
||
|
|
copy(sorted, fns)
|
||
|
|
sort.Slice(sorted, func(i, j int) bool {
|
||
|
|
ci, cj := int64(0), int64(0)
|
||
|
|
if len(sorted[i].Costs) > 0 {
|
||
|
|
ci = sorted[i].Costs[0]
|
||
|
|
}
|
||
|
|
if len(sorted[j].Costs) > 0 {
|
||
|
|
cj = sorted[j].Costs[0]
|
||
|
|
}
|
||
|
|
return ci > cj
|
||
|
|
})
|
||
|
|
return sorted
|
||
|
|
}
|
||
|
|
|
||
|
|
// formatCosts formats a cost slice as "Event=value Event=value".
|
||
|
|
func formatCosts(costs []int64, events []string) string {
|
||
|
|
parts := make([]string, 0, len(events))
|
||
|
|
for i, ev := range events {
|
||
|
|
if i < len(costs) {
|
||
|
|
parts = append(parts, fmt.Sprintf("%s=%d", ev, costs[i]))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return strings.Join(parts, " ")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Build to verify**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go build ./internal/tools/...
|
||
|
|
```
|
||
|
|
Expected: success
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/tools/shared.go
|
||
|
|
git commit -m "feat: add shared tool helpers"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: analyze_profile tool (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `internal/tools/tools_test.go`
|
||
|
|
- Create: `internal/tools/analyze.go`
|
||
|
|
|
||
|
|
- [ ] **Write the failing test** — test helper + analyze test only (`internal/tools/tools_test.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package tools_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"github.com/stretchr/testify/require"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/tools"
|
||
|
|
)
|
||
|
|
|
||
|
|
func makeTestProfile() *cachegrind.Profile {
|
||
|
|
main := &cachegrind.Function{Name: "main", File: "index.php", Costs: []int64{3500, 1700}}
|
||
|
|
query := &cachegrind.Function{Name: "query", File: "index.php", Costs: []int64{2000, 900}}
|
||
|
|
connect := &cachegrind.Function{Name: "connect", File: "index.php", Costs: []int64{250, 100}}
|
||
|
|
|
||
|
|
call1 := &cachegrind.Call{Caller: main, Callee: query, Count: 1, Costs: []int64{1500, 700}}
|
||
|
|
call2 := &cachegrind.Call{Caller: query, Callee: connect, Count: 2, Costs: []int64{500, 200}}
|
||
|
|
|
||
|
|
main.Calls = []*cachegrind.Call{call1}
|
||
|
|
query.CalledBy = []*cachegrind.Call{call1}
|
||
|
|
query.Calls = []*cachegrind.Call{call2}
|
||
|
|
connect.CalledBy = []*cachegrind.Call{call2}
|
||
|
|
|
||
|
|
return &cachegrind.Profile{
|
||
|
|
Cmd: "index.php",
|
||
|
|
Events: []string{"Time_(10ns)", "Memory_(bytes)"},
|
||
|
|
Functions: []*cachegrind.Function{main, query, connect},
|
||
|
|
ByName: map[string][]*cachegrind.Function{
|
||
|
|
"main": {main},
|
||
|
|
"query": {query},
|
||
|
|
"connect": {connect},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAnalyze_TopN(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
result := tools.Analyze(p, 3)
|
||
|
|
|
||
|
|
assert.Contains(t, result, "index.php")
|
||
|
|
assert.Contains(t, result, "Functions: 3 total")
|
||
|
|
assert.Contains(t, result, "main")
|
||
|
|
assert.Contains(t, result, "query")
|
||
|
|
assert.Contains(t, result, "connect")
|
||
|
|
// main must appear before query (higher cost)
|
||
|
|
assert.Less(t, strings.Index(result, "main"), strings.Index(result, "query"))
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestAnalyze_TopNLimited(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
result := tools.Analyze(p, 1)
|
||
|
|
|
||
|
|
assert.Contains(t, result, "main")
|
||
|
|
assert.NotContains(t, result, "query") // only top 1
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run test — verify it fails**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/tools/... -v -run TestAnalyze
|
||
|
|
```
|
||
|
|
Expected: FAIL — `tools.Analyze undefined`
|
||
|
|
|
||
|
|
- [ ] **Create analyze.go** (`internal/tools/analyze.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package tools
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/mark3labs/mcp-go/mcp"
|
||
|
|
"github.com/mark3labs/mcp-go/server"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
// AnalyzeTool returns the MCP tool definition for analyze_profile.
|
||
|
|
func AnalyzeTool() mcp.Tool {
|
||
|
|
return mcp.NewTool("analyze_profile",
|
||
|
|
mcp.WithDescription("Analyze an Xdebug cachegrind profiling file. Returns global stats and top N functions sorted by inclusive time cost."),
|
||
|
|
mcp.WithString("file_path",
|
||
|
|
mcp.Required(),
|
||
|
|
mcp.Description("Absolute or relative path to the cachegrind file (.gz or plain text)"),
|
||
|
|
),
|
||
|
|
mcp.WithNumber("top_n",
|
||
|
|
mcp.Description("Number of top functions to return (default: 20)"),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// AnalyzeHandler returns the MCP handler for analyze_profile.
|
||
|
|
func AnalyzeHandler(c *cache.Cache) server.ToolHandlerFunc {
|
||
|
|
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
|
|
filePath, ok := req.Params.Arguments["file_path"].(string)
|
||
|
|
if !ok || filePath == "" {
|
||
|
|
return mcp.NewToolResultError("file_path is required"), nil
|
||
|
|
}
|
||
|
|
topN := 20
|
||
|
|
if n, ok := req.Params.Arguments["top_n"].(float64); ok && n > 0 {
|
||
|
|
topN = int(n)
|
||
|
|
}
|
||
|
|
p, err := loadProfile(filePath, c)
|
||
|
|
if err != nil {
|
||
|
|
return mcp.NewToolResultError(err.Error()), nil
|
||
|
|
}
|
||
|
|
return mcp.NewToolResultText(Analyze(p, topN)), nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Analyze formats the top-N analysis of p. Exported for testing.
|
||
|
|
func Analyze(p *cachegrind.Profile, topN int) string {
|
||
|
|
sorted := sortedByTime(p.Functions)
|
||
|
|
if topN > len(sorted) {
|
||
|
|
topN = len(sorted)
|
||
|
|
}
|
||
|
|
|
||
|
|
var sb strings.Builder
|
||
|
|
fmt.Fprintf(&sb, "Command: %s\n", p.Cmd)
|
||
|
|
fmt.Fprintf(&sb, "Events: %s\n", strings.Join(p.Events, ", "))
|
||
|
|
fmt.Fprintf(&sb, "Functions: %d total\n\n", len(p.Functions))
|
||
|
|
|
||
|
|
peak := int64(0)
|
||
|
|
if len(sorted) > 0 && len(sorted[0].Costs) > 0 {
|
||
|
|
peak = sorted[0].Costs[0]
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(p.Events) > 0 {
|
||
|
|
fmt.Fprintf(&sb, "Top %d functions by %s:\n", topN, p.Events[0])
|
||
|
|
}
|
||
|
|
for i, fn := range sorted[:topN] {
|
||
|
|
pct := ""
|
||
|
|
if peak > 0 && len(fn.Costs) > 0 {
|
||
|
|
pct = fmt.Sprintf(" (%.1f%% of peak)", float64(fn.Costs[0])/float64(peak)*100)
|
||
|
|
}
|
||
|
|
fmt.Fprintf(&sb, " %3d. %-60s %s%s\n", i+1, fn.Name, formatCosts(fn.Costs, p.Events), pct)
|
||
|
|
fmt.Fprintf(&sb, " %s\n", fn.File)
|
||
|
|
}
|
||
|
|
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run test — verify it passes**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/tools/... -v -run TestAnalyze
|
||
|
|
```
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/tools/analyze.go internal/tools/tools_test.go
|
||
|
|
git commit -m "feat: add analyze_profile tool"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: get_callers and get_callees tools (TDD)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `internal/tools/tools_test.go`
|
||
|
|
- Create: `internal/tools/callers.go`
|
||
|
|
- Create: `internal/tools/callees.go`
|
||
|
|
|
||
|
|
- [ ] **Add tests to tools_test.go** (append to the existing file)
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestCallers_Found(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
result := tools.Callers(p, "query", 10)
|
||
|
|
|
||
|
|
assert.Contains(t, result, "main")
|
||
|
|
assert.Contains(t, result, "calls=1")
|
||
|
|
require.NotContains(t, result, "not found")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallers_ContainsFallback(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
// "uer" is a substring of "query"
|
||
|
|
result := tools.Callers(p, "uer", 10)
|
||
|
|
|
||
|
|
assert.Contains(t, result, "main")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallers_NotFound(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
result := tools.Callers(p, "nonexistent_xyz", 10)
|
||
|
|
|
||
|
|
assert.Contains(t, result, "not found")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallees_Found(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
result := tools.Callees(p, "query", 10)
|
||
|
|
|
||
|
|
assert.Contains(t, result, "connect")
|
||
|
|
assert.Contains(t, result, "calls=2")
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallees_NotFound(t *testing.T) {
|
||
|
|
p := makeTestProfile()
|
||
|
|
result := tools.Callees(p, "connect", 10) // connect has no callees
|
||
|
|
|
||
|
|
assert.Contains(t, result, "no outgoing calls")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run tests — verify they fail**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/tools/... -v -run "TestCallers|TestCallees"
|
||
|
|
```
|
||
|
|
Expected: FAIL — `tools.Callers undefined`, `tools.Callees undefined`
|
||
|
|
|
||
|
|
- [ ] **Create callers.go** (`internal/tools/callers.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package tools
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/mark3labs/mcp-go/mcp"
|
||
|
|
"github.com/mark3labs/mcp-go/server"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
// CallersTool returns the MCP tool definition for get_callers.
|
||
|
|
func CallersTool() mcp.Tool {
|
||
|
|
return mcp.NewTool("get_callers",
|
||
|
|
mcp.WithDescription("List functions that call a given function in an Xdebug profiling file, sorted by call cost descending."),
|
||
|
|
mcp.WithString("file_path",
|
||
|
|
mcp.Required(),
|
||
|
|
mcp.Description("Absolute or relative path to the cachegrind file"),
|
||
|
|
),
|
||
|
|
mcp.WithString("function_name",
|
||
|
|
mcp.Required(),
|
||
|
|
mcp.Description("Exact function name or substring to search for"),
|
||
|
|
),
|
||
|
|
mcp.WithNumber("top_n",
|
||
|
|
mcp.Description("Maximum number of callers to return (default: 10)"),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CallersHandler returns the MCP handler for get_callers.
|
||
|
|
func CallersHandler(c *cache.Cache) server.ToolHandlerFunc {
|
||
|
|
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
|
|
filePath, ok := req.Params.Arguments["file_path"].(string)
|
||
|
|
if !ok || filePath == "" {
|
||
|
|
return mcp.NewToolResultError("file_path is required"), nil
|
||
|
|
}
|
||
|
|
name, ok := req.Params.Arguments["function_name"].(string)
|
||
|
|
if !ok || name == "" {
|
||
|
|
return mcp.NewToolResultError("function_name is required"), nil
|
||
|
|
}
|
||
|
|
topN := 10
|
||
|
|
if n, ok := req.Params.Arguments["top_n"].(float64); ok && n > 0 {
|
||
|
|
topN = int(n)
|
||
|
|
}
|
||
|
|
p, err := loadProfile(filePath, c)
|
||
|
|
if err != nil {
|
||
|
|
return mcp.NewToolResultError(err.Error()), nil
|
||
|
|
}
|
||
|
|
return mcp.NewToolResultText(Callers(p, name, topN)), nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Callers formats the callers of name in p. Exported for testing.
|
||
|
|
func Callers(p *cachegrind.Profile, name string, topN int) string {
|
||
|
|
fns, errMsg := findFunctions(p, name)
|
||
|
|
if errMsg != "" {
|
||
|
|
return errMsg
|
||
|
|
}
|
||
|
|
|
||
|
|
var sb strings.Builder
|
||
|
|
for _, fn := range fns {
|
||
|
|
fmt.Fprintf(&sb, "Callers of %q [%s]\n", fn.Name, fn.File)
|
||
|
|
if len(fn.CalledBy) == 0 {
|
||
|
|
fmt.Fprintf(&sb, " no incoming calls recorded\n\n")
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
callers := sortedCalls(fn.CalledBy)
|
||
|
|
if topN < len(callers) {
|
||
|
|
callers = callers[:topN]
|
||
|
|
}
|
||
|
|
fmt.Fprintf(&sb, " called by %d function(s):\n\n", len(fn.CalledBy))
|
||
|
|
for i, call := range callers {
|
||
|
|
fmt.Fprintf(&sb, " %3d. %-60s calls=%d %s\n",
|
||
|
|
i+1, call.Caller.Name, call.Count, formatCosts(call.Costs, p.Events))
|
||
|
|
fmt.Fprintf(&sb, " %s\n", call.Caller.File)
|
||
|
|
}
|
||
|
|
fmt.Fprintln(&sb)
|
||
|
|
}
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// sortedCalls returns a copy of calls sorted by Costs[0] descending.
|
||
|
|
func sortedCalls(calls []*cachegrind.Call) []*cachegrind.Call {
|
||
|
|
out := make([]*cachegrind.Call, len(calls))
|
||
|
|
copy(out, calls)
|
||
|
|
sortCallSlice(out)
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func sortCallSlice(calls []*cachegrind.Call) {
|
||
|
|
for i := 1; i < len(calls); i++ {
|
||
|
|
for j := i; j > 0; j-- {
|
||
|
|
ci, cj := int64(0), int64(0)
|
||
|
|
if len(calls[j].Costs) > 0 {
|
||
|
|
ci = calls[j].Costs[0]
|
||
|
|
}
|
||
|
|
if len(calls[j-1].Costs) > 0 {
|
||
|
|
cj = calls[j-1].Costs[0]
|
||
|
|
}
|
||
|
|
if ci > cj {
|
||
|
|
calls[j], calls[j-1] = calls[j-1], calls[j]
|
||
|
|
} else {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Create callees.go** (`internal/tools/callees.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package tools
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/mark3labs/mcp-go/mcp"
|
||
|
|
"github.com/mark3labs/mcp-go/server"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind"
|
||
|
|
)
|
||
|
|
|
||
|
|
// CalleesTool returns the MCP tool definition for get_callees.
|
||
|
|
func CalleesTool() mcp.Tool {
|
||
|
|
return mcp.NewTool("get_callees",
|
||
|
|
mcp.WithDescription("List functions called by a given function in an Xdebug profiling file, sorted by call cost descending."),
|
||
|
|
mcp.WithString("file_path",
|
||
|
|
mcp.Required(),
|
||
|
|
mcp.Description("Absolute or relative path to the cachegrind file"),
|
||
|
|
),
|
||
|
|
mcp.WithString("function_name",
|
||
|
|
mcp.Required(),
|
||
|
|
mcp.Description("Exact function name or substring to search for"),
|
||
|
|
),
|
||
|
|
mcp.WithNumber("top_n",
|
||
|
|
mcp.Description("Maximum number of callees to return (default: 10)"),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CalleesHandler returns the MCP handler for get_callees.
|
||
|
|
func CalleesHandler(c *cache.Cache) server.ToolHandlerFunc {
|
||
|
|
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
|
|
filePath, ok := req.Params.Arguments["file_path"].(string)
|
||
|
|
if !ok || filePath == "" {
|
||
|
|
return mcp.NewToolResultError("file_path is required"), nil
|
||
|
|
}
|
||
|
|
name, ok := req.Params.Arguments["function_name"].(string)
|
||
|
|
if !ok || name == "" {
|
||
|
|
return mcp.NewToolResultError("function_name is required"), nil
|
||
|
|
}
|
||
|
|
topN := 10
|
||
|
|
if n, ok := req.Params.Arguments["top_n"].(float64); ok && n > 0 {
|
||
|
|
topN = int(n)
|
||
|
|
}
|
||
|
|
p, err := loadProfile(filePath, c)
|
||
|
|
if err != nil {
|
||
|
|
return mcp.NewToolResultError(err.Error()), nil
|
||
|
|
}
|
||
|
|
return mcp.NewToolResultText(Callees(p, name, topN)), nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Callees formats the callees of name in p. Exported for testing.
|
||
|
|
func Callees(p *cachegrind.Profile, name string, topN int) string {
|
||
|
|
fns, errMsg := findFunctions(p, name)
|
||
|
|
if errMsg != "" {
|
||
|
|
return errMsg
|
||
|
|
}
|
||
|
|
|
||
|
|
var sb strings.Builder
|
||
|
|
for _, fn := range fns {
|
||
|
|
fmt.Fprintf(&sb, "Callees of %q [%s]\n", fn.Name, fn.File)
|
||
|
|
if len(fn.Calls) == 0 {
|
||
|
|
fmt.Fprintf(&sb, " no outgoing calls recorded\n\n")
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
callees := sortedCalls(fn.Calls)
|
||
|
|
if topN < len(callees) {
|
||
|
|
callees = callees[:topN]
|
||
|
|
}
|
||
|
|
fmt.Fprintf(&sb, " calls %d function(s):\n\n", len(fn.Calls))
|
||
|
|
for i, call := range callees {
|
||
|
|
fmt.Fprintf(&sb, " %3d. %-60s calls=%d %s\n",
|
||
|
|
i+1, call.Callee.Name, call.Count, formatCosts(call.Costs, p.Events))
|
||
|
|
fmt.Fprintf(&sb, " %s\n", call.Callee.File)
|
||
|
|
}
|
||
|
|
fmt.Fprintln(&sb)
|
||
|
|
}
|
||
|
|
return sb.String()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Run all tools tests — verify they pass**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go test ./internal/tools/... -v
|
||
|
|
```
|
||
|
|
Expected: PASS (all 7 tests)
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/tools/
|
||
|
|
git commit -m "feat: add get_callers and get_callees tools"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Wiring — app.go and main.go
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `internal/app/app.go`
|
||
|
|
- Create: `cmd/xdebug-mcp/main.go`
|
||
|
|
|
||
|
|
- [ ] **Create app.go** (`internal/app/app.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package app
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
|
||
|
|
"github.com/mark3labs/mcp-go/server"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/mcp-framework/bootstrap"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/cache"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/tools"
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/mcpgen"
|
||
|
|
)
|
||
|
|
|
||
|
|
func Run(ctx context.Context, args []string, version string) error {
|
||
|
|
c := cache.New(cache.DefaultCapacity)
|
||
|
|
|
||
|
|
return bootstrap.Run(ctx, bootstrap.Options{
|
||
|
|
BinaryName: mcpgen.BinaryName,
|
||
|
|
Description: mcpgen.DefaultDescription,
|
||
|
|
Version: version,
|
||
|
|
Args: args,
|
||
|
|
Hooks: bootstrap.Hooks{
|
||
|
|
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||
|
|
return runMCP(c, version)
|
||
|
|
},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func runMCP(c *cache.Cache, version string) error {
|
||
|
|
s := server.NewMCPServer(mcpgen.BinaryName, version)
|
||
|
|
s.AddTool(tools.AnalyzeTool(), tools.AnalyzeHandler(c))
|
||
|
|
s.AddTool(tools.CallersTool(), tools.CallersHandler(c))
|
||
|
|
s.AddTool(tools.CalleesTool(), tools.CalleesHandler(c))
|
||
|
|
return server.ServeStdio(s)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Create main.go** (`cmd/xdebug-mcp/main.go`)
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"log"
|
||
|
|
"os"
|
||
|
|
|
||
|
|
"forge.lclr.dev/AI/xdebug-mcp/internal/app"
|
||
|
|
)
|
||
|
|
|
||
|
|
var version = "dev"
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
if err := app.Run(context.Background(), os.Args[1:], version); err != nil {
|
||
|
|
log.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Build the binary**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
go build ./cmd/xdebug-mcp/
|
||
|
|
```
|
||
|
|
Expected: binary `xdebug-mcp` created in current directory, no errors
|
||
|
|
|
||
|
|
- [ ] **Verify help output**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
./xdebug-mcp help
|
||
|
|
```
|
||
|
|
Expected: usage output listing the `mcp` subcommand
|
||
|
|
|
||
|
|
- [ ] **Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/app/app.go cmd/xdebug-mcp/main.go
|
||
|
|
git commit -m "feat: wire MCP server with bootstrap CLI"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 9: Integration smoke test
|
||
|
|
|
||
|
|
- [ ] **Run tools/list via MCP protocol**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' | ./xdebug-mcp mcp
|
||
|
|
```
|
||
|
|
Expected: JSON response with `serverInfo` containing `name: "xdebug-mcp"`
|
||
|
|
|
||
|
|
- [ ] **Test analyze_profile on a real file**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
echo '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"analyze_profile","arguments":{"file_path":"/home/leclere/Uppler/apps/uppler1/docker/tmp/xdebug/cachegrind.out.45.gz","top_n":5}}}' | ./xdebug-mcp mcp
|
||
|
|
```
|
||
|
|
Expected: JSON response with `content[0].text` containing top 5 PHP functions with costs
|
||
|
|
|
||
|
|
- [ ] **Commit binary to .gitignore and finalize**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
echo "xdebug-mcp" >> .gitignore
|
||
|
|
git add .gitignore
|
||
|
|
git commit -m "chore: ignore built binary"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Notes for the implementer
|
||
|
|
|
||
|
|
- **mcpgen constants**: After running `mcp-framework generate`, inspect `mcpgen/metadata.go` to confirm the exact names of `BinaryName` and `DefaultDescription`. Adjust `app.go` if the names differ.
|
||
|
|
- **mark3labs/mcp-go API**: Parameter extraction uses `req.Params.Arguments["key"].(type)` — JSON numbers arrive as `float64`. If `mcp.WithNumber` is not available in the installed version, use `mcp.WithString` and parse manually.
|
||
|
|
- **MCP protocol handshake**: `server.ServeStdio` handles the `initialize` / `initialized` handshake automatically. Tools are only callable after the handshake.
|
||
|
|
- **Large files**: The first call to `analyze_profile` on a 325 MB uncompressed file takes several seconds to parse. Subsequent calls on the same file hit the LRU cache and are instant.
|