From 8fafc4a69ee9a10b4475d6e8360f81bbe0fe1a8a Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 12 May 2026 09:50:35 +0200 Subject: [PATCH] feat: add analyze_profile tool Implements the analyze_profile MCP tool with TDD: exports Analyze() for direct testing, wraps it in AnalyzeHandler() for MCP, and adds AnalyzeTool() for the schema definition. Adds mark3labs/mcp-go v0.52.0 dependency. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 7 ++++ go.sum | 14 +++++++ internal/tools/analyze.go | 78 ++++++++++++++++++++++++++++++++++++ internal/tools/tools_test.go | 61 ++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 internal/tools/analyze.go create mode 100644 internal/tools/tools_test.go diff --git a/go.mod b/go.mod index 3aea777..901cbe0 100644 --- a/go.mod +++ b/go.mod @@ -15,10 +15,17 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/mark3labs/mcp-go v0.52.0 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ac3f01c..d1d2ef3 100644 --- a/go.sum +++ b/go.sum @@ -15,28 +15,42 @@ github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQx github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ= +github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/tools/analyze.go b/internal/tools/analyze.go new file mode 100644 index 0000000..c596d93 --- /dev/null +++ b/internal/tools/analyze.go @@ -0,0 +1,78 @@ +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 := req.GetString("file_path", "") + if filePath == "" { + return mcp.NewToolResultError("file_path is required"), nil + } + topN := req.GetInt("top_n", 20) + if topN <= 0 { + topN = 20 + } + 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() +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go new file mode 100644 index 0000000..9765097 --- /dev/null +++ b/internal/tools/tools_test.go @@ -0,0 +1,61 @@ +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 +} + +// require is used to avoid unused import error if tests change +var _ = require.New