Compare commits
10 commits
392c0f71aa
...
35487b996c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35487b996c | ||
|
|
6246b88bd5 | ||
|
|
87f7092c7e | ||
|
|
302d2d9b90 | ||
|
|
f3b14fd068 | ||
|
|
c419d8ce1d | ||
|
|
3943bfb8cc | ||
|
|
8fafc4a69e | ||
|
|
c56b803f8c | ||
|
|
8d41653be4 |
14 changed files with 910 additions and 4 deletions
203
.forgejo/workflows/release.yml
Normal file
203
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
BINARY_NAME: xdebug-mcp
|
||||||
|
BUILD_PATH: build/xdebug-mcp-linux-amd64
|
||||||
|
CHECKSUM_PATH: build/xdebug-mcp-linux-amd64.sha256
|
||||||
|
MANIFEST_PATH: mcp.toml
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Build linux amd64 binary
|
||||||
|
run: make build GOOS=linux GOARCH=amd64
|
||||||
|
|
||||||
|
- name: Generate binary checksum
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
asset_name="$(basename "${BUILD_PATH}")"
|
||||||
|
checksum_value="$(sha256sum "${BUILD_PATH}" | cut -d' ' -f1)"
|
||||||
|
printf '%s %s\n' "${checksum_value}" "${asset_name}" > "${CHECKSUM_PATH}"
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
id: release_notes
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${TAG_NAME}" ]; then
|
||||||
|
echo "missing tag name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
previous_tag="$(git describe --tags --abbrev=0 "${TAG_NAME}^" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'body<<EOF\n'
|
||||||
|
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
printf '## Commits since %s\n\n' "${previous_tag}"
|
||||||
|
git log --reverse --pretty='- %h %s' "${previous_tag}..${TAG_NAME}"
|
||||||
|
else
|
||||||
|
printf '## Commits\n\n'
|
||||||
|
git log --reverse --pretty='- %h %s' "${TAG_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\nEOF\n'
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Create or fetch release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
API_URL: ${{ github.api_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_BODY: ${{ steps.release_notes.outputs.body }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${TAG_NAME}" ]; then
|
||||||
|
echo "missing tag name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
owner="${REPOSITORY%%/*}"
|
||||||
|
repo="${REPOSITORY#*/}"
|
||||||
|
release_url="${API_URL}/repos/${owner}/${repo}/releases/tags/${TAG_NAME}"
|
||||||
|
create_url="${API_URL}/repos/${owner}/${repo}/releases"
|
||||||
|
payload="$(python3 -c 'import json, os; print(json.dumps({"tag_name": os.environ["TAG_NAME"], "name": os.environ["TAG_NAME"], "body": os.environ["RELEASE_BODY"], "prerelease": False, "draft": False}))')"
|
||||||
|
|
||||||
|
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${release_url}")"
|
||||||
|
|
||||||
|
if [ "${http_code}" = "404" ]; then
|
||||||
|
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${payload}" \
|
||||||
|
"${create_url}")"
|
||||||
|
elif [ "${http_code}" -ge 200 ] && [ "${http_code}" -lt 300 ]; then
|
||||||
|
release_id="$(python3 -c 'import json; print(json.load(open("release.json", "r", encoding="utf-8"))["id"])')"
|
||||||
|
update_url="${API_URL}/repos/${owner}/${repo}/releases/${release_id}"
|
||||||
|
|
||||||
|
http_code="$(curl -sS -o release.json -w '%{http_code}' \
|
||||||
|
-X PATCH \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${payload}" \
|
||||||
|
"${update_url}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||||
|
echo "release API call failed with status ${http_code}" >&2
|
||||||
|
cat release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_id="$(python3 -c 'import json; print(json.load(open("release.json", "r", encoding="utf-8"))["id"])')"
|
||||||
|
|
||||||
|
echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Upload release asset
|
||||||
|
env:
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
API_URL: ${{ github.api_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
owner="${REPOSITORY%%/*}"
|
||||||
|
repo="${REPOSITORY#*/}"
|
||||||
|
asset_name="$(basename "${BUILD_PATH}")"
|
||||||
|
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||||
|
|
||||||
|
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${BUILD_PATH}" \
|
||||||
|
"${upload_url}")"
|
||||||
|
|
||||||
|
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||||
|
echo "asset upload failed with status ${http_code}" >&2
|
||||||
|
cat asset.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload manifest asset
|
||||||
|
env:
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
API_URL: ${{ github.api_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
owner="${REPOSITORY%%/*}"
|
||||||
|
repo="${REPOSITORY#*/}"
|
||||||
|
asset_name="$(basename "${MANIFEST_PATH}")"
|
||||||
|
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||||
|
|
||||||
|
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${MANIFEST_PATH}" \
|
||||||
|
"${upload_url}")"
|
||||||
|
|
||||||
|
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||||
|
echo "asset upload failed with status ${http_code}" >&2
|
||||||
|
cat asset.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload checksum asset
|
||||||
|
env:
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
API_URL: ${{ github.api_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
owner="${REPOSITORY%%/*}"
|
||||||
|
repo="${REPOSITORY#*/}"
|
||||||
|
asset_name="$(basename "${CHECKSUM_PATH}")"
|
||||||
|
upload_url="${API_URL}/repos/${owner}/${repo}/releases/${RELEASE_ID}/assets?name=${asset_name}"
|
||||||
|
|
||||||
|
http_code="$(curl -sS -o asset.json -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${CHECKSUM_PATH}" \
|
||||||
|
"${upload_url}")"
|
||||||
|
|
||||||
|
if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
|
||||||
|
echo "asset upload failed with status ${http_code}" >&2
|
||||||
|
cat asset.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
xdebug-mcp
|
||||||
|
/build
|
||||||
|
|
||||||
33
Makefile
Normal file
33
Makefile
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
BINARY_NAME := xdebug-mcp
|
||||||
|
BUILD_DIR := build
|
||||||
|
GOCACHE ?= /tmp/$(BINARY_NAME)-gocache
|
||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
|
||||||
|
GOOS ?= $(shell go env GOOS)
|
||||||
|
GOARCH ?= $(shell go env GOARCH)
|
||||||
|
|
||||||
|
ifeq ($(GOOS),windows)
|
||||||
|
EXT := .exe
|
||||||
|
else
|
||||||
|
EXT :=
|
||||||
|
endif
|
||||||
|
|
||||||
|
OUTPUT := $(BUILD_DIR)/$(BINARY_NAME)-$(GOOS)-$(GOARCH)$(EXT)
|
||||||
|
|
||||||
|
.PHONY: build test generate generate-check
|
||||||
|
|
||||||
|
build:
|
||||||
|
@mkdir -p $(BUILD_DIR) $(GOCACHE)
|
||||||
|
GOCACHE=$(GOCACHE) GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X main.version=$(VERSION)" -o $(OUTPUT) ./cmd/xdebug-mcp
|
||||||
|
|
||||||
|
test:
|
||||||
|
@mkdir -p $(GOCACHE)
|
||||||
|
GOCACHE=$(GOCACHE) go test ./...
|
||||||
|
|
||||||
|
generate:
|
||||||
|
@mkdir -p $(GOCACHE)
|
||||||
|
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate
|
||||||
|
|
||||||
|
generate-check:
|
||||||
|
@mkdir -p $(GOCACHE)
|
||||||
|
GOCACHE=$(GOCACHE) go run forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework generate --check
|
||||||
17
cmd/xdebug-mcp/main.go
Normal file
17
cmd/xdebug-mcp/main.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
go.mod
9
go.mod
|
|
@ -3,7 +3,8 @@ module forge.lclr.dev/AI/xdebug-mcp
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lclr.dev/AI/mcp-framework v1.10.0
|
forge.lclr.dev/AI/mcp-framework v1.11.0
|
||||||
|
github.com/mark3labs/mcp-go v0.52.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,10 +16,16 @@ require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
|
||||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // 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/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||||
github.com/mtibben/percent v0.2.1 // indirect
|
github.com/mtibben/percent v0.2.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/sys v0.41.0 // indirect
|
||||||
golang.org/x/term v0.40.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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
31
go.sum
31
go.sum
|
|
@ -1,5 +1,5 @@
|
||||||
forge.lclr.dev/AI/mcp-framework v1.10.0 h1:RrTy7K/hSruaVS9Z/oaRpkLs2U5WGs4H3tox7PiErak=
|
forge.lclr.dev/AI/mcp-framework v1.11.0 h1:Yon3n1YghlUn0g2tdkUEoLVlpHUR9ijELdl3d2xiyvM=
|
||||||
forge.lclr.dev/AI/mcp-framework v1.10.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
forge.lclr.dev/AI/mcp-framework v1.11.0/go.mod h1:2xzmFEHGLQzT5PORq35j10pRhsOm0CDwivUZTHvxgh4=
|
||||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
|
||||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
|
||||||
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||||
|
|
@ -11,32 +11,57 @@ github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnG
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
|
||||||
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
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 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
|
||||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
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 h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
|
||||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
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/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
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 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
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 h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
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.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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
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 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 h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
|
||||||
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|
|
||||||
36
internal/app/app.go
Normal file
36
internal/app/app.go
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
75
internal/cache/lru.go
vendored
Normal file
75
internal/cache/lru.go
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
58
internal/cache/lru_test.go
vendored
Normal file
58
internal/cache/lru_test.go
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
87
internal/tools/callees.go
Normal file
87
internal/tools/callees.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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 := req.GetString("file_path", "")
|
||||||
|
if filePath == "" {
|
||||||
|
return mcp.NewToolResultError("file_path is required"), nil
|
||||||
|
}
|
||||||
|
name := req.GetString("function_name", "")
|
||||||
|
if name == "" {
|
||||||
|
return mcp.NewToolResultError("function_name is required"), nil
|
||||||
|
}
|
||||||
|
topN := req.GetInt("top_n", 10)
|
||||||
|
if topN <= 0 {
|
||||||
|
topN = 10
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if len(fns) > 1 {
|
||||||
|
fmt.Fprintf(&sb, "Warning: %d functions match %q — showing all\n\n", len(fns), name)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
105
internal/tools/callers.go
Normal file
105
internal/tools/callers.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"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 := req.GetString("file_path", "")
|
||||||
|
if filePath == "" {
|
||||||
|
return mcp.NewToolResultError("file_path is required"), nil
|
||||||
|
}
|
||||||
|
name := req.GetString("function_name", "")
|
||||||
|
if name == "" {
|
||||||
|
return mcp.NewToolResultError("function_name is required"), nil
|
||||||
|
}
|
||||||
|
topN := req.GetInt("top_n", 10)
|
||||||
|
if topN <= 0 {
|
||||||
|
topN = 10
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if len(fns) > 1 {
|
||||||
|
fmt.Fprintf(&sb, "Warning: %d functions match %q — showing all\n\n", len(fns), name)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
ci, cj := int64(0), int64(0)
|
||||||
|
if len(out[i].Costs) > 0 {
|
||||||
|
ci = out[i].Costs[0]
|
||||||
|
}
|
||||||
|
if len(out[j].Costs) > 0 {
|
||||||
|
cj = out[j].Costs[0]
|
||||||
|
}
|
||||||
|
return ci > cj
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
79
internal/tools/shared.go
Normal file
79
internal/tools/shared.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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, " ")
|
||||||
|
}
|
||||||
100
internal/tools/tools_test.go
Normal file
100
internal/tools/tools_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue