xdebug-mcp/docs/superpowers/plans/2026-05-12-xdebug-mcp.md
2026-05-12 09:30:10 +02:00

34 KiB

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

    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

    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

    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

    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

    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

    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

    go build ./internal/cachegrind/...
    

    Expected: no output (success)

  • Commit

    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)

    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

    go test ./internal/cachegrind/... -v -run TestParseSimple
    

    Expected: FAIL — cachegrind.Parse undefined

  • Create parser.go (internal/cachegrind/parser.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

    go test ./internal/cachegrind/... -v
    

    Expected: PASS (both TestParseSimple and TestParseFile_gzip; the gzip test may SKIP if the file is unavailable)

  • Commit

    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)

    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

    go test ./internal/cache/... -v
    

    Expected: FAIL — cache.New undefined

  • Create lru.go (internal/cache/lru.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

    go test ./internal/cache/... -v
    

    Expected: PASS (3 tests)

  • Commit

    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)

    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

    go build ./internal/tools/...
    

    Expected: success

  • Commit

    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)

    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

    go test ./internal/tools/... -v -run TestAnalyze
    

    Expected: FAIL — tools.Analyze undefined

  • Create analyze.go (internal/tools/analyze.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

    go test ./internal/tools/... -v -run TestAnalyze
    

    Expected: PASS

  • Commit

    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)

    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

    go test ./internal/tools/... -v -run "TestCallers|TestCallees"
    

    Expected: FAIL — tools.Callers undefined, tools.Callees undefined

  • Create callers.go (internal/tools/callers.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)

    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

    go test ./internal/tools/... -v
    

    Expected: PASS (all 7 tests)

  • Commit

    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)

    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)

    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

    go build ./cmd/xdebug-mcp/
    

    Expected: binary xdebug-mcp created in current directory, no errors

  • Verify help output

    ./xdebug-mcp help
    

    Expected: usage output listing the mcp subcommand

  • Commit

    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

    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

    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

    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.