a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
159 lines
5.5 KiB
Go
159 lines
5.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/query"
|
|
)
|
|
|
|
// registerOverlayDiffTool wires the `compare_with_overlay` MCP tool.
|
|
// Called from registerOverlayTools so the tool only exists when
|
|
// overlay support is enabled. Independent of the apply-style overlay
|
|
// tools because compare_with_overlay runs the query TWICE — once
|
|
// against the base graph and once against the calling session's
|
|
// shadow view — and reports the delta. The base-vs-overlay diff is
|
|
// what `non-destructive overlay` actually buys you: side-by-side
|
|
// answers to "what would change if I committed this buffer?" without
|
|
// touching the saved-view graph.
|
|
func (s *Server) registerOverlayDiffTool() {
|
|
s.mcpServer.AddTool(
|
|
mcp.NewTool("compare_with_overlay",
|
|
mcp.WithDescription("Run a graph query against both the base (saved-buffer) graph and the calling session's overlay (editor-buffer) view, then report the delta. The base side answers \"what's true now?\"; the overlay side answers \"what would be true if I committed this buffer?\". Useful for previewing the impact of an unsaved edit on callers, dependents, or call chains. Supported `kind` values: find_usages, get_callers, get_call_chain, get_dependencies, get_dependents."),
|
|
mcp.WithString("kind", mcp.Required(), mcp.Description("Query kind to run. One of: find_usages, get_callers, get_call_chain, get_dependencies, get_dependents.")),
|
|
mcp.WithString("id", mcp.Required(), mcp.Description("Symbol node ID to query (e.g. \"target.go::Target\").")),
|
|
mcp.WithNumber("depth", mcp.Description("Traversal depth for chain / dependency queries (default 2).")),
|
|
mcp.WithNumber("limit", mcp.Description("Maximum number of nodes to return per side (default 50).")),
|
|
),
|
|
s.handleCompareWithOverlay,
|
|
)
|
|
}
|
|
|
|
// handleCompareWithOverlay is the registered handler. It does NOT go
|
|
// through s.addTool (which would wrap with the overlay-injecting
|
|
// middleware) — we build views explicitly here so we can run the
|
|
// query against both base and overlay in a single call.
|
|
func (s *Server) handleCompareWithOverlay(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
kind, err := req.RequireString("kind")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
id, err := req.RequireString("id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if s.overlays == nil {
|
|
return mcp.NewToolResultError("overlay support is not enabled on this server"), nil
|
|
}
|
|
if SessionIDFromContext(ctx) == "" {
|
|
return mcp.NewToolResultError("compare_with_overlay requires an MCP session; connect via the daemon or set Mcp-Session-Id"), nil
|
|
}
|
|
view, viewErr := s.buildOverlayViewForCtx(ctx)
|
|
if viewErr != nil {
|
|
return mcp.NewToolResultError(viewErr.Error()), nil
|
|
}
|
|
if view == nil {
|
|
return mcp.NewToolResultError("session has no overlay attached; push an overlay before calling compare_with_overlay"), nil
|
|
}
|
|
|
|
depth := int(req.GetFloat("depth", 2))
|
|
limit := int(req.GetFloat("limit", 50))
|
|
opts := query.QueryOptions{Depth: depth, Limit: limit, Detail: "brief"}
|
|
|
|
baseEng := s.engine
|
|
overlayEng := s.engine.WithReader(view)
|
|
|
|
baseIDs := runQueryKind(baseEng, kind, id, opts)
|
|
overlayIDs := runQueryKind(overlayEng, kind, id, opts)
|
|
|
|
added, removed, common := diffIDSets(baseIDs, overlayIDs)
|
|
result := map[string]any{
|
|
"kind": kind,
|
|
"id": id,
|
|
"depth": depth,
|
|
"limit": limit,
|
|
"overlay_paths": view.Layer().FilePaths(),
|
|
"base": baseIDs,
|
|
"overlay": overlayIDs,
|
|
"delta": map[string]any{
|
|
"added": added,
|
|
"removed": removed,
|
|
"common": common,
|
|
"summary": map[string]any{
|
|
"added_count": len(added),
|
|
"removed_count": len(removed),
|
|
"common_count": len(common),
|
|
"base_count": len(baseIDs),
|
|
"overlay_count": len(overlayIDs),
|
|
},
|
|
},
|
|
}
|
|
return mcp.NewToolResultText(jsonOK(result)), nil
|
|
}
|
|
|
|
// runQueryKind dispatches a query kind against the supplied engine
|
|
// (base or overlay) and returns the resulting node IDs in stable
|
|
// sorted order. Sorting up front keeps diff output deterministic
|
|
// even when the underlying query returns nodes in shard-walk order.
|
|
func runQueryKind(eng *query.Engine, kind, id string, opts query.QueryOptions) []string {
|
|
if eng == nil {
|
|
return nil
|
|
}
|
|
var sg *query.SubGraph
|
|
switch kind {
|
|
case "find_usages":
|
|
sg = eng.FindUsagesScoped(id, opts)
|
|
case "get_callers":
|
|
sg = eng.GetCallers(id, opts)
|
|
case "get_call_chain":
|
|
sg = eng.GetCallChain(id, opts)
|
|
case "get_dependencies":
|
|
sg = eng.GetDependencies(id, opts)
|
|
case "get_dependents":
|
|
sg = eng.GetDependents(id, opts)
|
|
default:
|
|
return nil
|
|
}
|
|
if sg == nil {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(sg.Nodes))
|
|
for _, n := range sg.Nodes {
|
|
if n == nil || n.ID == id {
|
|
continue // exclude the seed itself
|
|
}
|
|
ids = append(ids, n.ID)
|
|
}
|
|
sort.Strings(ids)
|
|
return ids
|
|
}
|
|
|
|
// diffIDSets computes (added, removed, common) between two sorted ID
|
|
// slices. `added` = ids present in overlay but not base. `removed` =
|
|
// ids present in base but not overlay. `common` = ids in both.
|
|
func diffIDSets(base, overlay []string) (added, removed, common []string) {
|
|
in := make(map[string]int, len(base)+len(overlay))
|
|
for _, id := range base {
|
|
in[id] |= 1
|
|
}
|
|
for _, id := range overlay {
|
|
in[id] |= 2
|
|
}
|
|
for id, mask := range in {
|
|
switch mask {
|
|
case 1:
|
|
removed = append(removed, id)
|
|
case 2:
|
|
added = append(added, id)
|
|
case 3:
|
|
common = append(common, id)
|
|
}
|
|
}
|
|
sort.Strings(added)
|
|
sort.Strings(removed)
|
|
sort.Strings(common)
|
|
return added, removed, common
|
|
}
|