diff --git a/docs/superpowers/plans/2026-05-12-xdebug-mcp.md b/docs/superpowers/plans/2026-05-12-xdebug-mcp.md new file mode 100644 index 0000000..db123f3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-xdebug-mcp.md @@ -0,0 +1,1300 @@ +# 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.