Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}