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 <noreply@anthropic.com>
This commit is contained in:
parent
c56b803f8c
commit
8fafc4a69e
4 changed files with 160 additions and 0 deletions
7
go.mod
7
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
|
||||
)
|
||||
|
|
|
|||
14
go.sum
14
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=
|
||||
|
|
|
|||
78
internal/tools/analyze.go
Normal file
78
internal/tools/analyze.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
61
internal/tools/tools_test.go
Normal file
61
internal/tools/tools_test.go
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue