a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
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
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
232 lines
9.6 KiB
Go
232 lines
9.6 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
mcpserver "github.com/mark3labs/mcp-go/server"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
)
|
|
|
|
// SetOverlayManager wires the editor-overlay manager into the MCP
|
|
// server. After this call:
|
|
//
|
|
// - Every `tools/call` whose session has overlay buffers attached is
|
|
// wrapped with a per-request middleware that constructs a shadow-
|
|
// graph view (`*graph.OverlaidView`) layering the parsed overlay
|
|
// on top of the immutable base graph. The view is attached to the
|
|
// request context; tool handlers read it via `s.readerFor(ctx)`
|
|
// instead of touching `s.graph` directly. The base graph is never
|
|
// mutated, so concurrent sessions — overlay-active or not — see
|
|
// their own consistent view and the file watcher never races on
|
|
// overlay state.
|
|
//
|
|
// - The overlay management MCP tools (`overlay_register`,
|
|
// `overlay_push`, `overlay_list`, `overlay_delete`, `overlay_drop`)
|
|
// become live so MCP-native editor extensions can manage overlays
|
|
// without reaching for the parallel `/v1/overlay/*` HTTP surface.
|
|
//
|
|
// Passing nil leaves the server in pre-overlay behaviour (reads always
|
|
// come from the base graph; overlay tools are not registered). Calling
|
|
// twice re-registers the overlay tools idempotently.
|
|
func (s *Server) SetOverlayManager(mgr *daemon.OverlayManager) {
|
|
s.overlays = mgr
|
|
if mgr == nil {
|
|
return
|
|
}
|
|
s.registerOverlayToolsOnce.Do(func() {
|
|
s.registerOverlayTools()
|
|
})
|
|
}
|
|
|
|
// OverlayManager returns the wired editor-overlay manager, or nil
|
|
// when overlay support is disabled for this server instance.
|
|
func (s *Server) OverlayManager() *daemon.OverlayManager { return s.overlays }
|
|
|
|
// wrapToolHandler returns a tool handler decorated with the
|
|
// overlay-view middleware. Tool registration helpers (`s.addTool`)
|
|
// route every handler through this so the daemon-dispatched path
|
|
// (HandleMessage) and the HTTP `CallToolStrict` path get identical
|
|
// shadow-graph semantics — the latter bypasses mcp-go's hook surface,
|
|
// so handler-level wrapping is the only place that covers both
|
|
// transports.
|
|
//
|
|
// The middleware is non-mutating: it parses the calling session's
|
|
// overlay buffers once per request (cached by (sessID, contentHash) in
|
|
// s.overlayLayerCache) and attaches the resulting view to ctx via
|
|
// WithOverlayView. Tool handlers obtain the active reader via
|
|
// s.readerFor(ctx), which returns the view when present and the base
|
|
// graph otherwise. Concurrent sessions are isolated by construction
|
|
// because no shared state is touched.
|
|
//
|
|
// When the calling session has no overlay or no overlay manager is
|
|
// wired, this is a transparent pass-through (one map lookup, zero
|
|
// parsing) — non-overlay traffic pays no cost.
|
|
func (s *Server) wrapToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
|
|
// Prompt-injection screening sits closest to the handler so it
|
|
// sees the real arguments and the real result (see sanitize.go).
|
|
h = s.sanitizeToolHandler(h)
|
|
return func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) {
|
|
// Last-resort panic firewall around EVERY tool handler. A Go
|
|
// panic in any handler (e.g. when the store surfaces a fatal
|
|
// engine error) would otherwise unwind past the mcp-go server
|
|
// loop and crash the whole daemon — dropping every session's
|
|
// MCP transport, not just the offending call. Convert it to a
|
|
// structured tool error so the panicking tool fails in
|
|
// isolation and the daemon survives. This supersedes the
|
|
// per-handler recover that get_file_summary carried; every
|
|
// tool now gets the same protection.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
if s.logger != nil {
|
|
s.logger.Error("tool handler panic recovered",
|
|
zap.String("tool", req.Params.Name),
|
|
zap.Any("panic", r),
|
|
zap.Stack("stack"))
|
|
}
|
|
res = mcp.NewToolResultError(fmt.Sprintf("tool %q internal error: %v", req.Params.Name, r))
|
|
retErr = nil
|
|
}
|
|
}()
|
|
// Tolerate hallucinated / mistyped parameter names before the
|
|
// handler reads arguments (e.g. "symbol" accepted as "id").
|
|
s.reconcileToolParams(&req)
|
|
// Enforce the session's runtime mode / workflow phase — a hard
|
|
// gate even if the client never re-read tools/list.
|
|
if blocked := s.checkToolGate(ctx, req.Params.Name); blocked != nil {
|
|
return blocked, nil
|
|
}
|
|
// Opt-in zero-config: background-index an untracked cwd on the first
|
|
// tool call (GORTEX_AUTOINDEX=1). Cheap getenv + sync.Once on the
|
|
// request path; all real work runs on a background goroutine.
|
|
s.maybeAutoIndexCWD()
|
|
view, err := s.buildOverlayViewForCtx(ctx)
|
|
if err != nil {
|
|
// Drift surfaces as a structured tool error result so the
|
|
// client knows to re-read and resubmit. Return (result,
|
|
// nil) so the JSON-RPC framing carries the message rather
|
|
// than a transport error.
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if view != nil {
|
|
ctx = WithOverlayView(ctx, view)
|
|
}
|
|
// Warmup fast path: when the daemon is still warming up and
|
|
// this is a graph-querying tool, the handler still runs (so
|
|
// the caller gets a best-effort partial answer from the part
|
|
// of the graph indexed so far) and the result is decorated
|
|
// with a structured `warming` block — flag + real progress
|
|
// percentage + phase + message. Graph-independent tools are
|
|
// untouched; a ready daemon is a transparent pass-through.
|
|
// See warmup_fastpath.go.
|
|
env, warming := s.checkWarmupFastPath(req.Params.Name)
|
|
// Retrieval query logging: time the call and install a
|
|
// result-count holder so handlers can report an exact count
|
|
// (the logger falls back to parsing the response otherwise).
|
|
logQuery := s.queryLog.shouldLog(req.Params.Name)
|
|
var qStart time.Time
|
|
if logQuery {
|
|
ctx, _ = withResultCount(ctx)
|
|
qStart = time.Now()
|
|
}
|
|
res, hErr := h(ctx, req)
|
|
// Opt-in usage telemetry: count this tool invocation by name only —
|
|
// never arguments or results. nil-safe, consent-gated, and fail-silent,
|
|
// so a disabled or absent recorder adds nothing to the dispatch path.
|
|
s.recorder.Record("mcp_tool_call", req.Params.Name)
|
|
if logQuery {
|
|
s.queryLog.record(s, ctx, req, res, hErr, qStart)
|
|
}
|
|
if warming && hErr == nil {
|
|
res = decorateResultWithWarming(res, env)
|
|
}
|
|
// Inline freshness: when a file-reading tool returns content for a
|
|
// file that has changed on disk since it was indexed, attach a
|
|
// small `freshness` block so the agent knows the graph view may lag
|
|
// the working tree. Omitted (zero cost) for the common fresh case.
|
|
if hErr == nil {
|
|
if rider := s.freshnessRiderFor(req.Params.Name, req); rider != nil {
|
|
res = decorateResultWithFreshness(res, rider)
|
|
} else if isFreshnessListTool(req.Params.Name) {
|
|
// List tools get a per-file sweep: any hit whose file drifted
|
|
// or vanished on disk is flagged with per-repo provenance.
|
|
res = s.decorateListResultWithFreshness(res)
|
|
}
|
|
}
|
|
// Capture large successful responses into the session ring so
|
|
// the post-filter tools can re-cut them without re-querying.
|
|
if hErr == nil {
|
|
s.captureResponse(ctx, req.Params.Name, res)
|
|
}
|
|
// One-shot momentum note: after many read calls in one session,
|
|
// remind the agent that what it already holds is citeable
|
|
// (momentum.go). No-op for non-read tools and error results.
|
|
if hErr == nil {
|
|
res = s.maybeAttachMomentumNote(ctx, req.Params.Name, res)
|
|
}
|
|
return res, hErr
|
|
}
|
|
}
|
|
|
|
// errBaseSHADrift is the structured drift error returned by the
|
|
// disk-write edit tools (edit_file / edit_symbol / write_file) when
|
|
// the caller-supplied base_sha does not match the current on-disk
|
|
// blob SHA. The message mirrors daemon.ErrOverlayDrift so callers
|
|
// can pattern-match on a single substring across overlay-push and
|
|
// plain-write paths: "re-read and resubmit".
|
|
const errBaseSHADrift = "base_sha mismatch — re-read and resubmit"
|
|
|
|
// gitBlobSHA computes the git blob SHA-1 of the given content. The
|
|
// hash matches `git ls-files -s` / `git hash-object` output (i.e.
|
|
// sha1 of "blob <len>\0<content>"), so editors can pass the SHA they
|
|
// already have without any client-side reformatting. The returned
|
|
// string is lowercase hex. This is the canonical drift-anchor helper
|
|
// shared by overlay_push and the disk-write edit tools.
|
|
func gitBlobSHA(data []byte) string {
|
|
h := sha1.New()
|
|
// hash.Hash.Write never errors; fmt.Fprintf returns (n, err)
|
|
// because it's the io.Writer interface, but the underlying
|
|
// hash.Hash's Write contract forbids non-nil errors. Discard
|
|
// both to keep the linter happy without inventing fake error
|
|
// handling.
|
|
fmt.Fprintf(h, "blob %d\x00", len(data))
|
|
_, _ = h.Write(data)
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// normalizeExpectedSHA lowercases and trims a caller-supplied
|
|
// base_sha so comparisons are case- and whitespace-insensitive.
|
|
func normalizeExpectedSHA(s string) string {
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
}
|
|
|
|
// overlaySHAMatches re-computes the git blob SHA of an on-disk file
|
|
// and compares it to the SHA the editor recorded at didOpen time.
|
|
// Returns false on any read error: the safer default is "drift" —
|
|
// the client re-reads and resubmits.
|
|
func overlaySHAMatches(absPath, expected string) bool {
|
|
expected = normalizeExpectedSHA(expected)
|
|
if expected == "" {
|
|
return true
|
|
}
|
|
data, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return gitBlobSHA(data) == expected
|
|
}
|
|
|
|
// _ keeps sync.Mutex referenced by the package even after future
|
|
// refactors strip a field — the import lints flagged a phantom
|
|
// dependency in the prior iteration; harmless guard.
|
|
var _ sync.Mutex
|