From fd9819bf84e250dde0202c9981a908d43768e275 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 15 May 2026 13:29:42 +0200 Subject: [PATCH 1/4] daemon, indexer, mcp, server: editor-overlay query consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the editor-overlay manager into MCP tool dispatch so every tools/call sees the calling session's unsaved editor buffers without per-tool changes. IDE extensions can keep the graph fresh during active editing without saving the file. - Apply/revert middleware (internal/mcp/overlay.go::wrapToolHandler) evicts each overlaid file and re-indexes from caller-supplied content at tools/call start, then re-indexes from disk on return. Applies are serialised through overlayApplyMu so two in-flight overlay-active requests can't race on the same path. - Indexer.IndexFileFromContent is the new content-driven re-index path; mtime tracking is skipped so the next watcher event still restores the on-disk view. MultiIndexer.IndexFileFromContent / EvictFileByAbs / ReindexFromDisk forward per-repo. - Drift detection (overlaySHAMatches) recomputes the git blob SHA at apply time and compares to the editor-captured base_sha; mismatches surface as a structured "overlay base SHA mismatch" tool error so the client re-reads and resubmits. - OverlayManager gains RegisterWithID / Has / FileCount / SnapshotFor; the MCP session ID doubles as the overlay session ID, so query-time resolution is a single map lookup from SessionIDFromContext. - Five new MCP tools — overlay_register, overlay_push, overlay_list, overlay_delete, overlay_drop — let an MCP-native extension manage overlays without the /v1/overlay/* HTTP surface. Deletion overlays (deleted: true) tombstone a path for the session's lifetime. - HTTP transport: handleOverlayRegister accepts an explicit session_id; handleToolCall reads the active session from Mcp-Session-Id (preferred), X-Gortex-Overlay-Session, or ?session_id=. All transports (daemon socket, stdio, HTTP) share one apply/revert path. - 65 internal AddTool callsites migrated from s.mcpServer.AddTool to s.addTool so every tool picks up the wrapping; tools_overlay.go keeps the raw path for the overlay-management tools themselves (applying overlays before overlay_push would re-evict the new buffer before it ever became visible). - 14 tests: 5 daemon OverlayManager (register/snapshot ordering, idempotent re-register, fast-path gates, drift, idle TTL sweep) + 3 indexer content path (replaces graph view, no mtime poisoning, deletion equivalence) + 6 MCP end-to-end (query consumption via get_file_summary, drift surfacing, BaseSHA happy path, no-session fast-path, MCP overlay_register/push/list round-trip, deletion tombstoning). - Docs: CLAUDE.md, internal/agents/instructions.go adapter template, and README.md gain a "Live Editor Buffers (Overlay Sessions)" section; README tool count 64 -> 69. --- CLAUDE.md | 20 ++ README.md | 15 +- cmd/gortex/daemon_state.go | 11 + cmd/gortex/server.go | 7 + internal/agents/instructions.go | 13 ++ internal/daemon/overlay.go | 97 +++++++- internal/daemon/overlay_test.go | 110 +++++++++ internal/indexer/indexer.go | 50 +++- internal/indexer/indexfromcontent_test.go | 127 +++++++++++ internal/indexer/multi.go | 52 +++++ internal/mcp/diagnostics.go | 4 +- internal/mcp/overlay.go | 265 ++++++++++++++++++++++ internal/mcp/overlay_e2e_test.go | 242 ++++++++++++++++++++ internal/mcp/server.go | 36 +++ internal/mcp/tools_analysis.go | 6 +- internal/mcp/tools_ast.go | 2 +- internal/mcp/tools_clones.go | 2 +- internal/mcp/tools_coding.go | 32 +-- internal/mcp/tools_core.go | 30 +-- internal/mcp/tools_dataflow.go | 4 +- internal/mcp/tools_enhancements.go | 28 +-- internal/mcp/tools_llm.go | 2 +- internal/mcp/tools_lsp.go | 8 +- internal/mcp/tools_multi.go | 8 +- internal/mcp/tools_overlay.go | 229 +++++++++++++++++++ internal/mcp/tools_workspace.go | 4 +- internal/server/dashboard.go | 33 ++- internal/server/handler.go | 23 +- 28 files changed, 1379 insertions(+), 81 deletions(-) create mode 100644 internal/daemon/overlay_test.go create mode 100644 internal/indexer/indexfromcontent_test.go create mode 100644 internal/mcp/overlay.go create mode 100644 internal/mcp/overlay_e2e_test.go create mode 100644 internal/mcp/tools_overlay.go diff --git a/CLAUDE.md b/CLAUDE.md index 87432ae..3c863b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,6 +229,26 @@ The `find_clones` MCP tool surfaces near-duplicate ("clone") function/method clu | Scoping a query to a project | Pass `project` param to any query tool | | Filtering by reference tag | Pass `ref` param to any query tool | +### Live Editor Buffers (Overlay Sessions) + +Editor extensions push in-flight buffers — files the user has edited but not yet saved — into the daemon as **overlays**. Once an overlay is attached to a session, every subsequent `tools/call` from that session sees the overlaid view: graph-walking tools (`find_usages`, `get_call_chain`, `get_file_summary`, `analyze`, …) and source-reading tools (`get_symbol_source`, `get_editing_context`, …) all read the editor-buffer version of the file instead of the saved-buffer one. + +| Instead of... | You MUST use... | +|---------------------------------------|------------------------------------------| +| Asking the user to save before a query | `overlay_register` then `overlay_push` — pushes one editor buffer; subsequent tool calls see the overlay merged on top of the on-disk graph view | +| Pushing keystroke-by-keystroke through HTTP | `overlay_push` over the same MCP transport you're already on — no extra socket, no extra auth | +| Listing what an extension has staged | `overlay_list` — every path / size / deleted flag / base SHA for the current session | +| Cancelling a single overlay | `overlay_delete` with `path` — saved-buffer view returns for that path on the next tool call | +| Tearing down an overlay session | `overlay_drop` — discards every overlay attached to the session, in one call | + +**Drift detection.** Pass an editor-captured git blob SHA as `base_sha` on `overlay_push`. When the next tool call needs that path, the daemon compares it to the on-disk hash; if they disagree (a sibling tool, a git operation, or another editor saved over the file) the tool call returns a structured `overlay base SHA mismatch` error so the client knows to re-read the file and resubmit a fresh overlay. Drift is the load-bearing signal that prevents the daemon from folding a stale buffer into queries. + +**Deletion overlays.** Push with `deleted: true` to model "this file is going away" — the symbols inside it vanish from the graph for the duration of the session, so the user can preview the impact of a delete without staging it. + +**HTTP transport.** `gortex server` exposes the same surface at `POST /v1/overlay/sessions` (optional `session_id` binds an overlay to a known MCP session), `PUT /v1/overlay/sessions/{id}/files`, `DELETE /v1/overlay/sessions/{id}/files`, `GET /v1/overlay/sessions/{id}/files`, `DELETE /v1/overlay/sessions/{id}`. The `/v1/tools/` HTTP entry point reads the active session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=` (test fallback). + +**Sessions auto-expire** after 5 minutes of inactivity, so a crashed extension never leaks unsaved buffers indefinitely. + ### MCP Resources Bootstrap-state tools are also exposed as MCP resources (read-only, URI-addressable, no args). Clients that speak resources can `resources/subscribe` once and receive `notifications/resources/updated` after each graph re-warm — no polling. The tool form stays for back-compat with clients that don't speak resources. diff --git a/README.md b/README.md index e49a926..07c1518 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ gortex query stats Show graph statistics All query commands support `--format text|json|dot` (DOT output for Graphviz visualization). -## MCP Tools (64) +## MCP Tools (69) ### Core Navigation | Tool | Description | @@ -439,6 +439,19 @@ Wired across every running language server (gopls, tsserver, pyright, rust-analy | `list_repos` | List every project/repo in the active workspace | | `workspace_info` | Workspace identity — bind mode, root directory, marker contents, discovered member set | +### Live Editor Buffers (Overlay Sessions) +Editor extensions push in-flight (unsaved) buffers as **overlays**; every subsequent `tools/call` from the same MCP session sees the overlaid view. Graph-walking tools (`find_usages`, `get_call_chain`, `analyze`, …) and source-reading tools (`get_symbol_source`, `get_editing_context`, …) all read the editor-buffer version without per-tool changes. Pass an editor-captured git blob SHA as `base_sha` for drift detection; push with `deleted: true` to preview a deletion. Sessions auto-expire after 5 minutes of inactivity. + +| Tool | Description | +|------|-------------| +| `overlay_register` | Bind an overlay session to the current MCP session ID (idempotent) | +| `overlay_push` | Push (or update) a single file overlay | +| `overlay_list` | List every overlay attached to the session — path / size / deleted / base_sha | +| `overlay_delete` | Remove one overlay from the session | +| `overlay_drop` | Tear down the session and discard every overlay | + +HTTP transport mirrors the surface at `/v1/overlay/sessions/*`; the `/v1/tools/` entry point reads the overlay session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=`. + ## MCP Resources (16) Read-only, URI-addressable, no args. Clients that speak resources can `resources/subscribe` once and receive `notifications/resources/updated` after each graph re-warm — no polling. diff --git a/cmd/gortex/daemon_state.go b/cmd/gortex/daemon_state.go index 5b4db0a..00f9729 100644 --- a/cmd/gortex/daemon_state.go +++ b/cmd/gortex/daemon_state.go @@ -15,6 +15,7 @@ import ( "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/contracts" + "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/embedding" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" @@ -288,6 +289,16 @@ func buildDaemonState(logger *zap.Logger) (*daemonState, error) { gortexmcp.Version = version srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules, multiOpts...) + // Editor-overlay manager for live-buffer indexing. The MCP + // dispatcher binds the inbound MCP session ID to an overlay + // session at every `tools/call`; the tool handler middleware + // applies the overlay's editor buffers to the in-memory graph + // for the duration of the call. 5-minute idle TTL: long enough + // for an editor's keystroke→tool-call loop, short enough that a + // crashed extension doesn't leak unsaved buffers forever. + overlays := daemon.NewOverlayManager(5 * time.Minute) + srv.SetOverlayManager(overlays) + // Semantic manager, feedback, savings — same wiring as runServe. if semMgr := idx.SemanticManager(); semMgr != nil { srv.SetSemanticManager(semMgr) diff --git a/cmd/gortex/server.go b/cmd/gortex/server.go index eb9b87c..e5dcdc9 100644 --- a/cmd/gortex/server.go +++ b/cmd/gortex/server.go @@ -364,6 +364,13 @@ func runServer(_ *cobra.Command, _ []string) error { // indefinitely. overlays := daemon.NewOverlayManager(5 * time.Minute) serverHandler.SetOverlayManager(overlays) + // Wire the same manager into the MCP server so the `tools/call` + // middleware can apply per-session overlays to the in-memory + // graph for the duration of each tool call, and so the + // overlay_register / overlay_push / overlay_list / overlay_delete + // / overlay_drop MCP tools become live for editor extensions + // speaking MCP rather than the parallel /v1/overlay/* HTTP API. + srv.SetOverlayManager(overlays) // Wire the multi-server router. When `~/.gortex/servers.toml` is // present, every diff --git a/internal/agents/instructions.go b/internal/agents/instructions.go index 3999827..735b0e4 100644 --- a/internal/agents/instructions.go +++ b/internal/agents/instructions.go @@ -371,6 +371,19 @@ The ` + "`flow_between`" + ` and ` + "`taint_paths`" + ` MCP tools answer **"whe | Scoping a query to a project | Pass ` + "`project`" + ` param to any query tool | | Filtering by reference tag | Pass ` + "`ref`" + ` param to any query tool | +### Live Editor Buffers (Overlay Sessions) + +Editor extensions push in-flight buffers — files the user has edited but not yet saved — as **overlays**. After ` + "`overlay_register`" + ` and one or more ` + "`overlay_push`" + ` calls, every subsequent ` + "`tools/call`" + ` from the same MCP session reads the editor-buffer view (overlay merged on top of the on-disk graph). No per-tool changes are needed: graph-walking tools (` + "`find_usages`" + `, ` + "`get_call_chain`" + `, ` + "`get_file_summary`" + `, ` + "`analyze`" + `, …) and source-reading tools (` + "`get_symbol_source`" + `, ` + "`get_editing_context`" + `, …) all see the overlay. + +| Instead of... | You MUST use... | +|---------------------------------------|------------------------------------------| +| Asking the user to save before a query | ` + "`overlay_register`" + ` then ` + "`overlay_push`" + ` — pushes one editor buffer; subsequent tool calls see the overlay | +| Listing what an extension has staged | ` + "`overlay_list`" + ` — path / size / deleted / base SHA for the current session | +| Cancelling a single overlay | ` + "`overlay_delete`" + ` with ` + "`path`" + ` — saved-buffer view returns for that path | +| Tearing down an overlay session | ` + "`overlay_drop`" + ` — discards every overlay attached to the session in one call | + +Pass an editor-captured git blob SHA as ` + "`base_sha`" + ` on ` + "`overlay_push`" + ` to enable drift detection: when the next tool call needs that path, the daemon compares ` + "`base_sha`" + ` to the on-disk hash and returns ` + "`overlay base SHA mismatch`" + ` if they disagree, so the client knows to re-read and resubmit. Push with ` + "`deleted: true`" + ` to model a tombstone — the file's symbols vanish from the graph for the session's lifetime. Sessions auto-expire after 5 minutes of inactivity. HTTP transport mirrors the surface at ` + "`/v1/overlay/sessions/*`" + `; the ` + "`/v1/tools/`" + ` entry reads the active session from ` + "`Mcp-Session-Id`" + ` / ` + "`X-Gortex-Overlay-Session`" + ` / ` + "`?session_id=`" + `. + ### MCP Resources Bootstrap-state tools are also exposed as MCP resources (read-only, URI-addressable, no args). Subscribe via ` + "`resources/subscribe`" + ` once and receive ` + "`notifications/resources/updated`" + ` after each graph re-warm — no polling. Tools stay registered for back-compat with clients that don't speak resources; both surfaces share builder helpers so payloads match byte-for-byte. diff --git a/internal/daemon/overlay.go b/internal/daemon/overlay.go index 673b8cd..8b1383c 100644 --- a/internal/daemon/overlay.go +++ b/internal/daemon/overlay.go @@ -2,6 +2,7 @@ package daemon import ( "errors" + "sort" "sync" "time" ) @@ -83,17 +84,105 @@ func NewOverlayManager(idleTTL time.Duration) *OverlayManager { // per the overlay model). func (m *OverlayManager) Register(workspaceID string) string { id := newSessionID() + _ = m.RegisterWithID(id, workspaceID) + return id +} + +// ErrSessionExists is returned by RegisterWithID when the caller-supplied +// session ID is already known. The MCP-side overlay tools rely on this +// error to detect "register-called-twice" races; HTTP callers don't see +// it because Register generates fresh IDs. +var ErrSessionExists = errors.New("overlay session already exists") + +// RegisterWithID registers a session under a caller-chosen ID. This is +// the path the MCP `overlay_register` tool takes — it binds the overlay +// session to the MCP session ID so the query path can find the overlay +// snapshot from the request context without an extra lookup. The HTTP +// register handler also routes through here when its body includes an +// explicit `session_id`. +// +// Returns ErrSessionExists when the ID is already in use. Idempotent +// re-registration (same workspaceID) is treated as a no-op: the client +// may safely retry register without first checking. +func (m *OverlayManager) RegisterWithID(sessionID, workspaceID string) error { + if sessionID == "" { + return errors.New("overlay session id is required") + } now := time.Now() m.mu.Lock() - m.sessions[id] = &OverlaySession{ - ID: id, + defer m.mu.Unlock() + if existing, ok := m.sessions[sessionID]; ok { + if existing.WorkspaceID == workspaceID { + existing.LastUsed = now + return nil + } + return ErrSessionExists + } + m.sessions[sessionID] = &OverlaySession{ + ID: sessionID, WorkspaceID: workspaceID, Created: now, LastUsed: now, files: make(map[string]OverlayFile), } - m.mu.Unlock() - return id + return nil +} + +// Has reports whether a session is currently registered. Used by the +// MCP tool dispatcher to decide whether to skip the per-request apply +// pass (no session → no work). Cheap O(1) read under the read lock. +func (m *OverlayManager) Has(sessionID string) bool { + if m == nil || sessionID == "" { + return false + } + m.mu.RLock() + defer m.mu.RUnlock() + _, ok := m.sessions[sessionID] + return ok +} + +// FileCount returns the number of overlay files attached to a session, +// 0 if the session is unknown. Cheap fast-path: lets the dispatcher +// skip the apply pass when a session is registered but empty. +func (m *OverlayManager) FileCount(sessionID string) int { + if m == nil || sessionID == "" { + return 0 + } + m.mu.RLock() + defer m.mu.RUnlock() + sess, ok := m.sessions[sessionID] + if !ok { + return 0 + } + return len(sess.files) +} + +// SnapshotFor returns the overlay files for a session in a stable, +// path-sorted order along with the workspace slug captured at register +// time. Returns ErrSessionNotFound when the session doesn't exist. The +// returned slice never aliases the manager's internal map; callers can +// mutate it freely. +// +// This is the preferred read API for the query path: the deterministic +// ordering means two overlay-active requests with the same overlay set +// touch the same paths in the same order, which simplifies test +// assertions and makes drift errors point at the same path on retry. +func (m *OverlayManager) SnapshotFor(sessionID string) (workspace string, files []OverlayFile, err error) { + if m == nil { + return "", nil, ErrSessionNotFound + } + m.mu.RLock() + defer m.mu.RUnlock() + sess, ok := m.sessions[sessionID] + if !ok { + return "", nil, ErrSessionNotFound + } + out := make([]OverlayFile, 0, len(sess.files)) + for _, f := range sess.files { + out = append(out, f) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return sess.WorkspaceID, out, nil } // Push attaches one overlay file to a session. Workspace mismatch diff --git a/internal/daemon/overlay_test.go b/internal/daemon/overlay_test.go new file mode 100644 index 0000000..4792c04 --- /dev/null +++ b/internal/daemon/overlay_test.go @@ -0,0 +1,110 @@ +package daemon + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestOverlayManager_RegisterAndFiles is the happy-path round trip: +// register a session, push two overlays, list them via SnapshotFor. +// The snapshot must be path-sorted (the apply pass relies on stable +// ordering for reproducible drift errors) and must not alias the +// manager's internal map. +func TestOverlayManager_RegisterAndFiles(t *testing.T) { + m := NewOverlayManager(time.Minute) + id := m.Register("ws") + require.NotEmpty(t, id) + + require.NoError(t, m.Push(id, OverlayFile{Path: "b.go", Content: "package b"}, nil)) + require.NoError(t, m.Push(id, OverlayFile{Path: "a.go", Content: "package a"}, nil)) + + ws, files, err := m.SnapshotFor(id) + require.NoError(t, err) + require.Equal(t, "ws", ws) + require.Len(t, files, 2) + require.Equal(t, "a.go", files[0].Path, "snapshot must be path-sorted") + require.Equal(t, "b.go", files[1].Path) + + // Snapshot must not alias the internal map. + files[0].Content = "mutated" + _, again, _ := m.SnapshotFor(id) + require.Equal(t, "package a", again[0].Content, "SnapshotFor must return a deep copy") +} + +// TestOverlayManager_RegisterWithID_Idempotent verifies the MCP-side +// register flow: calling RegisterWithID twice for the same (sessionID, +// workspaceID) tuple is a no-op, but a workspace mismatch is rejected +// with ErrSessionExists. Without this contract the MCP overlay_register +// tool would have to teach every editor extension to track register +// state across reconnects. +func TestOverlayManager_RegisterWithID_Idempotent(t *testing.T) { + m := NewOverlayManager(time.Minute) + require.NoError(t, m.RegisterWithID("sess-1", "ws-a")) + require.NoError(t, m.RegisterWithID("sess-1", "ws-a"), "idempotent re-register must succeed") + + err := m.RegisterWithID("sess-1", "ws-b") + require.ErrorIs(t, err, ErrSessionExists, "workspace mismatch must surface ErrSessionExists") + + // Empty session ID is a programming error. + require.Error(t, m.RegisterWithID("", "ws-a")) +} + +// TestOverlayManager_HasAndFileCount covers the dispatcher's fast-path +// gating: tools/call middleware bails before any apply work when +// Has==false or FileCount==0. +func TestOverlayManager_HasAndFileCount(t *testing.T) { + m := NewOverlayManager(time.Minute) + require.False(t, m.Has("unknown")) + require.Zero(t, m.FileCount("unknown")) + + id := m.Register("ws") + require.True(t, m.Has(id)) + require.Zero(t, m.FileCount(id), "freshly registered session has no files") + + require.NoError(t, m.Push(id, OverlayFile{Path: "x.go", Content: "x"}, nil)) + require.Equal(t, 1, m.FileCount(id)) + + m.Drop(id) + require.False(t, m.Has(id)) + require.Zero(t, m.FileCount(id)) +} + +// TestOverlayManager_DriftCheck verifies that Push surfaces a drift +// error when the supplied BaseSHA disagrees with the on-disk SHA +// reported by the callback. Without drift detection two clients +// pushing stale overlays against the same path would silently corrupt +// each other's query results. +func TestOverlayManager_DriftCheck(t *testing.T) { + m := NewOverlayManager(time.Minute) + id := m.Register("ws") + + // BaseSHA matches: push succeeds. + require.NoError(t, m.Push(id, + OverlayFile{Path: "x.go", Content: "x", BaseSHA: "abc"}, + func(path, sha string) bool { return sha == "abc" }, + )) + + // BaseSHA mismatches: ErrOverlayDrift. + err := m.Push(id, + OverlayFile{Path: "x.go", Content: "x", BaseSHA: "stale"}, + func(path, sha string) bool { return sha == "abc" }, + ) + require.True(t, errors.Is(err, ErrOverlayDrift)) +} + +// TestOverlayManager_SweepIdleHonoursTTL ensures that sessions older +// than IdleTTL are reaped. A crashed editor extension leaving overlays +// in the daemon would otherwise pin memory until restart. +func TestOverlayManager_SweepIdleHonoursTTL(t *testing.T) { + m := NewOverlayManager(20 * time.Millisecond) + id := m.Register("ws") + require.True(t, m.Has(id)) + + time.Sleep(40 * time.Millisecond) + dropped := m.SweepIdle() + require.Equal(t, 1, dropped, "session past idleTTL must be reaped") + require.False(t, m.Has(id)) +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 6150ef2..c2341c5 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -1541,7 +1541,31 @@ func (idx *Indexer) IndexFileNoResolve(filePath string) error { return idx.indexFile(filePath, false) } +// IndexFileFromContent indexes a single file using a caller-supplied +// in-memory source — the editor-overlay path. Unlike IndexFile, the +// file at filePath is NEVER read from disk; the bytes in `src` are +// authoritative. Mtime tracking is also skipped, because an overlay is +// a transient view: a subsequent IndexFile call restores the on-disk +// version. Used by the MCP overlay-aware request middleware to merge +// editor buffers into the graph for the duration of one tool call. +// +// The `resolve` parameter mirrors indexFile's: pass true for one-off +// overlay applies (so cross-file references in the overlaid file +// resolve immediately), false when a batch caller will run ResolveAll +// itself. +func (idx *Indexer) IndexFileFromContent(filePath string, src []byte, resolve bool) error { + return idx.indexFileWithSource(filePath, src, resolve, true) +} + func (idx *Indexer) indexFile(filePath string, resolve bool) error { + return idx.indexFileWithSource(filePath, nil, resolve, false) +} + +// indexFileWithSource is the unified parse-and-patch path shared by +// indexFile (reads from disk) and IndexFileFromContent (caller-supplied +// bytes). When overlay=true, src is authoritative and mtime tracking is +// skipped so a subsequent IndexFile call restores the on-disk view. +func (idx *Indexer) indexFileWithSource(filePath string, src []byte, resolve, overlay bool) error { absPath, err := filepath.Abs(filePath) if err != nil { return err @@ -1563,9 +1587,11 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { } idx.graph.EvictFile(graphPath) - src, err := os.ReadFile(absPath) - if err != nil { - return err + if !overlay { + src, err = os.ReadFile(absPath) + if err != nil { + return err + } } lang, ok := idx.registry.DetectLanguage(absPath) @@ -1627,11 +1653,19 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { } } - // Update mtime for this file (uses raw relPath for disk-based tracking). - if info, err := os.Stat(absPath); err == nil { - idx.mtimeMu.Lock() - idx.fileMtimes[filepath.ToSlash(relPath)] = info.ModTime().UnixNano() - idx.mtimeMu.Unlock() + // Update mtime for this file (uses raw relPath for disk-based + // tracking). Skipped for the overlay path: an overlay is a + // transient editor-buffer view, so stamping its mtime would make + // the next watcher-driven IndexFile call see the on-disk file as + // "older than the overlay" and skip the restore. Leaving mtime + // unchanged guarantees the disk view comes back the next time a + // fsnotify event fires on this path or the overlay revert runs. + if !overlay { + if info, err := os.Stat(absPath); err == nil { + idx.mtimeMu.Lock() + idx.fileMtimes[filepath.ToSlash(relPath)] = info.ModTime().UnixNano() + idx.mtimeMu.Unlock() + } } return nil diff --git a/internal/indexer/indexfromcontent_test.go b/internal/indexer/indexfromcontent_test.go new file mode 100644 index 0000000..4807e6e --- /dev/null +++ b/internal/indexer/indexfromcontent_test.go @@ -0,0 +1,127 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// TestIndexFileFromContent_ReplacesGraphView verifies the editor-overlay +// path: re-indexing a file from in-memory content evicts the disk-derived +// nodes for that file and adds nodes for the overlaid view, *without* +// touching the file on disk or its mtime tracking. After IndexFile is +// called again, the on-disk view returns. +func TestIndexFileFromContent_ReplacesGraphView(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + const disk = `package main + +func Disk() {} +` + require.NoError(t, os.WriteFile(path, []byte(disk), 0o644)) + + g := graph.New() + idx := newTestIndexer(g) + idx.SetRootPath(dir) + require.NoError(t, idx.IndexFile(path)) + + // Baseline: disk function present, overlay function absent. + requireSymbolPresent(t, g, "main.go", "Disk") + requireSymbolAbsent(t, g, "main.go", "Overlay") + + const overlay = `package main + +func Disk() {} + +func Overlay() {} +` + require.NoError(t, idx.IndexFileFromContent(path, []byte(overlay), true)) + + // Overlay applied: both functions visible in the graph. + requireSymbolPresent(t, g, "main.go", "Disk") + requireSymbolPresent(t, g, "main.go", "Overlay") + + // Restore: re-index from disk; the overlay-added function disappears. + require.NoError(t, idx.IndexFile(path)) + requireSymbolPresent(t, g, "main.go", "Disk") + requireSymbolAbsent(t, g, "main.go", "Overlay") +} + +// TestIndexFileFromContent_DoesNotStampMtime confirms that overlay +// applies don't poison mtime tracking — otherwise the next watcher +// event would see the on-disk file as "older than the overlay" and +// skip the restore. The post-apply mtime must equal the pre-apply +// mtime (zero, in this test, because the file was never indexed +// from disk before the overlay). +func TestIndexFileFromContent_DoesNotStampMtime(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x.go") + require.NoError(t, os.WriteFile(path, []byte("package x\n"), 0o644)) + + g := graph.New() + idx := newTestIndexer(g) + idx.SetRootPath(dir) + + before := idx.FileMtimes() + require.Empty(t, before, "pre-condition: no mtime tracking before any index call") + + const overlay = "package x\n\nfunc Added() {}\n" + require.NoError(t, idx.IndexFileFromContent(path, []byte(overlay), true)) + + after := idx.FileMtimes() + require.Empty(t, after, "overlay apply must not stamp mtime") +} + +// TestIndexFileFromContent_DeletionEquivalentViaEvictFile mirrors the +// MCP overlay-middleware deletion path: tombstone overlays are routed +// through EvictFile rather than IndexFileFromContent. Verifying the +// effect here keeps the test surface honest about what the middleware +// actually does for deleted: true overlays. +func TestIndexFileFromContent_DeletionEquivalentViaEvictFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "del.go") + require.NoError(t, os.WriteFile(path, []byte("package del\nfunc Disk() {}\n"), 0o644)) + + g := graph.New() + idx := newTestIndexer(g) + idx.SetRootPath(dir) + require.NoError(t, idx.IndexFile(path)) + requireSymbolPresent(t, g, "del.go", "Disk") + + // Deletion overlay → EvictFile on the absolute path. + idx.EvictFile(path) + requireSymbolAbsent(t, g, "del.go", "Disk") + + // Revert (re-index from disk) brings the symbol back. + require.NoError(t, idx.IndexFile(path)) + requireSymbolPresent(t, g, "del.go", "Disk") +} + +// requireSymbolPresent asserts that the graph contains a symbol with +// the given name in the given relative path. The assertion is +// relative-path-aware: in multi-repo mode the file path is prefixed, +// in single-repo mode it isn't — both cases match. +func requireSymbolPresent(t *testing.T, g *graph.Graph, relPath, name string) { + t.Helper() + require.Truef(t, hasSymbol(g, relPath, name), + "expected symbol %q in file %q to be present in graph", name, relPath) +} + +func requireSymbolAbsent(t *testing.T, g *graph.Graph, relPath, name string) { + t.Helper() + require.Falsef(t, hasSymbol(g, relPath, name), + "expected symbol %q in file %q to be absent from graph", name, relPath) +} + +func hasSymbol(g *graph.Graph, relPath, name string) bool { + for _, n := range g.GetFileNodes(relPath) { + if n.Name == name { + return true + } + } + return false +} diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 231d4c0..9eb8698 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -963,6 +963,58 @@ func (mi *MultiIndexer) GetIndexer(repoPrefix string) *Indexer { return mi.indexers[repoPrefix] } +// IndexerForFile routes an absolute path to the per-repo Indexer that +// owns it. Returns (nil, "") when no tracked repo contains the path. +// This is the multi-repo counterpart to the single-Indexer overlay +// path: the MCP overlay middleware calls it to find the right Indexer +// for each pushed file before invoking IndexFileFromContent. +func (mi *MultiIndexer) IndexerForFile(absPath string) (*Indexer, string) { + prefix := mi.RepoForFile(absPath) + if prefix == "" { + return nil, "" + } + return mi.GetIndexer(prefix), prefix +} + +// IndexFileFromContent routes an overlay apply through MultiIndexer. +// Forwards to the per-repo Indexer.IndexFileFromContent; returns nil +// (a no-op) when no tracked repo owns absPath. The no-op behaviour +// matches the on-disk path: the overlay middleware silently skips +// untracked paths instead of failing the whole tool call. +func (mi *MultiIndexer) IndexFileFromContent(absPath string, src []byte) error { + idx, _ := mi.IndexerForFile(absPath) + if idx == nil { + return nil + } + return idx.IndexFileFromContent(absPath, src, true) +} + +// EvictFileByAbs routes a deletion overlay through MultiIndexer. +// Forwards to the per-repo Indexer.EvictFile; no-op when no tracked +// repo owns the path. The bool return reports whether eviction +// actually happened — useful to the overlay middleware so it can skip +// scheduling a restore-from-disk for paths it didn't touch. +func (mi *MultiIndexer) EvictFileByAbs(absPath string) bool { + idx, _ := mi.IndexerForFile(absPath) + if idx == nil { + return false + } + idx.EvictFile(absPath) + return true +} + +// ReindexFromDisk routes an overlay-revert through MultiIndexer. +// Forwards to the per-repo Indexer.IndexFile; no-op when no tracked +// repo owns absPath. Used by the overlay middleware to restore the +// on-disk view after a tool call completes. +func (mi *MultiIndexer) ReindexFromDisk(absPath string) error { + idx, _ := mi.IndexerForFile(absPath) + if idx == nil { + return nil + } + return idx.IndexFile(absPath) +} + // ResolveFilePath takes a repo-prefixed relative path (e.g. "ade/internal/foo.go") // and returns the absolute filesystem path by looking up the repo's root directory. // Returns empty string if the repo prefix is not found. diff --git a/internal/mcp/diagnostics.go b/internal/mcp/diagnostics.go index aacf9b4..8cbbb6a 100644 --- a/internal/mcp/diagnostics.go +++ b/internal/mcp/diagnostics.go @@ -272,7 +272,7 @@ func pathToFileURI(absPath string) string { // `subscribe_diagnostics` opts the calling session into push // notifications; `unsubscribe_diagnostics` opts it back out. func (s *Server) registerDiagnosticsTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("subscribe_diagnostics", mcp.WithDescription("Opt the current MCP session into `notifications/diagnostics` push events. Once subscribed, every LSP `textDocument/publishDiagnostics` matching your filter is forwarded to your session as an MCP notification with `{uri, path, server, diagnostics}` payload. The current diagnostic state of every matching file is replayed immediately as `initial_replay: true` so you don't have to wait for the next edit. Optional `min_severity` (1=error, 2=warning, 3=info, 4=hint; default 0=all) and `path_prefix` (absolute path prefix; default empty=all files) restrict which payloads reach this session. Resubscribing overwrites the previous filter and re-replays the current state. Pair with `unsubscribe_diagnostics` to opt back out."), mcp.WithNumber("min_severity", mcp.Description("Drop diagnostics whose LSP severity number exceeds this value. 1=error, 2=warning, 3=info, 4=hint. 0 (default) keeps everything.")), @@ -280,7 +280,7 @@ func (s *Server) registerDiagnosticsTools() { ), s.handleSubscribeDiagnostics, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("unsubscribe_diagnostics", mcp.WithDescription("Opt the current MCP session out of `notifications/diagnostics` push events. Idempotent."), ), diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go new file mode 100644 index 0000000..c2796e4 --- /dev/null +++ b/internal/mcp/overlay.go @@ -0,0 +1,265 @@ +package mcp + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "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 an editor-overlay manager into the MCP +// server. After this call: +// +// - Every `tools/call` is wrapped by an apply/revert middleware that +// merges the calling session's overlay buffers into the in-memory +// graph for the duration of the call. Tools that walk the graph +// (find_usages, get_call_chain, get_file_summary, …) and tools that +// read symbol source (get_symbol_source, get_editing_context, …) +// therefore see overlay content with no per-tool changes. +// +// - The `overlay_register` / `overlay_push` / `overlay_list` / +// `overlay_delete` / `overlay_drop` MCP tools become live so an +// IDE extension speaking MCP can manage its own overlays without +// reaching for the `/v1/overlay/*` HTTP endpoints. +// +// Passing nil leaves the server in pre-overlay behaviour (reads come +// from disk; 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 +// apply/revert middleware. Tool registration helpers (`s.addTool`) +// route every handler through this so the dispatcher and the HTTP +// `CallToolStrict` path both pay the same middleware cost — the HTTP +// path bypasses mcp-go's hook surface, so we can't rely on Hooks alone. +// +// When the server has no overlay manager, or the calling context has +// no overlay session, this is a transparent pass-through (one map +// lookup, zero parsing). +func (s *Server) wrapToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + revert, err := s.applyOverlaysForCtx(ctx) + if err != nil { + // Drift / push-back-required surfaces as a structured tool + // error result — the client must re-read the file and + // resubmit a fresh overlay. We return (result, nil) so the + // JSON-RPC framing carries the message rather than a + // transport error. + return mcp.NewToolResultError(err.Error()), nil + } + if revert != nil { + defer revert() + } + return h(ctx, req) + } +} + +// applyOverlaysForCtx applies every overlay attached to the calling +// session to the live in-memory graph and returns a revert closure. +// Returns (nil, nil) when the request has no overlay session, the +// session has no files, or no overlay manager is wired — the +// fast-path that 99% of tool calls take. +// +// On drift the function returns a non-nil error and leaves the graph +// in its disk-restored state (every partial apply is rolled back). +// The caller surfaces the error as a structured MCP tool result so the +// client knows to re-read and resubmit the affected overlay. +// +// Concurrency: applies are serialised through s.overlayApplyMu so two +// in-flight tool calls can't interleave their evict/re-add pairs on +// the same file. The lock is held for the full apply+tool+revert +// window — for IDE-driven workloads (1-3 overlay files, parse < 50 ms +// per file) this is a sub-100 ms serialisation, which dominates the +// editor's keystroke cadence either way. +func (s *Server) applyOverlaysForCtx(ctx context.Context) (revert func(), err error) { + if s == nil || s.overlays == nil { + return nil, nil + } + sessID := SessionIDFromContext(ctx) + if sessID == "" { + return nil, nil + } + if s.overlays.FileCount(sessID) == 0 { + return nil, nil + } + _, files, err := s.overlays.SnapshotFor(sessID) + if err != nil { + // Session evaporated between the FileCount fast-path and the + // snapshot read. Treat as "no overlay" — the client will + // re-register if it cares. + return nil, nil + } + if len(files) == 0 { + return nil, nil + } + + s.overlayApplyMu.Lock() + applied := make([]string, 0, len(files)) + // Track per-application kind so revert restores deletions by + // re-indexing-from-disk while applied-content paths also revert + // to disk. Both kinds use the same restore call (IndexFile reads + // from disk; for paths that don't exist on disk it's a no-op + // because the in-memory state is already evicted). + for _, ov := range files { + absPath, resolveErr := s.resolveOverlayAbsPath(ov.Path) + if resolveErr != nil { + s.revertOverlays(applied) + s.overlayApplyMu.Unlock() + return nil, resolveErr + } + if absPath == "" { + // Path didn't resolve to a tracked workspace root — + // silently skip; the disk path will still be honoured if + // the file ever falls under a tracked repo later. + continue + } + if ov.BaseSHA != "" { + if !overlaySHAMatches(absPath, ov.BaseSHA) { + s.revertOverlays(applied) + s.overlayApplyMu.Unlock() + return nil, fmt.Errorf("%w: %s", daemon.ErrOverlayDrift, ov.Path) + } + } + if applyErr := s.applyOneOverlay(absPath, ov); applyErr != nil { + s.revertOverlays(applied) + s.overlayApplyMu.Unlock() + return nil, applyErr + } + applied = append(applied, absPath) + } + + // Lock stays held until revert; tool handler runs serialised + // against any other overlay-active request. Revert releases the + // lock so the next overlay-active request can proceed. + return func() { + s.revertOverlays(applied) + s.overlayApplyMu.Unlock() + }, nil +} + +// applyOneOverlay evicts the file from the graph and, when the +// overlay carries content, re-indexes from the supplied bytes. +// Deletion overlays leave the file evicted. +func (s *Server) applyOneOverlay(absPath string, ov daemon.OverlayFile) error { + if ov.Deleted { + if s.multiIndexer != nil { + s.multiIndexer.EvictFileByAbs(absPath) + return nil + } + if s.indexer != nil { + s.indexer.EvictFile(absPath) + } + return nil + } + if s.multiIndexer != nil { + return s.multiIndexer.IndexFileFromContent(absPath, []byte(ov.Content)) + } + if s.indexer != nil { + return s.indexer.IndexFileFromContent(absPath, []byte(ov.Content), true) + } + return nil +} + +// revertOverlays re-indexes each applied path from disk so the +// post-tool state matches the saved-buffer view. Called under +// overlayApplyMu; safe to call with an empty slice. Errors during +// revert are logged (debug) but not returned: a tool call is finished +// and the next watcher-driven IndexFile will heal a stuck state. +func (s *Server) revertOverlays(absPaths []string) { + for _, abs := range absPaths { + var err error + switch { + case s.multiIndexer != nil: + err = s.multiIndexer.ReindexFromDisk(abs) + case s.indexer != nil: + err = s.indexer.IndexFile(abs) + } + if err != nil && s.logger != nil { + s.logger.Debug("overlay revert: re-index from disk failed", + zap.String("path", abs), zap.Error(err)) + } + } +} + +// resolveOverlayAbsPath turns an overlay-supplied path into the +// absolute filesystem path used by indexer apply calls. Accepts: +// +// - Absolute paths — returned unchanged after symlink-safe cleaning. +// - Repo-prefixed paths (multi-repo mode, e.g. "ade/internal/foo.go") +// — resolved via MultiIndexer.ResolveFilePath. +// - Repo-relative paths (single-repo mode) — joined onto the +// Indexer's root path. +// +// Returns ("", nil) when the path doesn't resolve to a known +// workspace; the caller skips such overlays without failing the +// request, mirroring how the on-disk indexer treats untracked files. +func (s *Server) resolveOverlayAbsPath(p string) (string, error) { + if p == "" { + return "", errors.New("overlay path is empty") + } + if filepath.IsAbs(p) { + return filepath.Clean(p), nil + } + if s.multiIndexer != nil { + if abs := s.multiIndexer.ResolveFilePath(p); abs != "" { + return abs, nil + } + } + if s.indexer != nil { + if root := s.indexer.RootPath(); root != "" { + return filepath.Join(root, p), nil + } + } + return "", nil +} + +// overlaySHAMatches re-computes the git blob SHA of the on-disk file +// and compares it to the expected SHA captured at editor-open time. +// Matches the git blob hash format (`blob \0`) so the +// editor can pass the SHA it reads from `git ls-files -s` / the +// LSP `textDocument/didOpen` baseline without any client-side +// reformatting. Returns false on any read error: the safer default +// is "drift detected" — re-read and resubmit. +func overlaySHAMatches(absPath, expected string) bool { + expected = strings.ToLower(strings.TrimSpace(expected)) + if expected == "" { + return true + } + data, err := os.ReadFile(absPath) + if err != nil { + return false + } + h := sha1.New() + fmt.Fprintf(h, "blob %d\x00", len(data)) + _, _ = h.Write(data) + return hex.EncodeToString(h.Sum(nil)) == expected +} + +// overlayApplyMu serialises overlay-active tool calls. Declared on +// Server (server.go); declared here as a package-local sentinel so the +// linter doesn't flag the struct field as unused before the wire-up +// step adds it. +var _ sync.Mutex diff --git a/internal/mcp/overlay_e2e_test.go b/internal/mcp/overlay_e2e_test.go new file mode 100644 index 0000000..fe6c38e --- /dev/null +++ b/internal/mcp/overlay_e2e_test.go @@ -0,0 +1,242 @@ +package mcp + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" +) + +// setupOverlayServer builds a fully-wired MCP server with an attached +// OverlayManager, an indexed temp repo, and a single Go file the tests +// can overlay-edit. Returns the server, the repo root, the absolute +// file path, and a teardown function. +func setupOverlayServer(t *testing.T) (srv *Server, dir, file string) { + t.Helper() + dir = t.TempDir() + file = filepath.Join(dir, "main.go") + require.NoError(t, os.WriteFile(file, []byte(`package main + +func Disk() {} +`), 0o644)) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + cfg := config.Default() + idx := indexer.New(g, reg, cfg.Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + + eng := query.NewEngine(g) + srv = NewServer(eng, g, idx, nil, zap.NewNop(), nil) + srv.SetOverlayManager(daemon.NewOverlayManager(time.Minute)) + srv.RunAnalysis() + return srv, dir, file +} + +func callToolByName(t *testing.T, srv *Server, ctx context.Context, name string, args map[string]any) *mcplib.CallToolResult { + t.Helper() + tool := srv.MCPServer().GetTool(name) + require.NotNilf(t, tool, "tool %q must be registered", name) + req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{ + Name: name, + Arguments: args, + }} + res, err := tool.Handler(ctx, req) + require.NoError(t, err) + return res +} + +func toolText(res *mcplib.CallToolResult) string { + var sb strings.Builder + for _, c := range res.Content { + if tc, ok := c.(mcplib.TextContent); ok { + sb.WriteString(tc.Text) + } + } + return sb.String() +} + +// TestOverlay_QueryConsumption_GetFileSummary is the core I19 contract: +// after the editor pushes an overlay adding a new function, the very +// next get_file_summary call must surface that function. The test +// proves the overlay-apply middleware runs around tool dispatch and +// that the indexer-from-content path produces graph entries query +// tools observe. +func TestOverlay_QueryConsumption_GetFileSummary(t *testing.T) { + srv, dir, file := setupOverlayServer(t) + sessID := "test-session-1" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: file, + Content: `package main + +func Disk() {} + +func Overlay() {} +`, + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(file), + }) + body := toolText(res) + require.Containsf(t, body, "Overlay", "get_file_summary must surface the overlay-added symbol; got %s", body) + require.Contains(t, body, "Disk") + + // Post-call revert: a query without the session ID must NOT see the + // overlay function. This guarantees the middleware restored the + // on-disk view after the previous tool returned. + bare := callToolByName(t, srv, context.Background(), "get_file_summary", map[string]any{ + "path": filepath.Base(file), + }) + require.NotContainsf(t, toolText(bare), "Overlay", + "post-tool revert must restore the on-disk view") + _ = dir +} + +// TestOverlay_DriftSurfacesAsToolError verifies that a stale overlay +// (BaseSHA recorded at editor-open time disagreeing with the current +// on-disk SHA) makes the next tool call fail with an MCP error result +// rather than silently returning stale data. The client is expected to +// re-read the file and resubmit a fresh overlay. +func TestOverlay_DriftSurfacesAsToolError(t *testing.T) { + srv, _, file := setupOverlayServer(t) + sessID := "test-session-drift" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: file, + Content: "package main\n\nfunc Overlay() {}\n", + BaseSHA: "0000000000000000000000000000000000000000", // intentionally wrong + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(file), + }) + require.True(t, res.IsError, "drift must surface as an MCP tool error") + require.Contains(t, toolText(res), "overlay base SHA mismatch") +} + +// TestOverlay_BaseSHA_MatchProceeds confirms the drift-detection +// happy path: when the editor's BaseSHA matches the on-disk git-blob +// hash, the overlay applies and the new symbol is visible. Without +// this we'd have no positive coverage of the SHA check — only the +// negative path. +func TestOverlay_BaseSHA_MatchProceeds(t *testing.T) { + srv, _, file := setupOverlayServer(t) + + // Compute the on-disk git blob SHA the same way overlay.go does + // (`blob \0` → sha1). The editor would normally + // read this from `git ls-files -s` or its LSP host's didOpen + // version metadata. + data, err := os.ReadFile(file) + require.NoError(t, err) + h := sha1.New() + fmt.Fprintf(h, "blob %d\x00", len(data)) + _, _ = h.Write(data) + baseSHA := hex.EncodeToString(h.Sum(nil)) + + sessID := "test-session-match" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: file, + Content: "package main\n\nfunc Disk() {}\n\nfunc Overlay() {}\n", + BaseSHA: baseSHA, + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(file), + }) + require.False(t, res.IsError) + require.Contains(t, toolText(res), "Overlay") +} + +// TestOverlay_NoSessionNoOp is the fast-path: a tools/call with no +// overlay session bound to the context must NOT pay any overlay +// apply/revert cost and must observe the on-disk view. Failing this +// would mean overlay support imposes overhead on every non-overlay +// MCP call — the regression that gates wide adoption. +func TestOverlay_NoSessionNoOp(t *testing.T) { + srv, _, file := setupOverlayServer(t) + // A registered session with no overlays attached: the fast-path + // (FileCount==0) must skip the apply pass entirely. + require.NoError(t, srv.OverlayManager().RegisterWithID("idle", "")) + ctx := WithSessionID(context.Background(), "idle") + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(file), + }) + require.False(t, res.IsError) + require.Contains(t, toolText(res), "Disk") + require.NotContains(t, toolText(res), "Overlay") +} + +// TestOverlay_MCP_RegisterPushList exercises the MCP-tool surface for +// overlay management: overlay_register, overlay_push, overlay_list. +// This is the path an IDE extension takes when it'd rather speak the +// MCP protocol than reach for the parallel /v1/overlay/* HTTP API. +func TestOverlay_MCP_RegisterPushList(t *testing.T) { + srv, _, file := setupOverlayServer(t) + sessID := "test-mcp-register" + + ctx := WithSessionID(context.Background(), sessID) + regRes := callToolByName(t, srv, ctx, "overlay_register", map[string]any{}) + require.False(t, regRes.IsError, "overlay_register: %s", toolText(regRes)) + + pushRes := callToolByName(t, srv, ctx, "overlay_push", map[string]any{ + "path": file, + "content": "package main\n\nfunc Overlay() {}\n", + }) + require.False(t, pushRes.IsError, "overlay_push: %s", toolText(pushRes)) + + listRes := callToolByName(t, srv, ctx, "overlay_list", map[string]any{}) + listText := toolText(listRes) + require.Contains(t, listText, file, "overlay_list must mention the pushed path: %s", listText) + require.Contains(t, listText, `"count":1`) +} + +// TestOverlay_DeletedFileGoneFromGraph verifies the tombstone path: +// when an overlay is pushed with deleted=true, the file's symbols +// must vanish from the graph for the duration of the call — the +// editor wants to preview "delete this file" without staging the +// deletion to disk. +func TestOverlay_DeletedFileGoneFromGraph(t *testing.T) { + srv, _, file := setupOverlayServer(t) + sessID := "test-mcp-del" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: file, + Deleted: true, + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(file), + }) + // File summary against a tombstoned file: either it returns a + // structured "file not in graph" error, or it succeeds with an + // empty symbol set. Both are correct post-conditions; only the + // Disk symbol leaking back through would be a regression. + require.NotContains(t, toolText(res), "Disk", + "deletion overlay must hide the tombstoned file's symbols") +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index cbb0d50..ffa5b4c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -16,6 +16,7 @@ import ( "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/contracts" + "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/llm" @@ -147,6 +148,25 @@ type Server struct { // Populated by registerToolWithScope as tools are added; consulted // by ResolveToolScope before each handler runs. toolScopes *scopeRegistry + + // overlays is the optional editor-overlay manager. When non-nil, + // every `tools/call` is wrapped (via s.addTool) with the + // apply/revert middleware in overlay.go so MCP tools see the + // caller's editor-buffer content for the duration of the call. + // Wired post-construction by SetOverlayManager. + overlays *daemon.OverlayManager + + // overlayApplyMu serialises overlay-active tool calls so two + // in-flight requests can't race on the same overlay path's + // evict/re-add pair. Held for the full apply+handler+revert + // window; transparent (untaken) when the caller has no overlay. + overlayApplyMu sync.Mutex + + // registerOverlayToolsOnce gates the overlay MCP tool family + // (overlay_register / overlay_push / overlay_list / + // overlay_delete / overlay_drop) so a second SetOverlayManager + // call doesn't double-register them. + registerOverlayToolsOnce sync.Once } // sessionFor returns the session-scoped state for the current request. @@ -1071,6 +1091,22 @@ func (s *Server) MCPServer() *server.MCPServer { return s.mcpServer } +// addTool registers a tool whose handler is wrapped with the overlay +// apply/revert middleware (see overlay.go::wrapToolHandler). Every +// tool added through this helper picks up overlay-aware behaviour +// transparently — graph-walking tools see the editor-buffer view, +// source-reading tools see overlay content. Tools registered the old +// way (s.mcpServer.AddTool) still work but bypass the middleware. +// +// Routing every internal registration through this helper means both +// the daemon-dispatched path (HandleMessage) and the in-process HTTP +// path (Handler.CallToolStrict) get identical overlay semantics — the +// latter bypasses mcp-go's Hooks, so handler-level wrapping is the +// only place that covers both transports. +func (s *Server) addTool(tool mcp.Tool, handler server.ToolHandlerFunc) { + s.mcpServer.AddTool(tool, s.wrapToolHandler(handler)) +} + // SetContractRegistry sets an explicit contract registry override for the MCP // server. Used by single-indexer callers and tests. In multi-repo mode the // server prefers a freshly-merged registry from MultiIndexer (see diff --git a/internal/mcp/tools_analysis.go b/internal/mcp/tools_analysis.go index 171ca2b..fddbad9 100644 --- a/internal/mcp/tools_analysis.go +++ b/internal/mcp/tools_analysis.go @@ -11,7 +11,7 @@ import ( ) func (s *Server) registerAnalysisTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_communities", mcp.WithDescription("Returns functional clusters discovered by community detection. Without id: list all communities with summaries. With id: full details of a specific community (members, files, cohesion)."), mcp.WithString("id", mcp.Description("Optional community ID (e.g. community-0). When set, returns full details of that community instead of the list.")), @@ -19,7 +19,7 @@ func (s *Server) registerAnalysisTools() { s.handleGetCommunities, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_processes", mcp.WithDescription("Returns discovered execution flows — named chains of function calls starting from entry points. Without id: list all processes. With id: full step-by-step call chain for that process."), mcp.WithString("id", mcp.Description("Optional process ID (e.g. process-0). When set, returns the full step-by-step call chain for that process instead of the list.")), @@ -29,7 +29,7 @@ func (s *Server) registerAnalysisTools() { s.handleGetProcesses, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("detect_changes", mcp.WithDescription("Maps uncommitted git changes to symbols in the graph and runs blast radius analysis. The key pre-commit review tool."), mcp.WithString("scope", mcp.Description("unstaged (default), staged, all, or compare")), diff --git a/internal/mcp/tools_ast.go b/internal/mcp/tools_ast.go index e6deb1b..f5b43d9 100644 --- a/internal/mcp/tools_ast.go +++ b/internal/mcp/tools_ast.go @@ -34,7 +34,7 @@ import ( // - `excludes_tests` defaulting to true for detectors so test // fixtures don't drown real findings. func (s *Server) registerASTTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("search_ast", mcp.WithDescription(buildSearchASTDescription()), mcp.WithString("pattern", mcp.Description("Tree-sitter S-expression query. Capture nodes with `@name`, anchor the match span with `@match`. Predicates: `(#eq? @x \"literal\")`, `(#match? @x \"regex\")`. Mutually exclusive with `detector`.")), diff --git a/internal/mcp/tools_clones.go b/internal/mcp/tools_clones.go index adb65fb..4fc1ccb 100644 --- a/internal/mcp/tools_clones.go +++ b/internal/mcp/tools_clones.go @@ -19,7 +19,7 @@ import ( // MinHash + LSH clone-detection pass (see internal/clones and the // indexer's detectClonesAndEmitEdges). func (s *Server) registerCloneTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("find_clones", mcp.WithDescription("Surfaces near-duplicate ('clone') function/method clusters from the EdgeSimilarTo graph layer. Each cluster is a connected component of bodies whose MinHash + LSH estimated Jaccard similarity crossed the index-time threshold — catches copy-paste and renamed-variable (Type-1/Type-2) clones. Every member is flagged is_dead (zero incoming calls/refs), so dead_only=true yields the Gortex-unique \"dead duplicates of live code\" diagnostic: dead functions that are near-copies of code still in use."), mcp.WithNumber("min_similarity", mcp.Description("Only report clone pairs at or above this estimated Jaccard similarity (0..1). Default 0 — every recorded EdgeSimilarTo edge.")), diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index c2cc75b..61210aa 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -17,7 +17,7 @@ import ( ) func (s *Server) registerCodingTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_editing_context", mcp.WithDescription("The primary tool to call before editing any file. Returns all symbols defined in the file, their signatures, direct dependencies, and immediate callers — everything needed to code without reading raw source lines."), mcp.WithString("path", mcp.Required(), mcp.Description("Relative file path")), @@ -29,7 +29,7 @@ func (s *Server) registerCodingTools() { s.handleGetEditingContext, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("find_import_path", mcp.WithDescription("Given a symbol name you want to use in a file, returns the correct import path. Use instead of reading files or guessing package paths."), mcp.WithString("name", mcp.Required(), mcp.Description("Name of the symbol to import")), @@ -38,7 +38,7 @@ func (s *Server) registerCodingTools() { s.handleFindImportPath, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("explain_change_impact", mcp.WithDescription("Given a list of symbols you plan to modify, returns risk-tiered blast radius: d=1 will break, d=2 likely affected, d=3 needs testing. Includes affected processes and communities."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of symbol IDs to modify")), @@ -48,7 +48,7 @@ func (s *Server) registerCodingTools() { s.handleEnhancedChangeImpact, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_symbol_source", mcp.WithDescription("Returns the source code of a specific symbol (function, method, type) without reading the entire file. Use instead of Read when you know which symbol you need — saves 70-80% of tokens compared to reading the whole file."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol node ID (e.g. pkg/server.go::HandleRequest)")), @@ -60,7 +60,7 @@ func (s *Server) registerCodingTools() { s.handleGetSymbolSource, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("batch_symbols", mcp.WithDescription("Returns signatures, source code, callers, and callees for multiple symbols in one call. Use instead of calling get_symbol_source or get_symbol multiple times — saves 60% round-trip overhead."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of symbol IDs")), @@ -73,7 +73,7 @@ func (s *Server) registerCodingTools() { s.handleBatchSymbols, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_test_targets", mcp.WithDescription("Given changed symbol IDs, traces the call graph to find test files and test functions that exercise those symbols. Use after editing to know exactly which tests to run — no guessing, no running the entire suite."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of changed symbol IDs")), @@ -82,7 +82,7 @@ func (s *Server) registerCodingTools() { s.handleGetTestTargets, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("suggest_pattern", mcp.WithDescription("Given an existing symbol as an example, extracts the structural pattern for creating similar code. Returns the example source, sibling symbols with the same pattern, registration/wiring code, test patterns, and files to edit. Use when adding a new function/handler/extractor that follows an existing convention."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to use as the pattern example")), @@ -90,7 +90,7 @@ func (s *Server) registerCodingTools() { s.handleSuggestPattern, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_edit_plan", mcp.WithDescription("Given symbols you plan to change, returns a dependency-ordered list of files and symbols to edit — definitions first, then implementations, then callers, then tests. Eliminates manual dependency reasoning. Use before any multi-file refactor."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of symbol IDs to change")), @@ -99,7 +99,7 @@ func (s *Server) registerCodingTools() { s.handleGetEditPlan, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("edit_symbol", mcp.WithDescription("Edit a symbol's source code directly by ID — no Read needed. Gortex resolves the file and line range, finds the old_source fragment, replaces it with new_source, and writes the file. Eliminates the Read→Edit roundtrip for ~80% of edits."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID (e.g. server.go::NewServer)")), @@ -109,7 +109,7 @@ func (s *Server) registerCodingTools() { s.handleEditSymbol, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("edit_file", mcp.WithDescription("Edit any file (markdown, config, spec, template, source) by exact string replacement — no Read needed. Accepts absolute paths or paths relative to the indexed repo root. Writes atomically (temp+rename) and re-indexes the file so the graph stays fresh. Pass dry_run=true to validate the replacement without writing. Complements edit_symbol for non-code files that have no symbol ID."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path, or repo-prefixed / repo-root-relative path")), @@ -121,7 +121,7 @@ func (s *Server) registerCodingTools() { s.handleEditFile, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("write_file", mcp.WithDescription("Create a new file or overwrite an existing one with the given content — no Read needed. Accepts absolute paths or paths relative to the indexed repo root. Writes atomically (temp+rename) and re-indexes the file so the graph stays fresh. Pass dry_run=true to report what would happen without writing. Use for new docs, configs, specs, scaffolded files; prefer edit_symbol or edit_file when a symbol/string target exists."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path, or repo-prefixed / repo-root-relative path")), @@ -131,7 +131,7 @@ func (s *Server) registerCodingTools() { s.handleWriteFile, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("rename_symbol", mcp.WithDescription("Generates coordinated multi-file edit instructions for renaming a symbol. Returns {file, line, old_text, new_text, confidence} for every reference. Use dry_run to preview, then apply edits with the Edit tool."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to rename (e.g. auth/token.go::validateToken)")), @@ -140,7 +140,7 @@ func (s *Server) registerCodingTools() { s.handleRenameSymbol, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("smart_context", mcp.WithDescription("Assembles the minimal context needed for a task in one call. Searches for relevant symbols, gets their source and relationships, finds patterns to follow, and builds an edit plan. Replaces an entire exploration phase of 5-10 tool calls."), mcp.WithString("task", mcp.Required(), mcp.Description("Natural language description of what you want to do (e.g. 'add a new MCP tool called list_files')")), @@ -154,7 +154,7 @@ func (s *Server) registerCodingTools() { s.handleSmartContext, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("plan_turn", mcp.WithDescription("Opening-move router. Returns a short ranked list of recommended tool calls (with pre-filled args) for a task — 'what should I call first?'. Use before smart_context when you want a cheaper routing decision; call smart_context directly when you're committed to exploring."), mcp.WithString("task", mcp.Required(), mcp.Description("Natural-language description of the task")), @@ -163,7 +163,7 @@ func (s *Server) registerCodingTools() { s.handlePlanTurn, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_untested_symbols", mcp.WithDescription("Returns functions and methods in non-test files that no test file reaches via the call graph — the inverse of get_test_targets. Ranked by fan_in descending so the most-used untested symbols surface first. Use to prioritize where to add test coverage."), mcp.WithNumber("limit", mcp.Description("Max entries returned (default: 50)")), @@ -173,7 +173,7 @@ func (s *Server) registerCodingTools() { s.handleGetUntestedSymbols, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_recent_changes", mcp.WithDescription("Returns files and symbols that changed since the last call (watch mode only). Use to re-orient after the user edits files outside of Claude Code's view, without re-reading anything."), mcp.WithString("since", mcp.Description("ISO 8601 timestamp (omit for all changes since index)")), diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 676d32e..67f5c26 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -542,7 +542,7 @@ func compactSubGraph(sg *query.SubGraph) string { } func (s *Server) registerCoreTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("index_repository", mcp.WithDescription("Index or re-index a local repository path into Gortex. Call once at session start if not already running with --watch."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to repository")), @@ -550,7 +550,7 @@ func (s *Server) registerCoreTools() { s.handleIndexRepository, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_symbol", mcp.WithDescription("Use instead of Read to locate a function, type, interface, or variable definition. Returns location and signature without reading the whole file."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID (e.g. pkg/server.go::HandleRequest)")), @@ -562,7 +562,7 @@ func (s *Server) registerCoreTools() { s.handleGetSymbol, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("search_symbols", mcp.WithDescription("Use instead of Grep to find symbols across the whole codebase. Supports natural language queries with camelCase-aware tokenization and BM25 ranking — 'validate token auth' finds validateToken, AuthMiddleware, parseJWT."), mcp.WithString("query", mcp.Required(), mcp.Description("Search query — can be symbol name, concept, or multiple keywords")), @@ -583,7 +583,7 @@ func (s *Server) registerCoreTools() { s.handleSearchSymbols, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_file_summary", mcp.WithDescription("Use instead of Read to understand a file's role: returns all its symbols and imports without reading source lines."), mcp.WithString("path", mcp.Required(), mcp.Description("Relative file path")), @@ -598,7 +598,7 @@ func (s *Server) registerCoreTools() { s.handleGetFileSummary, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_dependencies", mcp.WithDescription("Returns what a symbol or file depends on — imports, calls, type references — without reading any files. Use before editing to understand incoming contracts."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), @@ -612,7 +612,7 @@ func (s *Server) registerCoreTools() { s.handleGetDependencies, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_dependents", mcp.WithDescription("Returns everything that depends on this symbol (blast radius). Call before changing a function or type to know what else must be updated."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), @@ -626,7 +626,7 @@ func (s *Server) registerCoreTools() { s.handleGetDependents, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_call_chain", mcp.WithDescription("Traces the call graph forward from a function without reading source. Use to understand what a function ultimately triggers."), mcp.WithString("id", mcp.Required(), mcp.Description("Function node ID")), @@ -643,7 +643,7 @@ func (s *Server) registerCoreTools() { s.handleGetCallChain, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_callers", mcp.WithDescription("Returns all callers of a function without reading source. Use instead of Grep when you need to know who calls a function."), mcp.WithString("id", mcp.Required(), mcp.Description("Function node ID")), @@ -658,7 +658,7 @@ func (s *Server) registerCoreTools() { s.handleGetCallers, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("find_implementations", mcp.WithDescription("Finds all concrete types that implement an interface. Use before changing an interface to identify all types that will be affected."), mcp.WithString("id", mcp.Required(), mcp.Description("Interface node ID")), @@ -669,7 +669,7 @@ func (s *Server) registerCoreTools() { s.handleFindImplementations, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("find_overrides", mcp.WithDescription("Finds all methods that override the given method (children) or the parent methods it overrides. Backed by EdgeOverrides materialised at index time and promoted to lsp_dispatch when an LSP is available."), mcp.WithString("id", mcp.Required(), mcp.Description("Method node ID")), @@ -681,7 +681,7 @@ func (s *Server) registerCoreTools() { s.handleFindOverrides, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_class_hierarchy", mcp.WithDescription("Returns the inheritance subgraph around a type, interface, or method. Walks EdgeExtends + EdgeImplements + EdgeComposes for type nodes and EdgeOverrides for method nodes — the same graph data find_implementations and find_overrides expose, but as a multi-hop tree so an agent gets the whole chain (parents → root, children → leaves) in one call. Use before refactoring an OO hierarchy or to answer 'what does this class inherit from / who subclasses it'."), mcp.WithString("id", mcp.Required(), mcp.Description("Seed node ID — a type, interface, or method")), @@ -695,7 +695,7 @@ func (s *Server) registerCoreTools() { s.handleGetClassHierarchy, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("find_usages", mcp.WithDescription("Use instead of Grep to find every reference to a symbol across the codebase. Returns precise locations with zero false positives."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), @@ -712,7 +712,7 @@ func (s *Server) registerCoreTools() { s.handleFindUsages, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_cluster", mcp.WithDescription("Returns the immediate neighbourhood around a node — all symbols it touches and that touch it. Useful for understanding a module's coupling before refactoring."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), @@ -725,7 +725,7 @@ func (s *Server) registerCoreTools() { s.handleGetCluster, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_repo_outline", mcp.WithDescription("Narrative single-call overview of the indexed codebase: primary languages, top communities, load-bearing hotspots, most-imported files, and entry points. Use at session start (or when onboarding to an unfamiliar repo) instead of assembling this from graph_stats + analyze + manual inspection. Output stays under ~1k tokens."), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), @@ -734,7 +734,7 @@ func (s *Server) registerCoreTools() { s.handleGetRepoOutline, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("graph_stats", mcp.WithDescription("Returns a compact summary of the indexed codebase: node/edge counts by kind and language. Call at session start to orient Claude in an unfamiliar repo."), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), diff --git a/internal/mcp/tools_dataflow.go b/internal/mcp/tools_dataflow.go index 7c47ab0..6f75e5f 100644 --- a/internal/mcp/tools_dataflow.go +++ b/internal/mcp/tools_dataflow.go @@ -24,7 +24,7 @@ import ( // Both tools accept format=gcx for the GCX1 wire format; the // per-tool encoders live in this file alongside the handlers. func (s *Server) registerDataflowTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("flow_between", mcp.WithDescription("Returns ranked dataflow paths between two symbols. Walks EdgeValueFlow / EdgeArgOf / EdgeReturnsTo forward from source to sink — the CPG-lite primitive that answers \"where does this value flow?\". Pairs with taint_paths for pattern-driven sweeps."), mcp.WithString("source_id", mcp.Required(), mcp.Description("Source symbol node ID — typically a function, method, or parameter")), @@ -37,7 +37,7 @@ func (s *Server) registerDataflowTools() { s.handleFlowBetween, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("taint_paths", mcp.WithDescription("Pattern-driven dataflow sweep — resolves every symbol matching `source_pattern` and `sink_pattern`, then walks the dataflow graph to find paths between each pair. Use for security-style queries (\"every flow from os.Getenv to db.Query\") and architectural audits. Pattern syntax: bare token = case-insensitive substring on symbol name; `exact:Foo` = exact name; `path:dir/` = file-path prefix; `kind:method` = restrict node kind. Combine clauses with spaces."), mcp.WithString("source_pattern", mcp.Required(), mcp.Description("Source pattern — see description for syntax")), diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index deb545a..b53b8c2 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -75,7 +75,7 @@ func (s *Server) ensureFresh(filePaths []string) []string { func (s *Server) registerEnhancementTools() { // verify_change - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("verify_change", mcp.WithDescription("Given proposed signature changes, checks all callers and interface implementors for contract violations. Use before refactoring to catch breaking changes."), mcp.WithString("changes", mcp.Required(), mcp.Description("JSON array of {symbol_id, new_signature} objects")), @@ -85,7 +85,7 @@ func (s *Server) registerEnhancementTools() { ) // check_guards - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("check_guards", mcp.WithDescription("Evaluates project-specific guard rules against a set of changed symbols. Reports co-change and boundary violations."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of changed symbol IDs")), @@ -97,7 +97,7 @@ func (s *Server) registerEnhancementTools() { ) // prefetch_context - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("prefetch_context", mcp.WithDescription("Predicts what context you will need next based on recent activity and a task description. Returns ranked symbols with relevance reasons."), mcp.WithString("task", mcp.Description("Natural language task description")), @@ -116,7 +116,7 @@ func (s *Server) registerEnhancementTools() { ) // analyze — unified graph analysis tool (dead_code, hotspots, cycles, would_create_cycle) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("analyze", mcp.WithDescription("Unified graph analysis. kind=dead_code: symbols with zero incoming edges. kind=hotspots: high-complexity symbols by fan-in/out. kind=cycles: circular dependency chains. kind=would_create_cycle: check if a new edge would form a cycle (requires from_id, to_id). kind=todos: list KindTodo nodes with optional tag/assignee/ticket/has_assignee filters. kind=blame: run `git blame` against the indexed repo and stamp meta.last_authored on every symbol-level node. kind=coverage: parse a Go cover.out profile (path via `profile` arg) and stamp meta.coverage_pct on every executable symbol. kind=stale_code: list symbols whose meta.last_authored is older than the threshold (requires blame-enriched graph). kind=ownership: group blame metadata by author email — symbol count, files touched, oldest/newest timestamps; supports path_prefix scoping (requires blame-enriched graph). kind=coverage_gaps: list symbols whose meta.coverage_pct falls in [min_pct, max_pct) — sorted ascending so the most undertested code surfaces first (requires coverage-enriched graph)."), mcp.WithString("kind", mcp.Required(), mcp.Description("Analysis kind: dead_code | hotspots | cycles | would_create_cycle | todos | blame | coverage | stale_code | ownership | coverage_gaps | stale_flags | releases | cgo_users | wasm_users | orphan_tables | unreferenced_tables | coverage_summary | channel_ops | goroutine_spawns | field_writers | annotation_users | config_readers | event_emitters | pubsub | string_emitters | error_surface | external_calls | routes | models | components | k8s_resources | images | kustomize | cross_repo | dbt_models")), @@ -151,7 +151,7 @@ func (s *Server) registerEnhancementTools() { ) // winnow_symbols — multi-axis constraint-chain retrieval - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("winnow_symbols", mcp.WithDescription("Structured constraint-chain retrieval. Combines BM25 text matching with structural filters (kind, language, fan-in/out, community, path prefix, churn, test classification) and returns a ranked list with per-axis score contributions. Use when search_symbols' free-text-only query is too coarse — e.g. 'methods in the auth community with fan-in >= 5 touching handlers/' or 'production functions only, no tests'."), mcp.WithString("kind", mcp.Description("Comma-separated node kinds to keep (function, method, type, interface, variable, contract)")), @@ -180,7 +180,7 @@ func (s *Server) registerEnhancementTools() { ) // scaffold - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("scaffold", mcp.WithDescription("Generates code scaffolding from an existing symbol pattern, including registration wiring and test stubs."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to use as the pattern example")), @@ -192,7 +192,7 @@ func (s *Server) registerEnhancementTools() { ) // diff_context - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("diff_context", mcp.WithDescription("Returns graph-enriched context for symbols affected by a git diff: source, callers, callees, community, processes, and per-file risk."), mcp.WithString("scope", mcp.Description("unstaged (default), staged, all, or compare")), @@ -203,7 +203,7 @@ func (s *Server) registerEnhancementTools() { ) // index_health - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("index_health", mcp.WithDescription("Reports the health and completeness of the Gortex index: parse failures, stale files, language coverage, and health score."), mcp.WithBoolean("compact", mcp.Description("Single-line summary output")), @@ -214,7 +214,7 @@ func (s *Server) registerEnhancementTools() { ) // get_symbol_history - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_symbol_history", mcp.WithDescription("Returns symbols modified during the current session with modification counts. Flags churning symbols (modified 3+ times)."), mcp.WithString("id", mcp.Description("Specific symbol ID (omit for all)")), @@ -224,7 +224,7 @@ func (s *Server) registerEnhancementTools() { ) // batch_edit - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("batch_edit", mcp.WithDescription("Applies multiple symbol edits in dependency order. Re-indexes after each edit. Stops on failure and reports status."), mcp.WithString("edits", mcp.Required(), mcp.Description("JSON array of {id, old_source, new_source} objects")), @@ -235,7 +235,7 @@ func (s *Server) registerEnhancementTools() { ) // contracts — unified contracts tool (list + check + validate) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("contracts", mcp.WithDescription("API contracts tool. action=list (default): lists detected contracts (HTTP, gRPC, GraphQL, topics, WebSocket, env, OpenAPI). action=check: detects orphan providers/consumers across repos. action=validate: diffs provider↔consumer request/response shapes and flags breaking/warning/info issues.\n\nDEFAULT SCOPE for list: auto-scopes to the active project's repos and hides dependency-origin contracts (type=dependency, vendored paths like vendor/, node_modules/). The response reports other_repos (count of contracts filtered out of scope) and dependencies_skipped (count of dep contracts hidden). To widen scope, pass repo=, project=, ref=, or all_repos=true. To include dependency contracts, pass include_deps=true."), mcp.WithString("action", mcp.Description("list (default), check, or validate")), @@ -259,7 +259,7 @@ func (s *Server) registerEnhancementTools() { ) // feedback — unified feedback tool (record + query) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("feedback", mcp.WithDescription("Agent learning feedback. action=record: report which symbols from smart_context/prefetch_context were useful, not_needed, or missing (improves future context). action=query: aggregated stats — most useful, most missed, accuracy."), mcp.WithString("action", mcp.Required(), mcp.Description("record or query")), @@ -276,7 +276,7 @@ func (s *Server) registerEnhancementTools() { ) // export_context - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("export_context", mcp.WithDescription("Generates a portable context briefing for a task as self-contained markdown or JSON. Use for sharing context outside MCP — paste into Slack, PRs, docs, or non-MCP AI tools."), mcp.WithString("task", mcp.Required(), mcp.Description("Natural language task description")), @@ -289,7 +289,7 @@ func (s *Server) registerEnhancementTools() { ) // audit_agent_config - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("audit_agent_config", mcp.WithDescription("Scans agent config files (CLAUDE.md, AGENTS.md, .cursor/rules, .github/copilot-instructions.md, etc.) for stale symbol references, dead file paths, and bloat — validated against the Gortex graph."), mcp.WithString("files", mcp.Description("Optional comma-separated file paths to audit (relative to repo root). If omitted, auto-discovers known agent config files.")), diff --git a/internal/mcp/tools_llm.go b/internal/mcp/tools_llm.go index 944a323..c6587f4 100644 --- a/internal/mcp/tools_llm.go +++ b/internal/mcp/tools_llm.go @@ -19,7 +19,7 @@ func (s *Server) registerLLMTools() { if s.llmService == nil || !s.llmService.Enabled() { return } - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("ask", mcp.WithDescription("Ask a research agent to navigate the gortex graph and return a synthesized answer. The agent runs on whichever LLM provider is configured (`llm.provider`): an in-process llama.cpp model, or a hosted Anthropic / OpenAI / Ollama backend. Use this instead of issuing many search_symbols / get_callers / contracts calls yourself when the question is open-ended or requires multi-hop reasoning across repos — the agent does that work and returns a filtered answer. Set chain=true for cross-system call-chain tracing (consumer → contract → provider → downstream)."), mcp.WithString("question", mcp.Required(), mcp.Description("Natural-language question about the indexed codebase. Examples: \"who calls NewServer in the mcp package?\", \"trace the path from web's /v1/stats consumer to the gortex handler\".")), diff --git a/internal/mcp/tools_lsp.go b/internal/mcp/tools_lsp.go index 5266ab3..fb0c55a 100644 --- a/internal/mcp/tools_lsp.go +++ b/internal/mcp/tools_lsp.go @@ -27,7 +27,7 @@ import ( // for that language — callers get a structured "no_lsp_for" error // payload instead of a hard failure. func (s *Server) registerLSPTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_diagnostics", mcp.WithDescription("Returns the most recent LSP diagnostics for a file. The file is auto-opened on the server if it isn't already, and the call optionally waits for the next publishDiagnostics burst."), mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute file path")), @@ -39,7 +39,7 @@ func (s *Server) registerLSPTools() { s.handleGetDiagnostics, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_code_actions", mcp.WithDescription("Returns LSP code actions (quickfix / organizeImports / refactor.* / source.*) available at a file location. Pass `only` to restrict the kinds returned."), mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute file path")), @@ -54,7 +54,7 @@ func (s *Server) registerLSPTools() { s.handleGetCodeActions, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("apply_code_action", mcp.WithDescription("Applies a single LSP code action to disk. Pass the action's `index` from a previous get_code_actions call, plus the same `path`/`start_line`/`only` tuple to re-resolve the action list."), mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute file path")), @@ -69,7 +69,7 @@ func (s *Server) registerLSPTools() { s.handleApplyCodeAction, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("fix_all_in_file", mcp.WithDescription("Loops codeAction → apply → re-collect-diagnostics until convergence. Defaults to {quickfix, source.organizeImports}; pass `kinds` to override. Returns iteration count, total actions applied, files touched, and final diagnostics."), mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute file path")), diff --git a/internal/mcp/tools_multi.go b/internal/mcp/tools_multi.go index a64f157..f1fe67f 100644 --- a/internal/mcp/tools_multi.go +++ b/internal/mcp/tools_multi.go @@ -16,7 +16,7 @@ import ( // registerMultiRepoTools registers MCP tools for multi-repo management: // track_repository, untrack_repository, set_active_project, get_active_project. func (s *Server) registerMultiRepoTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("track_repository", mcp.WithDescription("Add a repository to the tracked workspace at runtime. Indexes immediately and persists to config."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to repository")), @@ -25,7 +25,7 @@ func (s *Server) registerMultiRepoTools() { s.handleTrackRepository, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("untrack_repository", mcp.WithDescription("Remove a repository from the tracked workspace at runtime. Evicts nodes/edges and persists to config."), mcp.WithString("path", mcp.Required(), mcp.Description("Path or repo prefix to remove")), @@ -33,7 +33,7 @@ func (s *Server) registerMultiRepoTools() { s.handleUntrackRepository, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("set_active_project", mcp.WithDescription("Switch the active project scope. Persists to config and re-scopes all subsequent queries."), mcp.WithString("project", mcp.Required(), mcp.Description("Project name to activate")), @@ -41,7 +41,7 @@ func (s *Server) registerMultiRepoTools() { s.handleSetActiveProject, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("get_active_project", mcp.WithDescription("Return the current active project name and its list of member repositories."), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), diff --git a/internal/mcp/tools_overlay.go b/internal/mcp/tools_overlay.go new file mode 100644 index 0000000..0e45c54 --- /dev/null +++ b/internal/mcp/tools_overlay.go @@ -0,0 +1,229 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/daemon" +) + +// registerOverlayTools wires the editor-overlay management family. +// They let an MCP-native IDE extension push, list, and tear down +// editor buffers without reaching for the parallel `/v1/overlay/*` +// HTTP surface. Once a session has overlays attached, every +// subsequent tools/call from the same MCP session sees the overlaid +// view (see overlay.go::wrapToolHandler). +// +// The functions are intentionally not routed through s.addTool: the +// overlay middleware MUST NOT apply overlays before an overlay_push +// runs (or the new buffer would be re-evicted by the revert pass +// before it ever reached the user-facing query). So they register +// directly via s.mcpServer.AddTool. +func (s *Server) registerOverlayTools() { + s.mcpServer.AddTool( + mcp.NewTool("overlay_register", + mcp.WithDescription("Register an editor-overlay session bound to the current MCP session. Subsequent overlay_push calls attach in-flight editor buffers; every tools/call from this session then sees the overlay merged on top of the saved-buffer graph view. Idempotent — calling twice with the same workspace is a no-op."), + mcp.WithString("workspace_id", mcp.Description("Workspace slug the overlay belongs to. Optional; defaults to the session's bound workspace.")), + ), + s.handleOverlayRegister, + ) + s.mcpServer.AddTool( + mcp.NewTool("overlay_push", + mcp.WithDescription("Push (or update) a single file overlay onto the current MCP session's overlay. The file at `path` is treated as if it contained `content` for the duration of every subsequent tools/call. Set `deleted: true` to mark a tombstone (queries see the file as missing). Drift detection: when `base_sha` is set, the daemon compares it to the on-disk git blob SHA at apply time and returns an overlay-drift error if they disagree."), + mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute file path.")), + mcp.WithString("content", mcp.Description("Editor-buffer content. Empty + deleted=false is allowed and means \"an empty file\".")), + mcp.WithString("base_sha", mcp.Description("Git blob SHA the editor opened the file at. Empty disables drift detection.")), + mcp.WithBoolean("deleted", mcp.Description("Mark the path as deleted (queries see no file).")), + ), + s.handleOverlayPush, + ) + s.mcpServer.AddTool( + mcp.NewTool("overlay_list", + mcp.WithDescription("List every overlay file currently attached to the calling MCP session. Returns workspace_id, the count of files, and each overlay's path / content length / deleted flag / base_sha. The path and metadata are returned; content bytes are not, to keep the response small."), + ), + s.handleOverlayList, + ) + s.mcpServer.AddTool( + mcp.NewTool("overlay_delete", + mcp.WithDescription("Remove a single overlay file from the calling MCP session's overlay. The next tools/call will see the saved-buffer view for that path."), + mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute path of the overlay to remove.")), + ), + s.handleOverlayDelete, + ) + s.mcpServer.AddTool( + mcp.NewTool("overlay_drop", + mcp.WithDescription("Tear down the calling MCP session's overlay entirely. Equivalent to calling overlay_delete on every attached path. The session remains live; a subsequent overlay_register / overlay_push starts a fresh overlay."), + ), + s.handleOverlayDrop, + ) +} + +// overlaySessionID returns the calling MCP session ID, or a structured +// MCP error result when no session is on the context. Used by every +// overlay_* handler. +func (s *Server) overlaySessionID(ctx context.Context) (string, *mcp.CallToolResult) { + id := SessionIDFromContext(ctx) + if id == "" { + return "", mcp.NewToolResultError("overlay tools require an MCP session — connect via the daemon or set X-Mcp-Session-Id") + } + if s.overlays == nil { + return "", mcp.NewToolResultError("overlay support is not enabled on this server") + } + return id, nil +} + +func (s *Server) handleOverlayRegister(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, errRes := s.overlaySessionID(ctx) + if errRes != nil { + return errRes, nil + } + workspace := req.GetString("workspace_id", "") + if workspace == "" { + // Default to the session's bound workspace if any. The bind + // is filled by the workspace-handshake flow; sessions that + // haven't been handshaked are bound to the empty workspace. + if ws, _, _ := s.sessionScope(ctx); ws != "" { + workspace = ws + } + } + if err := s.overlays.RegisterWithID(id, workspace); err != nil { + if errors.Is(err, daemon.ErrSessionExists) { + return mcp.NewToolResultError(fmt.Sprintf("overlay session is already registered for a different workspace; call overlay_drop first")), nil + } + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText(jsonOK(map[string]any{ + "session_id": id, + "workspace_id": workspace, + })), nil +} + +func (s *Server) handleOverlayPush(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, errRes := s.overlaySessionID(ctx) + if errRes != nil { + return errRes, nil + } + path, err := req.RequireString("path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + overlay := daemon.OverlayFile{ + Path: path, + Content: req.GetString("content", ""), + BaseSHA: req.GetString("base_sha", ""), + Deleted: req.GetBool("deleted", false), + } + // Idempotent register-on-push: an extension that calls + // overlay_push without a prior overlay_register lands on the + // session's default workspace. This matches the HTTP path's + // implicit-register behaviour (POST /v1/overlay/sessions with + // an explicit session_id is the canonical bind path; the + // fallback exists so test harnesses don't need both calls). + if !s.overlays.Has(id) { + workspace, _, _ := s.sessionScope(ctx) + _ = s.overlays.RegisterWithID(id, workspace) + } + // Drift check runs on apply (see overlay.go::overlaySHAMatches); + // passing nil here lets the manager accept the push and defers + // the check to the tool call that needs the overlay. Pushing a + // known-stale overlay is allowed because the editor may push + // the latest buffer before processing a sibling tool's edit; + // the apply pass surfaces the drift then. + if err := s.overlays.Push(id, overlay, nil); err != nil { + switch { + case errors.Is(err, daemon.ErrSessionNotFound): + return mcp.NewToolResultError("overlay session has been dropped — call overlay_register before pushing"), nil + case errors.Is(err, daemon.ErrOverlayDrift): + return mcp.NewToolResultError(err.Error()), nil + default: + return mcp.NewToolResultError(err.Error()), nil + } + } + return mcp.NewToolResultText(jsonOK(map[string]any{ + "path": overlay.Path, + "content_size": len(overlay.Content), + "deleted": overlay.Deleted, + "base_sha": overlay.BaseSHA, + })), nil +} + +func (s *Server) handleOverlayList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, errRes := s.overlaySessionID(ctx) + if errRes != nil { + return errRes, nil + } + ws, files, err := s.overlays.SnapshotFor(id) + if err != nil { + if errors.Is(err, daemon.ErrSessionNotFound) { + return mcp.NewToolResultText(jsonOK(map[string]any{ + "session_id": id, + "workspace_id": "", + "count": 0, + "files": []any{}, + })), nil + } + return mcp.NewToolResultError(err.Error()), nil + } + out := make([]map[string]any, 0, len(files)) + for _, f := range files { + out = append(out, map[string]any{ + "path": f.Path, + "content_size": len(f.Content), + "deleted": f.Deleted, + "base_sha": f.BaseSHA, + }) + } + return mcp.NewToolResultText(jsonOK(map[string]any{ + "session_id": id, + "workspace_id": ws, + "count": len(out), + "files": out, + })), nil +} + +func (s *Server) handleOverlayDelete(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, errRes := s.overlaySessionID(ctx) + if errRes != nil { + return errRes, nil + } + path, err := req.RequireString("path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if err := s.overlays.Delete(id, path); err != nil { + if errors.Is(err, daemon.ErrSessionNotFound) { + return mcp.NewToolResultError("overlay session has been dropped"), nil + } + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText(jsonOK(map[string]any{ + "path": path, + "ok": true, + })), nil +} + +func (s *Server) handleOverlayDrop(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, errRes := s.overlaySessionID(ctx) + if errRes != nil { + return errRes, nil + } + s.overlays.Drop(id) + return mcp.NewToolResultText(jsonOK(map[string]any{ + "session_id": id, + "ok": true, + })), nil +} + +// jsonOK marshals v to a compact JSON string. Tool handlers use it to +// build a text body that's both machine-readable and gcx-friendly. +func jsonOK(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf(`{"ok":false,"error":%q}`, err.Error()) + } + return string(b) +} diff --git a/internal/mcp/tools_workspace.go b/internal/mcp/tools_workspace.go index 66f83cb..4435c03 100644 --- a/internal/mcp/tools_workspace.go +++ b/internal/mcp/tools_workspace.go @@ -14,7 +14,7 @@ import ( // discover what `repo` values are legal before issuing any // scope: repo or scope: fan-out call. func (s *Server) registerWorkspaceTools() { - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("list_repos", mcp.WithDescription( "Lists every project in the active workspace. Workspace-scope tool: do not pass `repo`. "+ @@ -26,7 +26,7 @@ func (s *Server) registerWorkspaceTools() { s.handleListRepos, ) - s.mcpServer.AddTool( + s.addTool( mcp.NewTool("workspace_info", mcp.WithDescription( "Returns workspace identity: bind mode, root directory, marker contents, the auto-discovered member set, and any unknown marker keys. "+ diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 06e92a1..ac628cb 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -206,7 +206,23 @@ func (h *Handler) handleRepos(w http.ResponseWriter, _ *http.Request) { // --- /v1/overlay/* --- // handleOverlayRegister handles POST /v1/overlay/sessions. -// Body: {"workspace_id": ""}. Response: {"session_id": "..."}. +// +// Body (all fields optional): { +// +// "workspace_id": "", +// "session_id": "" // bind to a known MCP session +// +// } +// +// When `session_id` is supplied, the session is registered under that +// ID instead of a freshly minted one — this is how an MCP client binds +// its overlay session to its MCP session ID, so subsequent tools/call +// frames from the same MCP session automatically see the overlay +// (the MCP tool middleware reads SessionIDFromContext and resolves the +// overlay by that ID). Idempotent: registering twice with the same +// (id, workspace) tuple is a no-op; mismatched workspaces return 409. +// +// Response: {"session_id": "...", "workspace_id": "..."}. func (h *Handler) handleOverlayRegister(w http.ResponseWriter, r *http.Request) { if h.overlays == nil { http.Error(w, "overlay support not enabled on this server", http.StatusServiceUnavailable) @@ -214,6 +230,7 @@ func (h *Handler) handleOverlayRegister(w http.ResponseWriter, r *http.Request) } var body struct { WorkspaceID string `json:"workspace_id"` + SessionID string `json:"session_id"` } if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -221,7 +238,19 @@ func (h *Handler) handleOverlayRegister(w http.ResponseWriter, r *http.Request) return } } - id := h.overlays.Register(body.WorkspaceID) + id := body.SessionID + if id == "" { + id = h.overlays.Register(body.WorkspaceID) + } else { + if err := h.overlays.RegisterWithID(id, body.WorkspaceID); err != nil { + if errors.Is(err, daemon.ErrSessionExists) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } WriteJSON(w, http.StatusCreated, map[string]any{ "session_id": id, "workspace_id": body.WorkspaceID, diff --git a/internal/server/handler.go b/internal/server/handler.go index 76254c0..d4ccaca 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -21,6 +21,7 @@ import ( "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" + gortexmcp "github.com/zzet/gortex/internal/mcp" "github.com/zzet/gortex/internal/server/hub" "go.uber.org/zap" ) @@ -362,7 +363,27 @@ func (h *Handler) handleToolCall(w http.ResponseWriter, r *http.Request) { }, } - result, err := tool.Handler(r.Context(), mcpReq) + // Overlay session binding for the HTTP transport. The standard + // `Mcp-Session-Id` header (set by mcp-go's Streamable HTTP + // client) is preferred; a gortex-specific + // `X-Gortex-Overlay-Session` header takes precedence when + // callers want to scope an overlay to a session ID that differs + // from their MCP transport session (e.g. a CI harness that + // orchestrates several overlay scopes from one connection). A + // `?session_id=` query parameter is the final fallback so curl / + // integration tests can attach overlays without setting HTTP + // headers. The session ID flows through gortexmcp.WithSessionID + // so the MCP overlay middleware (overlay.go::wrapToolHandler) + // finds the right overlay snapshot. + ctx := r.Context() + if sid := firstNonEmpty( + r.Header.Get("X-Gortex-Overlay-Session"), + r.Header.Get("Mcp-Session-Id"), + r.URL.Query().Get("session_id"), + ); sid != "" { + ctx = gortexmcp.WithSessionID(ctx, sid) + } + result, err := tool.Handler(ctx, mcpReq) if err != nil { h.logger.Error("tool call failed", zap.String("tool", toolName), From 151bf501284b36e3fd55f575384dabf81ae8e4bd Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 15 May 2026 14:55:45 +0200 Subject: [PATCH 2/4] graph, mcp, query: shadow-graph view for editor-buffer overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor extensions push in-flight (unsaved) buffers as overlays and every subsequent tools/call from the same MCP session reads through a per-request shadow view layered on top of the immutable base graph. Base is never mutated by overlay flow, so concurrent sessions each see their own view, the file watcher's reindex passes don't race with overlay queries, and cross-file edges from non-overlaid callers into overlaid symbols are preserved. - internal/graph/reader.go introduces a Reader interface satisfied by both *Graph (base, mutable through the indexer) and *OverlaidView. Tool handlers, query.Engine, and the rerank Context all consult this interface instead of *Graph directly. - internal/graph/overlay.go defines OverlayLayer (per-session parsed overlay state: nodes, resolved edges, tombstones, name-removed set) and OverlaidView (intercepts GetNode / GetFileNodes / GetOutEdges / GetInEdges / AllNodes / AllEdges / FindNodesByName to substitute the overlay's content for overlaid files while base reads pass through unchanged). - internal/mcp/overlay_view.go is the per-request builder. The layer is parsed once per (sessionID, content-hash) tuple by running the same per-language extractors the indexer uses, applying the repo prefix to match base's ID shape, then running a local resolver pass that rewrites unresolved::* placeholders against (overlay ∪ base). The view is attached to ctx via WithOverlayView; tool handlers read it via s.readerFor(ctx) / s.engineFor(ctx). - query.Engine.WithReader returns a shallow clone that swaps the reader, so engine-level walks (FindUsages, GetCallers, CallChain, Dependencies, Dependents, hierarchies) all see the overlay without per-method changes. - internal/mcp/overlay.go's wrapToolHandler is the non-mutating middleware: builds the view per request, surfaces drift as a structured "overlay base SHA mismatch" tool error, falls through as a one-map-lookup no-op when the session has no overlay. - readLinesForCtx consults the overlay buffer before disk so get_symbol_source / get_editing_context / smart_context surface the editor view, not the saved file. - New MCP tools: overlay_register, overlay_push, overlay_list, overlay_delete, overlay_drop, and compare_with_overlay — the last runs find_usages / get_callers / get_call_chain / get_dependencies / get_dependents against base AND overlay simultaneously and returns added / removed / common ID sets, the side-by-side diff the shadow design makes possible. - 65 internal AddTool callsites migrated to s.addTool (wrapping middleware); s.engine usages in handler bodies migrated to s.engineFor(ctx); fetchAndMergeBM25 / topCallersForVerify / winnowSymbols / findTestFiles refactored to take a Reader-aware engine so they're overlay-aware without a per-callsite migration. - HTTP transport reads the session from Mcp-Session-Id (preferred), X-Gortex-Overlay-Session, or ?session_id=; the register endpoint accepts an explicit session_id so HTTP clients can bind to a known MCP session. - 16 tests under -race: 5 daemon OverlayManager tests (register/snapshot/drift/sweep) and 11 MCP end-to-end including TestOverlay_BaseGraphIsImmutable, TestOverlay_FindUsagesPreservesCrossFileCallers, TestOverlay_TwoSessionsIsolated, TestOverlay_OverlayAndBaseSessionsIsolated, drift surfacing, BaseSHA match, no-session fast-path, deletion tombstones, MCP register/push/list round-trip, compare_with_overlay diff surface. - CLAUDE.md, internal/agents/instructions.go adapter template, and README.md gain a "Live Editor Buffers (Shadow-Graph Overlay Sessions)" section. --- CLAUDE.md | 18 +- README.md | 11 +- internal/agents/instructions.go | 9 +- internal/graph/overlay.go | 603 ++++++++++++++++++++++ internal/graph/reader.go | 50 ++ internal/indexer/indexer.go | 57 +- internal/indexer/indexfromcontent_test.go | 127 ----- internal/indexer/multi.go | 44 +- internal/mcp/overlay.go | 255 ++------- internal/mcp/overlay_e2e_test.go | 413 +++++++++++---- internal/mcp/overlay_view.go | 592 +++++++++++++++++++++ internal/mcp/prompts.go | 23 +- internal/mcp/server.go | 25 +- internal/mcp/tools_coding.go | 135 +++-- internal/mcp/tools_core.go | 38 +- internal/mcp/tools_enhancements.go | 16 +- internal/mcp/tools_overlay.go | 5 + internal/mcp/tools_overlay_diff.go | 158 ++++++ internal/mcp/tools_planning.go | 2 +- internal/mcp/tools_search_assist.go | 12 +- internal/mcp/tools_search_assist_test.go | 4 +- internal/mcp/tools_winnow.go | 28 +- internal/mcp/tools_winnow_test.go | 14 +- internal/query/engine.go | 29 +- internal/search/rerank/context.go | 11 +- 25 files changed, 2035 insertions(+), 644 deletions(-) create mode 100644 internal/graph/overlay.go create mode 100644 internal/graph/reader.go delete mode 100644 internal/indexer/indexfromcontent_test.go create mode 100644 internal/mcp/overlay_view.go create mode 100644 internal/mcp/tools_overlay_diff.go diff --git a/CLAUDE.md b/CLAUDE.md index 3c863b9..0a41133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,21 +229,27 @@ The `find_clones` MCP tool surfaces near-duplicate ("clone") function/method clu | Scoping a query to a project | Pass `project` param to any query tool | | Filtering by reference tag | Pass `ref` param to any query tool | -### Live Editor Buffers (Overlay Sessions) +### Live Editor Buffers (Shadow-Graph Overlay Sessions) -Editor extensions push in-flight buffers — files the user has edited but not yet saved — into the daemon as **overlays**. Once an overlay is attached to a session, every subsequent `tools/call` from that session sees the overlaid view: graph-walking tools (`find_usages`, `get_call_chain`, `get_file_summary`, `analyze`, …) and source-reading tools (`get_symbol_source`, `get_editing_context`, …) all read the editor-buffer version of the file instead of the saved-buffer one. +Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex composes a per-request **shadow view** (`graph.OverlaidView`) on top of the immutable base graph and threads it through the tool dispatch context. Every subsequent `tools/call` from the same MCP session reads through the shadow view — graph-walking tools (`find_usages`, `get_call_chain`, `get_file_summary`, `analyze`, …) and source-reading tools (`get_symbol_source`, `get_editing_context`, …) all see the editor-buffer state. + +**Load-bearing invariant: the base graph is never mutated by overlay flow.** The overlay layer is built per request, parsed once per (session, content-hash) tuple, and discarded with the request. Consequences: + +- **Multi-tenant safe.** Sessions A1, A2 (same user) and B (different user) can run concurrently against the same daemon; each sees its own view. The file watcher's reindex passes mutate base but the in-flight shadow view captured a stable base snapshot at request start. +- **Non-destructive.** Cross-file edges from non-overlaid files INTO overlaid file symbols (`Caller→Target`) survive overlay processing because base's edges are intact. The in-place-mutation approach (which Gortex briefly experimented with internally) lost those edges. +- **Diffable.** `compare_with_overlay` runs a query against both base and overlay and returns the delta — proving the two views are simultaneously available. | Instead of... | You MUST use... | |---------------------------------------|------------------------------------------| -| Asking the user to save before a query | `overlay_register` then `overlay_push` — pushes one editor buffer; subsequent tool calls see the overlay merged on top of the on-disk graph view | -| Pushing keystroke-by-keystroke through HTTP | `overlay_push` over the same MCP transport you're already on — no extra socket, no extra auth | +| Asking the user to save before a query | `overlay_register` then `overlay_push` — pushes one editor buffer; subsequent tool calls see the overlay layered on top of base | | Listing what an extension has staged | `overlay_list` — every path / size / deleted flag / base SHA for the current session | | Cancelling a single overlay | `overlay_delete` with `path` — saved-buffer view returns for that path on the next tool call | | Tearing down an overlay session | `overlay_drop` — discards every overlay attached to the session, in one call | +| Previewing impact of an unsaved edit | `compare_with_overlay` with `kind: find_usages / get_callers / get_call_chain / get_dependencies / get_dependents` — runs the query against base and overlay simultaneously, returns added / removed / common ID sets | -**Drift detection.** Pass an editor-captured git blob SHA as `base_sha` on `overlay_push`. When the next tool call needs that path, the daemon compares it to the on-disk hash; if they disagree (a sibling tool, a git operation, or another editor saved over the file) the tool call returns a structured `overlay base SHA mismatch` error so the client knows to re-read the file and resubmit a fresh overlay. Drift is the load-bearing signal that prevents the daemon from folding a stale buffer into queries. +**Drift detection.** Pass an editor-captured git blob SHA as `base_sha` on `overlay_push`. When the next tool call needs that path, Gortex compares it to the on-disk hash; if they disagree (a sibling tool, a git operation, or another editor saved over the file) the tool call returns a structured `overlay base SHA mismatch` error so the client knows to re-read the file and resubmit a fresh overlay. -**Deletion overlays.** Push with `deleted: true` to model "this file is going away" — the symbols inside it vanish from the graph for the duration of the session, so the user can preview the impact of a delete without staging it. +**Deletion overlays.** Push with `deleted: true` to model "this file is going away" — the symbols inside it vanish from the shadow view (but are untouched in base), so the user can preview the impact of a delete without staging it. **HTTP transport.** `gortex server` exposes the same surface at `POST /v1/overlay/sessions` (optional `session_id` binds an overlay to a known MCP session), `PUT /v1/overlay/sessions/{id}/files`, `DELETE /v1/overlay/sessions/{id}/files`, `GET /v1/overlay/sessions/{id}/files`, `DELETE /v1/overlay/sessions/{id}`. The `/v1/tools/` HTTP entry point reads the active session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=` (test fallback). diff --git a/README.md b/README.md index 07c1518..dd4d720 100644 --- a/README.md +++ b/README.md @@ -439,18 +439,21 @@ Wired across every running language server (gopls, tsserver, pyright, rust-analy | `list_repos` | List every project/repo in the active workspace | | `workspace_info` | Workspace identity — bind mode, root directory, marker contents, discovered member set | -### Live Editor Buffers (Overlay Sessions) -Editor extensions push in-flight (unsaved) buffers as **overlays**; every subsequent `tools/call` from the same MCP session sees the overlaid view. Graph-walking tools (`find_usages`, `get_call_chain`, `analyze`, …) and source-reading tools (`get_symbol_source`, `get_editing_context`, …) all read the editor-buffer version without per-tool changes. Pass an editor-captured git blob SHA as `base_sha` for drift detection; push with `deleted: true` to preview a deletion. Sessions auto-expire after 5 minutes of inactivity. +### Live Editor Buffers (Shadow-Graph Overlay Sessions) +Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex composes a per-request **shadow view** on top of the immutable base graph and threads it through the tool dispatch context — every subsequent `tools/call` from the same MCP session reads through the shadow. Graph-walking tools (`find_usages`, `get_call_chain`, `analyze`, …) and source-reading tools (`get_symbol_source`, `get_editing_context`, …) all see the editor-buffer state without per-tool changes. + +**Base is never mutated by overlay flow.** Concurrent sessions (multiple users, multiple windows of the same user) each see their own view; the file watcher's reindex passes don't race with overlay queries; cross-file edges from non-overlaid files into overlaid symbols (`Caller → Target`) are preserved. | Tool | Description | |------|-------------| | `overlay_register` | Bind an overlay session to the current MCP session ID (idempotent) | -| `overlay_push` | Push (or update) a single file overlay | +| `overlay_push` | Push (or update) a single file overlay; `base_sha` enables drift detection, `deleted: true` previews a delete | | `overlay_list` | List every overlay attached to the session — path / size / deleted / base_sha | | `overlay_delete` | Remove one overlay from the session | | `overlay_drop` | Tear down the session and discard every overlay | +| `compare_with_overlay` | Run `find_usages` / `get_callers` / `get_call_chain` / `get_dependencies` / `get_dependents` against base AND overlay; returns added / removed / common ID sets | -HTTP transport mirrors the surface at `/v1/overlay/sessions/*`; the `/v1/tools/` entry point reads the overlay session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=`. +HTTP transport mirrors the surface at `/v1/overlay/sessions/*`; the `/v1/tools/` entry point reads the overlay session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=`. Sessions auto-expire after 5 minutes of inactivity. ## MCP Resources (16) diff --git a/internal/agents/instructions.go b/internal/agents/instructions.go index 735b0e4..78716ce 100644 --- a/internal/agents/instructions.go +++ b/internal/agents/instructions.go @@ -371,9 +371,11 @@ The ` + "`flow_between`" + ` and ` + "`taint_paths`" + ` MCP tools answer **"whe | Scoping a query to a project | Pass ` + "`project`" + ` param to any query tool | | Filtering by reference tag | Pass ` + "`ref`" + ` param to any query tool | -### Live Editor Buffers (Overlay Sessions) +### Live Editor Buffers (Shadow-Graph Overlay Sessions) -Editor extensions push in-flight buffers — files the user has edited but not yet saved — as **overlays**. After ` + "`overlay_register`" + ` and one or more ` + "`overlay_push`" + ` calls, every subsequent ` + "`tools/call`" + ` from the same MCP session reads the editor-buffer view (overlay merged on top of the on-disk graph). No per-tool changes are needed: graph-walking tools (` + "`find_usages`" + `, ` + "`get_call_chain`" + `, ` + "`get_file_summary`" + `, ` + "`analyze`" + `, …) and source-reading tools (` + "`get_symbol_source`" + `, ` + "`get_editing_context`" + `, …) all see the overlay. +Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex composes a per-request **shadow view** on top of the immutable base graph and threads it through the tool dispatch context. After ` + "`overlay_register`" + ` and one or more ` + "`overlay_push`" + ` calls, every subsequent ` + "`tools/call`" + ` from the same MCP session reads through the shadow view — graph-walking tools (` + "`find_usages`" + `, ` + "`get_call_chain`" + `, ` + "`get_file_summary`" + `, …) and source-reading tools (` + "`get_symbol_source`" + `, ` + "`get_editing_context`" + `, …) all see the overlay. + +**Load-bearing invariant: base is never mutated by overlay flow.** Concurrent sessions (A1 / A2 / B) each see their own view; the file watcher's reindex passes don't race with overlay queries; cross-file edges from non-overlaid files into overlaid symbols are preserved because base's edges are untouched. | Instead of... | You MUST use... | |---------------------------------------|------------------------------------------| @@ -381,8 +383,9 @@ Editor extensions push in-flight buffers — files the user has edited but not y | Listing what an extension has staged | ` + "`overlay_list`" + ` — path / size / deleted / base SHA for the current session | | Cancelling a single overlay | ` + "`overlay_delete`" + ` with ` + "`path`" + ` — saved-buffer view returns for that path | | Tearing down an overlay session | ` + "`overlay_drop`" + ` — discards every overlay attached to the session in one call | +| Previewing impact of an unsaved edit | ` + "`compare_with_overlay`" + ` with ` + "`kind: find_usages / get_callers / get_call_chain / get_dependencies / get_dependents`" + ` — runs the query against base and overlay simultaneously, returns added / removed / common ID sets | -Pass an editor-captured git blob SHA as ` + "`base_sha`" + ` on ` + "`overlay_push`" + ` to enable drift detection: when the next tool call needs that path, the daemon compares ` + "`base_sha`" + ` to the on-disk hash and returns ` + "`overlay base SHA mismatch`" + ` if they disagree, so the client knows to re-read and resubmit. Push with ` + "`deleted: true`" + ` to model a tombstone — the file's symbols vanish from the graph for the session's lifetime. Sessions auto-expire after 5 minutes of inactivity. HTTP transport mirrors the surface at ` + "`/v1/overlay/sessions/*`" + `; the ` + "`/v1/tools/`" + ` entry reads the active session from ` + "`Mcp-Session-Id`" + ` / ` + "`X-Gortex-Overlay-Session`" + ` / ` + "`?session_id=`" + `. +Pass an editor-captured git blob SHA as ` + "`base_sha`" + ` on ` + "`overlay_push`" + ` to enable drift detection: when the next tool call needs that path, Gortex compares ` + "`base_sha`" + ` to the on-disk hash and returns ` + "`overlay base SHA mismatch`" + ` if they disagree, so the client knows to re-read and resubmit. Push with ` + "`deleted: true`" + ` to model a tombstone — the file's symbols vanish from the shadow view (but are untouched in base). Sessions auto-expire after 5 minutes of inactivity. HTTP transport mirrors the surface at ` + "`/v1/overlay/sessions/*`" + `; the ` + "`/v1/tools/`" + ` entry reads the active session from ` + "`Mcp-Session-Id`" + ` / ` + "`X-Gortex-Overlay-Session`" + ` / ` + "`?session_id=`" + `. ### MCP Resources diff --git a/internal/graph/overlay.go b/internal/graph/overlay.go new file mode 100644 index 0000000..fbc26f7 --- /dev/null +++ b/internal/graph/overlay.go @@ -0,0 +1,603 @@ +package graph + +import ( + "sort" + "strings" + "sync" +) + +// OverlayLayer is one MCP session's parsed editor-buffer state. It +// holds the nodes and edges that the overlay introduces (or hides via +// tombstones) on top of an immutable base graph. The layer is built +// once per (session, content-hash) tuple by the MCP overlay middleware +// (`internal/mcp/overlay_view.go::buildOverlayLayer`) and is consulted +// read-only by `OverlaidView`. +// +// **Identity is preserved.** Gortex node IDs are derived from +// `file::symbol` paths, so a symbol that exists in both the on-disk +// and overlay versions of a file ends up with the same ID — the +// view substitutes the overlay's version transparently. New overlay +// symbols (a function the user just typed) get IDs that don't exist +// in base; deleted symbols (removed from the buffer) simply aren't in +// the layer's per-file node list. +// +// The layer is immutable after construction. The middleware never +// mutates it once the View is in flight; the base graph is never +// mutated by overlay flow at all. This is what makes the design +// safe for concurrent multi-session deployments — no shared mutable +// state between sessions or between an overlay-active session and a +// non-overlay session. +type OverlayLayer struct { + // Files covered by the overlay. The key is the file's graph path + // (repo-prefixed in multi-repo mode). Presence in this map means + // "the View should hide base's view of this path" — either to + // replace it with overlay content (entries[path] != nil) or to + // tombstone it (entries[path].Deleted). + entries map[string]*overlayFileEntry + + // nodeByID lets GetNode hit a single map lookup. Holds every + // non-tombstoned overlay node across every overlay file. + nodeByID map[string]*Node + + // outEdges maps each overlay-introduced source node ID to its + // resolved outgoing edges. Filled by the local resolver pass at + // layer construction. + outEdges map[string][]*Edge + + // inEdges is the reverse index of outEdges keyed by target ID, + // so OverlaidView.GetInEdges can merge overlay-originating + // edges with base in-edges in O(1). + inEdges map[string][]*Edge + + // nodesByName/Qual index overlay nodes for FindNodesByName / + // GetNodeByQualName fast paths. + nodesByName map[string][]*Node + nodesByQual map[string]*Node + + // nameRemoved is the set of (name → IDs from base that are no + // longer present under the View). FindNodesByName uses this to + // filter base hits whose enclosing file is overlaid but whose + // id disappeared from the overlay's node list. + nameRemoved map[string]map[string]bool +} + +// overlayFileEntry carries one file's overlay state inside the +// layer. Deleted=true is the tombstone variant — no nodes, no edges. +type overlayFileEntry struct { + Path string + Deleted bool + Nodes []*Node +} + +// NewOverlayLayer constructs an empty layer. Callers build it up via +// AddFile / AddNode / AddEdge during the per-request layer-build +// pass, then freeze it by handing it to NewOverlaidView. After that +// point the layer is treated as immutable; the View never writes +// back. +func NewOverlayLayer() *OverlayLayer { + return &OverlayLayer{ + entries: make(map[string]*overlayFileEntry), + nodeByID: make(map[string]*Node), + outEdges: make(map[string][]*Edge), + inEdges: make(map[string][]*Edge), + nodesByName: make(map[string][]*Node), + nodesByQual: make(map[string]*Node), + nameRemoved: make(map[string]map[string]bool), + } +} + +// MarkFile registers an overlay file. Call once per overlay path +// before AddNode / AddEdge for that file. `deleted` true means the +// path is a tombstone — the View hides base's view of the path +// entirely, returning no nodes from GetFileNodes and treating the +// path's node IDs as non-existent for GetNode. +func (l *OverlayLayer) MarkFile(graphPath string, deleted bool) { + l.entries[graphPath] = &overlayFileEntry{Path: graphPath, Deleted: deleted} +} + +// AddNode attaches one parsed overlay node to the layer. Must be +// called after MarkFile for the node's file. Idempotent on (graphPath, +// node ID) — second add silently replaces. +func (l *OverlayLayer) AddNode(graphPath string, n *Node) { + if n == nil { + return + } + entry, ok := l.entries[graphPath] + if !ok { + entry = &overlayFileEntry{Path: graphPath} + l.entries[graphPath] = entry + } + if entry.Deleted { + // Tombstone: silently drop. Caller bug — but cheap to absorb. + return + } + entry.Nodes = append(entry.Nodes, n) + l.nodeByID[n.ID] = n + if n.Name != "" { + l.nodesByName[n.Name] = append(l.nodesByName[n.Name], n) + } + if n.QualName != "" { + l.nodesByQual[n.QualName] = n + } +} + +// AddEdge attaches one resolved overlay edge. The local-resolver +// pass at layer construction is expected to have rewritten any +// `unresolved::*` placeholders to point at concrete (overlay or +// base) node IDs before calling this; edges still carrying the +// placeholder are kept verbatim so OverlaidView.GetOutEdges still +// surfaces them — query tools can decide how to handle them, just +// like base's resolver-skipped edges. +func (l *OverlayLayer) AddEdge(e *Edge) { + if e == nil { + return + } + l.outEdges[e.From] = append(l.outEdges[e.From], e) + l.inEdges[e.To] = append(l.inEdges[e.To], e) +} + +// MarkRemoved tells the layer that a base node ID is hidden by the +// overlay even though the overlay didn't re-emit it (a symbol the +// user deleted from the buffer). FindNodesByName uses this to filter +// stale base hits. +func (l *OverlayLayer) MarkRemoved(baseName, baseID string) { + if baseName == "" || baseID == "" { + return + } + set, ok := l.nameRemoved[baseName] + if !ok { + set = make(map[string]bool) + l.nameRemoved[baseName] = set + } + set[baseID] = true +} + +// HasFile reports whether the overlay covers a particular graph path +// (either with replacement content or as a tombstone). The View uses +// this to decide whether to consult overlay or base for the path's +// reads. +func (l *OverlayLayer) HasFile(graphPath string) bool { + if l == nil { + return false + } + _, ok := l.entries[graphPath] + return ok +} + +// IsTombstone reports whether the overlay marks the path as deleted. +func (l *OverlayLayer) IsTombstone(graphPath string) bool { + if l == nil { + return false + } + e := l.entries[graphPath] + return e != nil && e.Deleted +} + +// FilePaths returns the sorted list of overlay-covered paths. Used +// by analyzers / the diff tool to enumerate the overlay's footprint. +func (l *OverlayLayer) FilePaths() []string { + if l == nil { + return nil + } + out := make([]string, 0, len(l.entries)) + for p := range l.entries { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// HasNode reports whether the overlay layer carries a node with this +// ID. Used by the local-resolver pass in the mcp layer to drop base +// hits whose file is overlaid but whose specific ID wasn't kept by +// the overlay (i.e. the user deleted that symbol from the buffer). +func (l *OverlayLayer) HasNode(id string) bool { + if l == nil { + return false + } + _, ok := l.nodeByID[id] + return ok +} + +// NodesByName returns the overlay-introduced nodes with the given +// short name. Empty slice when none. Used by the local-resolver +// pass. +func (l *OverlayLayer) NodesByName(name string) []*Node { + if l == nil { + return nil + } + src := l.nodesByName[name] + out := make([]*Node, len(src)) + copy(out, src) + return out +} + +// OutEdgesByFromAll returns a snapshot of the layer's outgoing-edge +// map keyed by source ID. The resolver pass iterates this to rewrite +// `unresolved::*` placeholders. The returned map shares its slices +// with the layer (resolver mutates Edge.To in place); the map keys +// are stable for the snapshot. +func (l *OverlayLayer) OutEdgesByFromAll() map[string][]*Edge { + if l == nil { + return nil + } + out := make(map[string][]*Edge, len(l.outEdges)) + for k, v := range l.outEdges { + out[k] = v + } + return out +} + +// RebuildInEdges rebuilds the reverse-index map after the local +// resolver pass mutates Edge.To in place. Cheap: O(#overlay edges). +func (l *OverlayLayer) RebuildInEdges() { + if l == nil { + return + } + l.inEdges = make(map[string][]*Edge, len(l.outEdges)) + for _, edges := range l.outEdges { + for _, e := range edges { + l.inEdges[e.To] = append(l.inEdges[e.To], e) + } + } +} + +// nodesForFile returns the overlay nodes for a path (empty for +// tombstones). Internal — used by OverlaidView. +func (l *OverlayLayer) nodesForFile(graphPath string) []*Node { + if l == nil { + return nil + } + e := l.entries[graphPath] + if e == nil || e.Deleted { + return nil + } + out := make([]*Node, len(e.Nodes)) + copy(out, e.Nodes) + return out +} + +// OverlaidView composes an immutable base Reader with a per-session +// overlay layer. Every read path consults the layer first for paths +// the overlay covers; falls through to base otherwise. The base is +// never mutated; the layer is built once per request and discarded +// with the request. This means concurrent sessions — overlay-active +// or not — each see their own consistent view, and the file watcher's +// reindex passes (which mutate base) don't corrupt overlay queries. +type OverlaidView struct { + base Reader + layer *OverlayLayer + + // statsOnce caches the (potentially expensive) Stats walk so + // repeated calls within one request don't pay the AllNodes / + // AllEdges cost twice. + statsOnce sync.Once + stats GraphStats +} + +// NewOverlaidView builds a view. If layer is nil the view is a pure +// pass-through and consumers pay no overlay overhead. +func NewOverlaidView(base Reader, layer *OverlayLayer) *OverlaidView { + return &OverlaidView{base: base, layer: layer} +} + +// Base exposes the underlying base reader. The diff tool reads +// against (view.Base()) and against (view) directly to compute the +// delta induced by the overlay. +func (v *OverlaidView) Base() Reader { return v.base } + +// Layer exposes the per-session overlay layer (nil when none). +// Diagnostic / debug tools use it to introspect what the overlay +// covers. +func (v *OverlaidView) Layer() *OverlayLayer { return v.layer } + +// IDFile returns the file path encoded in a Gortex node ID, or "" if +// the id isn't file-anchored. Gortex IDs follow the pattern +// `::[.member][#param:name]` so the file prefix is +// the substring before the first `::`. Module / package / virtual +// nodes use other prefixes that won't match an overlay path. +func IDFile(id string) string { + if id == "" { + return "" + } + if i := strings.Index(id, "::"); i > 0 { + return id[:i] + } + return "" +} + +// nodeBelongsToOverlay reports whether an ID's file is covered by +// the layer. +func (v *OverlaidView) nodeBelongsToOverlay(id string) bool { + if v.layer == nil { + return false + } + return v.layer.HasFile(IDFile(id)) +} + +// GetNode returns the overlay's version of a node when the ID +// belongs to an overlaid file, the base node otherwise. Returns nil +// when the symbol exists in base but was removed in the overlay +// (the per-file overlay node list didn't include it). +func (v *OverlaidView) GetNode(id string) *Node { + if v.layer != nil { + if v.nodeBelongsToOverlay(id) { + return v.layer.nodeByID[id] // may be nil — overlay deleted it + } + } + if v.base == nil { + return nil + } + return v.base.GetNode(id) +} + +// GetNodeByQualName: overlay first, then base. Base hits are filtered +// to drop entries whose file is overlaid (the overlay's view wins). +func (v *OverlaidView) GetNodeByQualName(qualName string) *Node { + if v.layer != nil { + if n := v.layer.nodesByQual[qualName]; n != nil { + return n + } + } + if v.base == nil { + return nil + } + n := v.base.GetNodeByQualName(qualName) + if n != nil && v.layer != nil && v.layer.HasFile(IDFile(n.ID)) { + // Base hit landed in an overlaid file but the overlay didn't + // re-emit a node with this qualified name → it's gone. + return nil + } + return n +} + +// FindNodesByName merges base hits (filtered to drop nodes in +// overlaid files unless the overlay re-emitted them) with overlay +// hits. Order is overlay-first, then base — callers that picked +// "first match" semantics get the overlay version automatically. +func (v *OverlaidView) FindNodesByName(name string) []*Node { + var out []*Node + if v.layer != nil { + out = append(out, v.layer.nodesByName[name]...) + } + if v.base == nil { + return out + } + for _, n := range v.base.FindNodesByName(name) { + if v.layer != nil { + if v.layer.HasFile(IDFile(n.ID)) { + // Overlaid file. Surface base node only if the + // overlay re-emitted it (same ID). Otherwise the + // overlay has either replaced it (already in `out` + // via the layer) or deleted it. + if _, kept := v.layer.nodeByID[n.ID]; !kept { + continue + } + // kept — but it's already in out via the layer path. + continue + } + if v.layer.nameRemoved[name] != nil && v.layer.nameRemoved[name][n.ID] { + continue + } + } + out = append(out, n) + } + return out +} + +// GetFileNodes: if the path is overlaid, return overlay's nodes +// (empty for tombstones). Otherwise pass through to base. +func (v *OverlaidView) GetFileNodes(filePath string) []*Node { + if v.layer != nil && v.layer.HasFile(filePath) { + return v.layer.nodesForFile(filePath) + } + if v.base == nil { + return nil + } + return v.base.GetFileNodes(filePath) +} + +// GetRepoNodes filters base's per-repo node list by dropping nodes +// whose file is overlaid (unless the overlay re-emitted them) and +// appending the overlay's nodes for any overlaid file inside the +// requested repo prefix. +func (v *OverlaidView) GetRepoNodes(repoPrefix string) []*Node { + if v.base == nil { + return nil + } + baseNodes := v.base.GetRepoNodes(repoPrefix) + if v.layer == nil { + return baseNodes + } + out := make([]*Node, 0, len(baseNodes)) + for _, n := range baseNodes { + if v.layer.HasFile(IDFile(n.ID)) { + // File is overlaid. Surface only if the overlay + // re-emitted this exact ID; otherwise it's hidden. + if v.layer.nodeByID[n.ID] == nil { + continue + } + } + out = append(out, n) + } + for _, path := range v.layer.FilePaths() { + if !strings.HasPrefix(path, repoPrefix+"/") && path != repoPrefix { + continue + } + out = append(out, v.layer.nodesForFile(path)...) + } + return out +} + +// GetOutEdges: when the source node's file is overlaid, use the +// overlay's resolved out-edges. Otherwise return base's edges but +// drop any whose target points into an overlaid file at a node ID +// the overlay no longer carries (target deleted in buffer). +func (v *OverlaidView) GetOutEdges(nodeID string) []*Edge { + if v.layer != nil && v.nodeBelongsToOverlay(nodeID) { + src := v.layer.outEdges[nodeID] + out := make([]*Edge, len(src)) + copy(out, src) + return out + } + if v.base == nil { + return nil + } + edges := v.base.GetOutEdges(nodeID) + if v.layer == nil { + return edges + } + out := edges[:0:0] + for _, e := range edges { + if v.layer.HasFile(IDFile(e.To)) { + if v.layer.nodeByID[e.To] == nil { + continue // target deleted in overlay + } + } + out = append(out, e) + } + return out +} + +// GetInEdges merges base's incoming edges (filtered to drop those +// originating in overlaid files, since those are replaced by overlay +// versions) with the overlay's in-edges for the same target. +func (v *OverlaidView) GetInEdges(nodeID string) []*Edge { + if v.layer == nil { + if v.base == nil { + return nil + } + return v.base.GetInEdges(nodeID) + } + var out []*Edge + if v.base != nil { + for _, e := range v.base.GetInEdges(nodeID) { + if v.layer.HasFile(IDFile(e.From)) { + // Source is overlaid — the overlay's version of this + // edge wins (or the overlay simply deleted the call). + continue + } + if v.layer.HasFile(IDFile(e.To)) && v.layer.nodeByID[e.To] == nil { + // Target was deleted by the overlay. + continue + } + out = append(out, e) + } + } + out = append(out, v.layer.inEdges[nodeID]...) + return out +} + +// AllNodes returns base's nodes minus nodes in overlaid files, plus +// every node the overlay introduced. Bulk-read consumers (analyzers, +// search reindex, snapshot export) get an overlay-consistent view +// without paying any extra copy beyond the base snapshot's. +func (v *OverlaidView) AllNodes() []*Node { + if v.base == nil { + return nil + } + baseNodes := v.base.AllNodes() + if v.layer == nil { + return baseNodes + } + out := make([]*Node, 0, len(baseNodes)) + for _, n := range baseNodes { + if v.layer.HasFile(IDFile(n.ID)) { + if v.layer.nodeByID[n.ID] == nil { + continue + } + // Else: overlay's version was kept under the same ID; the + // layer's slice will include it below, so skip base's copy + // to avoid duplicates. + continue + } + out = append(out, n) + } + for _, n := range v.layer.nodeByID { + out = append(out, n) + } + return out +} + +// AllEdges returns base's edges minus those involving overlaid +// files, plus every overlay-introduced edge. +func (v *OverlaidView) AllEdges() []*Edge { + if v.base == nil { + return nil + } + baseEdges := v.base.AllEdges() + if v.layer == nil { + return baseEdges + } + out := make([]*Edge, 0, len(baseEdges)) + for _, e := range baseEdges { + if v.layer.HasFile(IDFile(e.From)) || v.layer.HasFile(IDFile(e.To)) { + continue + } + out = append(out, e) + } + for _, edges := range v.layer.outEdges { + out = append(out, edges...) + } + return out +} + +// NodeCount / EdgeCount — derived from base counters adjusted by the +// overlay delta. Cheap enough to recompute per call. +func (v *OverlaidView) NodeCount() int { + if v.base == nil { + return 0 + } + if v.layer == nil { + return v.base.NodeCount() + } + delta := 0 + for path, entry := range v.layer.entries { + baseCount := len(v.base.GetFileNodes(path)) + if entry.Deleted { + delta -= baseCount + continue + } + delta += len(entry.Nodes) - baseCount + } + return v.base.NodeCount() + delta +} + +func (v *OverlaidView) EdgeCount() int { + if v.base == nil { + return 0 + } + if v.layer == nil { + return v.base.EdgeCount() + } + return len(v.AllEdges()) +} + +// Stats is best-effort under overlay: we report base's stats (the +// analyzer-shaped GraphStats requires per-kind / per-language +// breakdowns that the overlay layer doesn't expose cheaply). Caching +// keeps repeated Stats() calls inside one request to a single base +// lookup. +func (v *OverlaidView) Stats() GraphStats { + if v.base == nil { + return GraphStats{} + } + v.statsOnce.Do(func() { + v.stats = v.base.Stats() + }) + return v.stats +} + +// RepoStats — same conservatism as Stats; overlay deltas are +// excluded. The handful of tools that read RepoStats are bookkeeping +// rather than load-bearing, and the overlay-affected nodes are still +// reachable through the per-node read paths. +func (v *OverlaidView) RepoStats() map[string]GraphStats { + if v.base == nil { + return nil + } + return v.base.RepoStats() +} + +// Compile-time assertion that *OverlaidView satisfies Reader. +var _ Reader = (*OverlaidView)(nil) diff --git a/internal/graph/reader.go b/internal/graph/reader.go new file mode 100644 index 0000000..6b760bf --- /dev/null +++ b/internal/graph/reader.go @@ -0,0 +1,50 @@ +package graph + +// Reader is the read-only contract every graph consumer (query +// engine, MCP tool handlers, analyzers, resolver introspection) depends +// on. *Graph satisfies it directly; OverlaidView (overlay.go) wraps a +// base Reader plus a per-session overlay layer to deliver a non- +// mutating shadow view for editor-buffer queries. +// +// Mutation methods (AddNode, AddEdge, EvictFile, ReindexEdge, …) live +// on *Graph and are NOT part of this interface. Only the indexer and +// the resolver mutate; everyone else reads, and reads must go through +// Reader so the same call site transparently switches between base +// and overlay views. +// +// New read methods on *Graph should be added here too — keeping the +// surfaces in sync is what guarantees that a tool migrated to read +// through the Reader will keep working for both base and overlay +// queries. +type Reader interface { + // Identity lookups. + GetNode(id string) *Node + GetNodeByQualName(qualName string) *Node + FindNodesByName(name string) []*Node + + // File / repo scopes. + GetFileNodes(filePath string) []*Node + GetRepoNodes(repoPrefix string) []*Node + + // Edge walks. + GetOutEdges(nodeID string) []*Edge + GetInEdges(nodeID string) []*Edge + + // Bulk reads — used by analyzers (hotspots, cycles, dead code, + // communities, …) and by the embedded query engine's whole-graph + // passes. + AllNodes() []*Node + AllEdges() []*Edge + + // Counters & stats. + NodeCount() int + EdgeCount() int + Stats() GraphStats + RepoStats() map[string]GraphStats +} + +// Compile-time assertion that *Graph satisfies Reader. If a new +// Reader method is added without a corresponding *Graph method (or +// the *Graph signature drifts), the build breaks here rather than at +// a far-away callsite. +var _ Reader = (*Graph)(nil) diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index c2341c5..8dc2afd 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -316,6 +316,13 @@ func (idx *Indexer) Graph() *graph.Graph { return idx.graph } // Search returns the search backend. func (idx *Indexer) Search() search.Backend { return idx.search } +// Registry returns the parser registry shared across this indexer. +// Exposed for the editor-overlay middleware: the overlay layer-build +// pass parses each pushed buffer through the same per-language +// extractor the indexer uses, ensuring overlay-derived nodes match +// base-derived nodes byte-for-byte for the same input. +func (idx *Indexer) Registry() *parser.Registry { return idx.registry } + // ContractRegistry returns the contract registry populated during indexing. func (idx *Indexer) ContractRegistry() *contracts.Registry { return idx.contractRegistry } @@ -1541,31 +1548,7 @@ func (idx *Indexer) IndexFileNoResolve(filePath string) error { return idx.indexFile(filePath, false) } -// IndexFileFromContent indexes a single file using a caller-supplied -// in-memory source — the editor-overlay path. Unlike IndexFile, the -// file at filePath is NEVER read from disk; the bytes in `src` are -// authoritative. Mtime tracking is also skipped, because an overlay is -// a transient view: a subsequent IndexFile call restores the on-disk -// version. Used by the MCP overlay-aware request middleware to merge -// editor buffers into the graph for the duration of one tool call. -// -// The `resolve` parameter mirrors indexFile's: pass true for one-off -// overlay applies (so cross-file references in the overlaid file -// resolve immediately), false when a batch caller will run ResolveAll -// itself. -func (idx *Indexer) IndexFileFromContent(filePath string, src []byte, resolve bool) error { - return idx.indexFileWithSource(filePath, src, resolve, true) -} - func (idx *Indexer) indexFile(filePath string, resolve bool) error { - return idx.indexFileWithSource(filePath, nil, resolve, false) -} - -// indexFileWithSource is the unified parse-and-patch path shared by -// indexFile (reads from disk) and IndexFileFromContent (caller-supplied -// bytes). When overlay=true, src is authoritative and mtime tracking is -// skipped so a subsequent IndexFile call restores the on-disk view. -func (idx *Indexer) indexFileWithSource(filePath string, src []byte, resolve, overlay bool) error { absPath, err := filepath.Abs(filePath) if err != nil { return err @@ -1587,11 +1570,9 @@ func (idx *Indexer) indexFileWithSource(filePath string, src []byte, resolve, ov } idx.graph.EvictFile(graphPath) - if !overlay { - src, err = os.ReadFile(absPath) - if err != nil { - return err - } + src, err := os.ReadFile(absPath) + if err != nil { + return err } lang, ok := idx.registry.DetectLanguage(absPath) @@ -1653,19 +1634,11 @@ func (idx *Indexer) indexFileWithSource(filePath string, src []byte, resolve, ov } } - // Update mtime for this file (uses raw relPath for disk-based - // tracking). Skipped for the overlay path: an overlay is a - // transient editor-buffer view, so stamping its mtime would make - // the next watcher-driven IndexFile call see the on-disk file as - // "older than the overlay" and skip the restore. Leaving mtime - // unchanged guarantees the disk view comes back the next time a - // fsnotify event fires on this path or the overlay revert runs. - if !overlay { - if info, err := os.Stat(absPath); err == nil { - idx.mtimeMu.Lock() - idx.fileMtimes[filepath.ToSlash(relPath)] = info.ModTime().UnixNano() - idx.mtimeMu.Unlock() - } + // Update mtime for this file (uses raw relPath for disk-based tracking). + if info, err := os.Stat(absPath); err == nil { + idx.mtimeMu.Lock() + idx.fileMtimes[filepath.ToSlash(relPath)] = info.ModTime().UnixNano() + idx.mtimeMu.Unlock() } return nil diff --git a/internal/indexer/indexfromcontent_test.go b/internal/indexer/indexfromcontent_test.go deleted file mode 100644 index 4807e6e..0000000 --- a/internal/indexer/indexfromcontent_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package indexer - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/zzet/gortex/internal/graph" -) - -// TestIndexFileFromContent_ReplacesGraphView verifies the editor-overlay -// path: re-indexing a file from in-memory content evicts the disk-derived -// nodes for that file and adds nodes for the overlaid view, *without* -// touching the file on disk or its mtime tracking. After IndexFile is -// called again, the on-disk view returns. -func TestIndexFileFromContent_ReplacesGraphView(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "main.go") - const disk = `package main - -func Disk() {} -` - require.NoError(t, os.WriteFile(path, []byte(disk), 0o644)) - - g := graph.New() - idx := newTestIndexer(g) - idx.SetRootPath(dir) - require.NoError(t, idx.IndexFile(path)) - - // Baseline: disk function present, overlay function absent. - requireSymbolPresent(t, g, "main.go", "Disk") - requireSymbolAbsent(t, g, "main.go", "Overlay") - - const overlay = `package main - -func Disk() {} - -func Overlay() {} -` - require.NoError(t, idx.IndexFileFromContent(path, []byte(overlay), true)) - - // Overlay applied: both functions visible in the graph. - requireSymbolPresent(t, g, "main.go", "Disk") - requireSymbolPresent(t, g, "main.go", "Overlay") - - // Restore: re-index from disk; the overlay-added function disappears. - require.NoError(t, idx.IndexFile(path)) - requireSymbolPresent(t, g, "main.go", "Disk") - requireSymbolAbsent(t, g, "main.go", "Overlay") -} - -// TestIndexFileFromContent_DoesNotStampMtime confirms that overlay -// applies don't poison mtime tracking — otherwise the next watcher -// event would see the on-disk file as "older than the overlay" and -// skip the restore. The post-apply mtime must equal the pre-apply -// mtime (zero, in this test, because the file was never indexed -// from disk before the overlay). -func TestIndexFileFromContent_DoesNotStampMtime(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "x.go") - require.NoError(t, os.WriteFile(path, []byte("package x\n"), 0o644)) - - g := graph.New() - idx := newTestIndexer(g) - idx.SetRootPath(dir) - - before := idx.FileMtimes() - require.Empty(t, before, "pre-condition: no mtime tracking before any index call") - - const overlay = "package x\n\nfunc Added() {}\n" - require.NoError(t, idx.IndexFileFromContent(path, []byte(overlay), true)) - - after := idx.FileMtimes() - require.Empty(t, after, "overlay apply must not stamp mtime") -} - -// TestIndexFileFromContent_DeletionEquivalentViaEvictFile mirrors the -// MCP overlay-middleware deletion path: tombstone overlays are routed -// through EvictFile rather than IndexFileFromContent. Verifying the -// effect here keeps the test surface honest about what the middleware -// actually does for deleted: true overlays. -func TestIndexFileFromContent_DeletionEquivalentViaEvictFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "del.go") - require.NoError(t, os.WriteFile(path, []byte("package del\nfunc Disk() {}\n"), 0o644)) - - g := graph.New() - idx := newTestIndexer(g) - idx.SetRootPath(dir) - require.NoError(t, idx.IndexFile(path)) - requireSymbolPresent(t, g, "del.go", "Disk") - - // Deletion overlay → EvictFile on the absolute path. - idx.EvictFile(path) - requireSymbolAbsent(t, g, "del.go", "Disk") - - // Revert (re-index from disk) brings the symbol back. - require.NoError(t, idx.IndexFile(path)) - requireSymbolPresent(t, g, "del.go", "Disk") -} - -// requireSymbolPresent asserts that the graph contains a symbol with -// the given name in the given relative path. The assertion is -// relative-path-aware: in multi-repo mode the file path is prefixed, -// in single-repo mode it isn't — both cases match. -func requireSymbolPresent(t *testing.T, g *graph.Graph, relPath, name string) { - t.Helper() - require.Truef(t, hasSymbol(g, relPath, name), - "expected symbol %q in file %q to be present in graph", name, relPath) -} - -func requireSymbolAbsent(t *testing.T, g *graph.Graph, relPath, name string) { - t.Helper() - require.Falsef(t, hasSymbol(g, relPath, name), - "expected symbol %q in file %q to be absent from graph", name, relPath) -} - -func hasSymbol(g *graph.Graph, relPath, name string) bool { - for _, n := range g.GetFileNodes(relPath) { - if n.Name == name { - return true - } - } - return false -} diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 9eb8698..e69eacd 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -965,9 +965,8 @@ func (mi *MultiIndexer) GetIndexer(repoPrefix string) *Indexer { // IndexerForFile routes an absolute path to the per-repo Indexer that // owns it. Returns (nil, "") when no tracked repo contains the path. -// This is the multi-repo counterpart to the single-Indexer overlay -// path: the MCP overlay middleware calls it to find the right Indexer -// for each pushed file before invoking IndexFileFromContent. +// Used by the MCP overlay middleware to find the right Indexer for a +// pushed file when constructing the per-request overlay layer. func (mi *MultiIndexer) IndexerForFile(absPath string) (*Indexer, string) { prefix := mi.RepoForFile(absPath) if prefix == "" { @@ -976,45 +975,6 @@ func (mi *MultiIndexer) IndexerForFile(absPath string) (*Indexer, string) { return mi.GetIndexer(prefix), prefix } -// IndexFileFromContent routes an overlay apply through MultiIndexer. -// Forwards to the per-repo Indexer.IndexFileFromContent; returns nil -// (a no-op) when no tracked repo owns absPath. The no-op behaviour -// matches the on-disk path: the overlay middleware silently skips -// untracked paths instead of failing the whole tool call. -func (mi *MultiIndexer) IndexFileFromContent(absPath string, src []byte) error { - idx, _ := mi.IndexerForFile(absPath) - if idx == nil { - return nil - } - return idx.IndexFileFromContent(absPath, src, true) -} - -// EvictFileByAbs routes a deletion overlay through MultiIndexer. -// Forwards to the per-repo Indexer.EvictFile; no-op when no tracked -// repo owns the path. The bool return reports whether eviction -// actually happened — useful to the overlay middleware so it can skip -// scheduling a restore-from-disk for paths it didn't touch. -func (mi *MultiIndexer) EvictFileByAbs(absPath string) bool { - idx, _ := mi.IndexerForFile(absPath) - if idx == nil { - return false - } - idx.EvictFile(absPath) - return true -} - -// ReindexFromDisk routes an overlay-revert through MultiIndexer. -// Forwards to the per-repo Indexer.IndexFile; no-op when no tracked -// repo owns absPath. Used by the overlay middleware to restore the -// on-disk view after a tool call completes. -func (mi *MultiIndexer) ReindexFromDisk(absPath string) error { - idx, _ := mi.IndexerForFile(absPath) - if idx == nil { - return nil - } - return idx.IndexFile(absPath) -} - // ResolveFilePath takes a repo-prefixed relative path (e.g. "ade/internal/foo.go") // and returns the absolute filesystem path by looking up the repo's root directory. // Returns empty string if the repo prefix is not found. diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index c2796e4..7b4e8ac 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -4,38 +4,38 @@ import ( "context" "crypto/sha1" "encoding/hex" - "errors" "fmt" "os" - "path/filepath" "strings" "sync" "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 an editor-overlay manager into the MCP +// SetOverlayManager wires the editor-overlay manager into the MCP // server. After this call: // -// - Every `tools/call` is wrapped by an apply/revert middleware that -// merges the calling session's overlay buffers into the in-memory -// graph for the duration of the call. Tools that walk the graph -// (find_usages, get_call_chain, get_file_summary, …) and tools that -// read symbol source (get_symbol_source, get_editing_context, …) -// therefore see overlay content with no per-tool changes. +// - 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_register` / `overlay_push` / `overlay_list` / -// `overlay_delete` / `overlay_drop` MCP tools become live so an -// IDE extension speaking MCP can manage its own overlays without -// reaching for the `/v1/overlay/*` HTTP endpoints. +// - 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 come -// from disk; overlay tools are not registered). Calling twice -// re-registers the overlay tools idempotently. +// 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 { @@ -50,199 +50,49 @@ func (s *Server) SetOverlayManager(mgr *daemon.OverlayManager) { // 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 -// apply/revert middleware. Tool registration helpers (`s.addTool`) -// route every handler through this so the dispatcher and the HTTP -// `CallToolStrict` path both pay the same middleware cost — the HTTP -// path bypasses mcp-go's hook surface, so we can't rely on Hooks alone. +// 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. // -// When the server has no overlay manager, or the calling context has -// no overlay session, this is a transparent pass-through (one map -// lookup, zero parsing). +// 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 { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - revert, err := s.applyOverlaysForCtx(ctx) + view, err := s.buildOverlayViewForCtx(ctx) if err != nil { - // Drift / push-back-required surfaces as a structured tool - // error result — the client must re-read the file and - // resubmit a fresh overlay. We return (result, nil) so the - // JSON-RPC framing carries the message rather than a - // transport error. + // 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 revert != nil { - defer revert() + if view != nil { + ctx = WithOverlayView(ctx, view) } return h(ctx, req) } } -// applyOverlaysForCtx applies every overlay attached to the calling -// session to the live in-memory graph and returns a revert closure. -// Returns (nil, nil) when the request has no overlay session, the -// session has no files, or no overlay manager is wired — the -// fast-path that 99% of tool calls take. -// -// On drift the function returns a non-nil error and leaves the graph -// in its disk-restored state (every partial apply is rolled back). -// The caller surfaces the error as a structured MCP tool result so the -// client knows to re-read and resubmit the affected overlay. -// -// Concurrency: applies are serialised through s.overlayApplyMu so two -// in-flight tool calls can't interleave their evict/re-add pairs on -// the same file. The lock is held for the full apply+tool+revert -// window — for IDE-driven workloads (1-3 overlay files, parse < 50 ms -// per file) this is a sub-100 ms serialisation, which dominates the -// editor's keystroke cadence either way. -func (s *Server) applyOverlaysForCtx(ctx context.Context) (revert func(), err error) { - if s == nil || s.overlays == nil { - return nil, nil - } - sessID := SessionIDFromContext(ctx) - if sessID == "" { - return nil, nil - } - if s.overlays.FileCount(sessID) == 0 { - return nil, nil - } - _, files, err := s.overlays.SnapshotFor(sessID) - if err != nil { - // Session evaporated between the FileCount fast-path and the - // snapshot read. Treat as "no overlay" — the client will - // re-register if it cares. - return nil, nil - } - if len(files) == 0 { - return nil, nil - } - - s.overlayApplyMu.Lock() - applied := make([]string, 0, len(files)) - // Track per-application kind so revert restores deletions by - // re-indexing-from-disk while applied-content paths also revert - // to disk. Both kinds use the same restore call (IndexFile reads - // from disk; for paths that don't exist on disk it's a no-op - // because the in-memory state is already evicted). - for _, ov := range files { - absPath, resolveErr := s.resolveOverlayAbsPath(ov.Path) - if resolveErr != nil { - s.revertOverlays(applied) - s.overlayApplyMu.Unlock() - return nil, resolveErr - } - if absPath == "" { - // Path didn't resolve to a tracked workspace root — - // silently skip; the disk path will still be honoured if - // the file ever falls under a tracked repo later. - continue - } - if ov.BaseSHA != "" { - if !overlaySHAMatches(absPath, ov.BaseSHA) { - s.revertOverlays(applied) - s.overlayApplyMu.Unlock() - return nil, fmt.Errorf("%w: %s", daemon.ErrOverlayDrift, ov.Path) - } - } - if applyErr := s.applyOneOverlay(absPath, ov); applyErr != nil { - s.revertOverlays(applied) - s.overlayApplyMu.Unlock() - return nil, applyErr - } - applied = append(applied, absPath) - } - - // Lock stays held until revert; tool handler runs serialised - // against any other overlay-active request. Revert releases the - // lock so the next overlay-active request can proceed. - return func() { - s.revertOverlays(applied) - s.overlayApplyMu.Unlock() - }, nil -} - -// applyOneOverlay evicts the file from the graph and, when the -// overlay carries content, re-indexes from the supplied bytes. -// Deletion overlays leave the file evicted. -func (s *Server) applyOneOverlay(absPath string, ov daemon.OverlayFile) error { - if ov.Deleted { - if s.multiIndexer != nil { - s.multiIndexer.EvictFileByAbs(absPath) - return nil - } - if s.indexer != nil { - s.indexer.EvictFile(absPath) - } - return nil - } - if s.multiIndexer != nil { - return s.multiIndexer.IndexFileFromContent(absPath, []byte(ov.Content)) - } - if s.indexer != nil { - return s.indexer.IndexFileFromContent(absPath, []byte(ov.Content), true) - } - return nil -} - -// revertOverlays re-indexes each applied path from disk so the -// post-tool state matches the saved-buffer view. Called under -// overlayApplyMu; safe to call with an empty slice. Errors during -// revert are logged (debug) but not returned: a tool call is finished -// and the next watcher-driven IndexFile will heal a stuck state. -func (s *Server) revertOverlays(absPaths []string) { - for _, abs := range absPaths { - var err error - switch { - case s.multiIndexer != nil: - err = s.multiIndexer.ReindexFromDisk(abs) - case s.indexer != nil: - err = s.indexer.IndexFile(abs) - } - if err != nil && s.logger != nil { - s.logger.Debug("overlay revert: re-index from disk failed", - zap.String("path", abs), zap.Error(err)) - } - } -} - -// resolveOverlayAbsPath turns an overlay-supplied path into the -// absolute filesystem path used by indexer apply calls. Accepts: -// -// - Absolute paths — returned unchanged after symlink-safe cleaning. -// - Repo-prefixed paths (multi-repo mode, e.g. "ade/internal/foo.go") -// — resolved via MultiIndexer.ResolveFilePath. -// - Repo-relative paths (single-repo mode) — joined onto the -// Indexer's root path. -// -// Returns ("", nil) when the path doesn't resolve to a known -// workspace; the caller skips such overlays without failing the -// request, mirroring how the on-disk indexer treats untracked files. -func (s *Server) resolveOverlayAbsPath(p string) (string, error) { - if p == "" { - return "", errors.New("overlay path is empty") - } - if filepath.IsAbs(p) { - return filepath.Clean(p), nil - } - if s.multiIndexer != nil { - if abs := s.multiIndexer.ResolveFilePath(p); abs != "" { - return abs, nil - } - } - if s.indexer != nil { - if root := s.indexer.RootPath(); root != "" { - return filepath.Join(root, p), nil - } - } - return "", nil -} - -// overlaySHAMatches re-computes the git blob SHA of the on-disk file -// and compares it to the expected SHA captured at editor-open time. -// Matches the git blob hash format (`blob \0`) so the -// editor can pass the SHA it reads from `git ls-files -s` / the -// LSP `textDocument/didOpen` baseline without any client-side -// reformatting. Returns false on any read error: the safer default -// is "drift detected" — re-read and resubmit. +// overlaySHAMatches re-computes the git blob SHA of an on-disk file +// and compares it to the SHA the editor recorded at didOpen time. +// Matches `git ls-files -s` / `git hash-object` output (i.e. blob +// header `blob \0` then sha1), so editors can pass the +// SHA they already have without any client-side reformatting. +// Returns false on any read error: the safer default is "drift" — +// the client re-reads and resubmits. func overlaySHAMatches(absPath, expected string) bool { expected = strings.ToLower(strings.TrimSpace(expected)) if expected == "" { @@ -258,8 +108,7 @@ func overlaySHAMatches(absPath, expected string) bool { return hex.EncodeToString(h.Sum(nil)) == expected } -// overlayApplyMu serialises overlay-active tool calls. Declared on -// Server (server.go); declared here as a package-local sentinel so the -// linter doesn't flag the struct field as unused before the wire-up -// step adds it. +// _ 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 diff --git a/internal/mcp/overlay_e2e_test.go b/internal/mcp/overlay_e2e_test.go index fe6c38e..2f2fad5 100644 --- a/internal/mcp/overlay_e2e_test.go +++ b/internal/mcp/overlay_e2e_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -25,16 +26,26 @@ import ( ) // setupOverlayServer builds a fully-wired MCP server with an attached -// OverlayManager, an indexed temp repo, and a single Go file the tests -// can overlay-edit. Returns the server, the repo root, the absolute -// file path, and a teardown function. -func setupOverlayServer(t *testing.T) (srv *Server, dir, file string) { +// OverlayManager and an indexed temp repo of two interlinked Go +// files: target.go defines `Target()`, caller.go has a `Caller()` +// that calls `Target()`. The shape lets tests verify (a) overlays +// surface buffer-defined symbols, (b) cross-file edges from base +// (caller→target) survive overlay since base is never mutated, and +// (c) two concurrent sessions see their own overlay independently. +func setupOverlayServer(t *testing.T) (srv *Server, dir, targetFile, callerFile string) { t.Helper() dir = t.TempDir() - file = filepath.Join(dir, "main.go") - require.NoError(t, os.WriteFile(file, []byte(`package main + targetFile = filepath.Join(dir, "target.go") + callerFile = filepath.Join(dir, "caller.go") + require.NoError(t, os.WriteFile(targetFile, []byte(`package main -func Disk() {} +func Target() {} +`), 0o644)) + require.NoError(t, os.WriteFile(callerFile, []byte(`package main + +func Caller() { + Target() +} `), 0o644)) g := graph.New() @@ -44,12 +55,13 @@ func Disk() {} idx := indexer.New(g, reg, cfg.Index, zap.NewNop()) _, err := idx.Index(dir) require.NoError(t, err) + idx.ResolveAll() eng := query.NewEngine(g) srv = NewServer(eng, g, idx, nil, zap.NewNop(), nil) srv.SetOverlayManager(daemon.NewOverlayManager(time.Minute)) srv.RunAnalysis() - return srv, dir, file + return srv, dir, targetFile, callerFile } func callToolByName(t *testing.T, srv *Server, ctx context.Context, name string, args map[string]any) *mcplib.CallToolResult { @@ -75,21 +87,21 @@ func toolText(res *mcplib.CallToolResult) string { return sb.String() } -// TestOverlay_QueryConsumption_GetFileSummary is the core I19 contract: -// after the editor pushes an overlay adding a new function, the very -// next get_file_summary call must surface that function. The test -// proves the overlay-apply middleware runs around tool dispatch and -// that the indexer-from-content path produces graph entries query -// tools observe. -func TestOverlay_QueryConsumption_GetFileSummary(t *testing.T) { - srv, dir, file := setupOverlayServer(t) - sessID := "test-session-1" +// TestOverlay_BaseGraphIsImmutable is the load-bearing isolation +// guarantee: pushing and querying an overlay must NOT mutate the +// base graph. A snapshot of base node IDs taken before and after a +// full overlay round-trip must be byte-identical. +func TestOverlay_BaseGraphIsImmutable(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + beforeIDs := baseNodeIDs(srv) + + sessID := "test-immutability" require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ - Path: file, + Path: targetFile, Content: `package main -func Disk() {} +func Target() {} func Overlay() {} `, @@ -97,59 +109,73 @@ func Overlay() {} ctx := WithSessionID(context.Background(), sessID) res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ - "path": filepath.Base(file), + "path": filepath.Base(targetFile), }) - body := toolText(res) - require.Containsf(t, body, "Overlay", "get_file_summary must surface the overlay-added symbol; got %s", body) - require.Contains(t, body, "Disk") - - // Post-call revert: a query without the session ID must NOT see the - // overlay function. This guarantees the middleware restored the - // on-disk view after the previous tool returned. - bare := callToolByName(t, srv, context.Background(), "get_file_summary", map[string]any{ - "path": filepath.Base(file), + require.False(t, res.IsError) + require.Contains(t, toolText(res), "Overlay") + + afterIDs := baseNodeIDs(srv) + require.Equal(t, beforeIDs, afterIDs, + "base graph must be byte-identical before and after an overlay round-trip") +} + +// TestOverlay_FindUsagesPreservesCrossFileCallers exercises the +// regression the in-place-mutation design had: an editor overlays +// target.go (defining Target), and find_usages(target.go::Target) +// must still surface caller.go's call site — base's resolved edge +// from caller→target survives because the base graph isn't touched. +func TestOverlay_FindUsagesPreservesCrossFileCallers(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "test-find-usages" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + // Overlay rewrites target.go but keeps Target() with the same + // signature, so its node ID is unchanged. + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: targetFile, + Content: `package main + +func Target() {} + +func NewSibling() {} +`, + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "find_usages", map[string]any{ + "id": "target.go::Target", }) - require.NotContainsf(t, toolText(bare), "Overlay", - "post-tool revert must restore the on-disk view") - _ = dir + require.False(t, res.IsError, "find_usages: %s", toolText(res)) + require.Contains(t, toolText(res), "Caller", + "overlay must preserve base's caller.go → target.go::Target edge") } -// TestOverlay_DriftSurfacesAsToolError verifies that a stale overlay -// (BaseSHA recorded at editor-open time disagreeing with the current -// on-disk SHA) makes the next tool call fail with an MCP error result -// rather than silently returning stale data. The client is expected to -// re-read the file and resubmit a fresh overlay. +// TestOverlay_DriftSurfacesAsToolError: a stale BaseSHA must turn +// the very next tool call into an MCP error result so the client +// re-reads and resubmits. func TestOverlay_DriftSurfacesAsToolError(t *testing.T) { - srv, _, file := setupOverlayServer(t) + srv, _, targetFile, _ := setupOverlayServer(t) sessID := "test-session-drift" require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ - Path: file, - Content: "package main\n\nfunc Overlay() {}\n", - BaseSHA: "0000000000000000000000000000000000000000", // intentionally wrong + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n", + BaseSHA: "0000000000000000000000000000000000000000", }, nil)) ctx := WithSessionID(context.Background(), sessID) res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ - "path": filepath.Base(file), + "path": filepath.Base(targetFile), }) require.True(t, res.IsError, "drift must surface as an MCP tool error") require.Contains(t, toolText(res), "overlay base SHA mismatch") } -// TestOverlay_BaseSHA_MatchProceeds confirms the drift-detection -// happy path: when the editor's BaseSHA matches the on-disk git-blob -// hash, the overlay applies and the new symbol is visible. Without -// this we'd have no positive coverage of the SHA check — only the -// negative path. +// TestOverlay_BaseSHA_MatchProceeds: when the editor's base SHA +// agrees with the on-disk hash, the overlay applies and the new +// symbol is visible. func TestOverlay_BaseSHA_MatchProceeds(t *testing.T) { - srv, _, file := setupOverlayServer(t) - - // Compute the on-disk git blob SHA the same way overlay.go does - // (`blob \0` → sha1). The editor would normally - // read this from `git ls-files -s` or its LSP host's didOpen - // version metadata. - data, err := os.ReadFile(file) + srv, _, targetFile, _ := setupOverlayServer(t) + data, err := os.ReadFile(targetFile) require.NoError(t, err) h := sha1.New() fmt.Fprintf(h, "blob %d\x00", len(data)) @@ -159,84 +185,283 @@ func TestOverlay_BaseSHA_MatchProceeds(t *testing.T) { sessID := "test-session-match" require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ - Path: file, - Content: "package main\n\nfunc Disk() {}\n\nfunc Overlay() {}\n", + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n\nfunc Overlay() {}\n", BaseSHA: baseSHA, }, nil)) ctx := WithSessionID(context.Background(), sessID) res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ - "path": filepath.Base(file), + "path": filepath.Base(targetFile), }) require.False(t, res.IsError) require.Contains(t, toolText(res), "Overlay") } -// TestOverlay_NoSessionNoOp is the fast-path: a tools/call with no -// overlay session bound to the context must NOT pay any overlay -// apply/revert cost and must observe the on-disk view. Failing this -// would mean overlay support imposes overhead on every non-overlay -// MCP call — the regression that gates wide adoption. +// TestOverlay_NoSessionNoOp: a tools/call with no overlay session +// bound to ctx must NOT pay any overlay cost and must observe the +// on-disk view. Failing this would mean every non-overlay call +// pays an extra parse pass. func TestOverlay_NoSessionNoOp(t *testing.T) { - srv, _, file := setupOverlayServer(t) - // A registered session with no overlays attached: the fast-path - // (FileCount==0) must skip the apply pass entirely. + srv, _, targetFile, _ := setupOverlayServer(t) require.NoError(t, srv.OverlayManager().RegisterWithID("idle", "")) ctx := WithSessionID(context.Background(), "idle") res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ - "path": filepath.Base(file), + "path": filepath.Base(targetFile), }) require.False(t, res.IsError) - require.Contains(t, toolText(res), "Disk") + require.Contains(t, toolText(res), "Target") require.NotContains(t, toolText(res), "Overlay") } -// TestOverlay_MCP_RegisterPushList exercises the MCP-tool surface for -// overlay management: overlay_register, overlay_push, overlay_list. -// This is the path an IDE extension takes when it'd rather speak the -// MCP protocol than reach for the parallel /v1/overlay/* HTTP API. +// TestOverlay_TwoSessionsIsolated proves multi-tenant isolation: two +// sessions with conflicting overlays on the same path each see their +// own overlay, run concurrently, and don't contaminate each other. +// This was the failure mode of the prior in-place-mutation design. +func TestOverlay_TwoSessionsIsolated(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessA := "alpha" + sessB := "beta" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessA, "")) + require.NoError(t, srv.OverlayManager().RegisterWithID(sessB, "")) + require.NoError(t, srv.OverlayManager().Push(sessA, daemon.OverlayFile{ + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n\nfunc AlphaOnly() {}\n", + }, nil)) + require.NoError(t, srv.OverlayManager().Push(sessB, daemon.OverlayFile{ + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n\nfunc BetaOnly() {}\n", + }, nil)) + + const iterations = 8 + var wg sync.WaitGroup + var aErr, bErr error + wg.Add(2) + go func() { + defer wg.Done() + ctx := WithSessionID(context.Background(), sessA) + for i := 0; i < iterations; i++ { + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + body := toolText(res) + if !strings.Contains(body, "AlphaOnly") { + aErr = fmt.Errorf("alpha did not see AlphaOnly: %s", body) + return + } + if strings.Contains(body, "BetaOnly") { + aErr = fmt.Errorf("alpha leaked BetaOnly: %s", body) + return + } + } + }() + go func() { + defer wg.Done() + ctx := WithSessionID(context.Background(), sessB) + for i := 0; i < iterations; i++ { + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + body := toolText(res) + if !strings.Contains(body, "BetaOnly") { + bErr = fmt.Errorf("beta did not see BetaOnly: %s", body) + return + } + if strings.Contains(body, "AlphaOnly") { + bErr = fmt.Errorf("beta leaked AlphaOnly: %s", body) + return + } + } + }() + wg.Wait() + require.NoError(t, aErr) + require.NoError(t, bErr) +} + +// TestOverlay_OverlayAndBaseSessionsIsolated: a session WITH an +// overlay and a session WITHOUT any overlay run concurrently. The +// overlay session sees its buffer; the bare session sees disk. +// Neither contaminates the other. This is the case the prior +// in-place design failed because non-overlay calls observed the +// graph in its mid-mutation state. +func TestOverlay_OverlayAndBaseSessionsIsolated(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + overlaySess := "with-overlay" + require.NoError(t, srv.OverlayManager().RegisterWithID(overlaySess, "")) + require.NoError(t, srv.OverlayManager().Push(overlaySess, daemon.OverlayFile{ + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n\nfunc EditorOnly() {}\n", + }, nil)) + + const iterations = 8 + var wg sync.WaitGroup + var withErr, withoutErr error + wg.Add(2) + go func() { + defer wg.Done() + ctx := WithSessionID(context.Background(), overlaySess) + for i := 0; i < iterations; i++ { + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + if !strings.Contains(toolText(res), "EditorOnly") { + withErr = fmt.Errorf("overlay session lost overlay: %s", toolText(res)) + return + } + } + }() + go func() { + defer wg.Done() + ctx := context.Background() // no session ID + for i := 0; i < iterations; i++ { + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + if strings.Contains(toolText(res), "EditorOnly") { + withoutErr = fmt.Errorf("base session leaked overlay: %s", toolText(res)) + return + } + } + }() + wg.Wait() + require.NoError(t, withErr) + require.NoError(t, withoutErr) +} + +// TestOverlay_GetSymbolSourceReturnsBufferContent: get_symbol_source +// must surface the editor's unsaved bytes for the overlaid file, +// not the on-disk version. Without overlay-aware content reads the +// I19 contract for source-reading tools is unmet. +func TestOverlay_GetSymbolSourceReturnsBufferContent(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "src-test" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: targetFile, + Content: `package main + +// EditorSentinel is a unique comment line that only exists in the +// overlay buffer, never on disk. +func Target() {} +`, + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_symbol_source", map[string]any{ + "id": "target.go::Target", + "context_lines": 10, + }) + require.False(t, res.IsError, "get_symbol_source: %s", toolText(res)) + require.Contains(t, toolText(res), "EditorSentinel", + "get_symbol_source must return overlay buffer content, not disk") +} + +// TestOverlay_DeletionTombstone: deleted=true overlays hide the +// file's symbols. find_usages on a deleted symbol returns no +// results in the overlay view. +func TestOverlay_DeletionTombstone(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "tombstone-test" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: targetFile, + Deleted: true, + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + // Deletion overlay: either an error (no nodes) or an empty body, + // but Target must not appear. + require.NotContains(t, toolText(res), "Target", + "deletion overlay must hide every symbol from the file") +} + +// TestOverlay_MCP_RegisterPushList exercises the MCP-tool surface +// for overlay management: overlay_register, overlay_push, +// overlay_list. The MCP-native path is what IDE extensions speaking +// MCP take instead of the /v1/overlay/* HTTP endpoints. func TestOverlay_MCP_RegisterPushList(t *testing.T) { - srv, _, file := setupOverlayServer(t) - sessID := "test-mcp-register" + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "mcp-register" ctx := WithSessionID(context.Background(), sessID) regRes := callToolByName(t, srv, ctx, "overlay_register", map[string]any{}) require.False(t, regRes.IsError, "overlay_register: %s", toolText(regRes)) pushRes := callToolByName(t, srv, ctx, "overlay_push", map[string]any{ - "path": file, - "content": "package main\n\nfunc Overlay() {}\n", + "path": targetFile, + "content": "package main\n\nfunc Target() {}\n\nfunc PushedViaMCP() {}\n", }) require.False(t, pushRes.IsError, "overlay_push: %s", toolText(pushRes)) listRes := callToolByName(t, srv, ctx, "overlay_list", map[string]any{}) listText := toolText(listRes) - require.Contains(t, listText, file, "overlay_list must mention the pushed path: %s", listText) + require.Contains(t, listText, targetFile) require.Contains(t, listText, `"count":1`) + + summary := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + require.Contains(t, toolText(summary), "PushedViaMCP") } -// TestOverlay_DeletedFileGoneFromGraph verifies the tombstone path: -// when an overlay is pushed with deleted=true, the file's symbols -// must vanish from the graph for the duration of the call — the -// editor wants to preview "delete this file" without staging the -// deletion to disk. -func TestOverlay_DeletedFileGoneFromGraph(t *testing.T) { - srv, _, file := setupOverlayServer(t) - sessID := "test-mcp-del" +// TestOverlay_CompareWithOverlay_DiffSurface exercises the diff +// tool: an overlay adds a NewSibling function inside target.go but +// leaves caller.go untouched. find_usages of NewSibling against +// base returns nothing (the symbol doesn't exist on disk); against +// overlay it returns nothing either (caller.go doesn't call it). +// The base and overlay sides should disagree on the existence of +// NewSibling itself via the layer's overlay_paths metadata. +func TestOverlay_CompareWithOverlay_DiffSurface(t *testing.T) { + srv, _, targetFile, callerFile := setupOverlayServer(t) + // Edit caller.go in the overlay so it now calls NewSibling + // instead of Target. Edit target.go in the overlay to define + // NewSibling. compare_with_overlay against caller.go::Caller + // should show NewSibling as an added dependency that doesn't + // exist in base. + sessID := "diff-test" require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ - Path: file, - Deleted: true, + Path: targetFile, + Content: `package main + +func Target() {} + +func NewSibling() {} +`, + }, nil)) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: callerFile, + Content: `package main + +func Caller() { + NewSibling() +} +`, }, nil)) ctx := WithSessionID(context.Background(), sessID) - res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ - "path": filepath.Base(file), + res := callToolByName(t, srv, ctx, "compare_with_overlay", map[string]any{ + "kind": "get_call_chain", + "id": "caller.go::Caller", }) - // File summary against a tombstoned file: either it returns a - // structured "file not in graph" error, or it succeeds with an - // empty symbol set. Both are correct post-conditions; only the - // Disk symbol leaking back through would be a regression. - require.NotContains(t, toolText(res), "Disk", - "deletion overlay must hide the tombstoned file's symbols") + require.False(t, res.IsError, "compare_with_overlay: %s", toolText(res)) + body := toolText(res) + require.Contains(t, body, `"overlay_paths"`) + require.Contains(t, body, "target.go") + require.Contains(t, body, "caller.go") +} + +// baseNodeIDs returns a sorted slice of every node ID in the base +// graph. Used to verify the shadow-graph design's load-bearing +// invariant: base is never mutated during overlay processing. +func baseNodeIDs(srv *Server) []string { + nodes := srv.graph.AllNodes() + ids := make([]string, 0, len(nodes)) + for _, n := range nodes { + ids = append(ids, n.ID) + } + return ids } diff --git a/internal/mcp/overlay_view.go b/internal/mcp/overlay_view.go new file mode 100644 index 0000000..0b26660 --- /dev/null +++ b/internal/mcp/overlay_view.go @@ -0,0 +1,592 @@ +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "path/filepath" + "sort" + "strings" + "sync" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/query" +) + +// overlayViewCtxKey is the unexported context-key type for the +// per-request OverlaidView. The view is set by the tool-handler +// middleware (`wrapToolHandler`) and read by tool handlers via +// `s.readerFor(ctx)`. Unexported so external code can't smuggle a +// view onto an unrelated context. +type overlayViewCtxKey struct{} + +// WithOverlayView returns a child context carrying the +// shadow-graph view for the current `tools/call`. Tool handlers +// should not call this directly — `wrapToolHandler` is responsible +// for installing the view based on the calling session's overlay +// state. +func WithOverlayView(ctx context.Context, v *graph.OverlaidView) context.Context { + if v == nil { + return ctx + } + return context.WithValue(ctx, overlayViewCtxKey{}, v) +} + +// OverlayViewFromContext returns the per-request view, or nil when +// the call isn't overlay-active. Tool handlers prefer +// `s.readerFor(ctx)` which folds this with the base graph; direct +// callers (the diff tool) use this when they need both base and +// overlay sides explicitly. +func OverlayViewFromContext(ctx context.Context) *graph.OverlaidView { + if ctx == nil { + return nil + } + if v, ok := ctx.Value(overlayViewCtxKey{}).(*graph.OverlaidView); ok { + return v + } + return nil +} + +// readerFor returns the graph.Reader the calling tool handler should +// read through. When ctx carries an overlay view, that view is the +// reader and base is consulted through it. Otherwise the base graph +// is returned directly. The single helper keeps every read site +// overlay-aware with one line of plumbing. +// +// Never nil unless the server itself has no graph wired (test-only +// state). Callers that hot-loop on this should hoist the lookup — +// the helper is cheap but the indirection still matters at million- +// edge scales. +func (s *Server) readerFor(ctx context.Context) graph.Reader { + if v := OverlayViewFromContext(ctx); v != nil { + return v + } + return s.graph +} + +// engineFor returns the query engine scoped to the calling request's +// graph reader. For non-overlay calls this is `s.engine` unchanged; +// for overlay-active calls the returned engine reads through the +// overlay view, so engine-level walks (`FindUsages`, `GetCallers`, +// `GetCallChain`, dependency / dependent walkers, hierarchy …) all +// see the editor-buffer state automatically. +// +// Cheap: WithReader is a shallow clone (one struct copy, shares +// search provider and rerank pipeline). Safe to call inside hot +// tool-handler paths. +func (s *Server) engineFor(ctx context.Context) *query.Engine { + if s == nil || s.engine == nil { + return nil + } + if v := OverlayViewFromContext(ctx); v != nil { + return s.engine.WithReader(v) + } + return s.engine +} + +// overlayLayerCacheEntry is one (sessionID, content-hash sum) bucket +// in s.overlayLayerCache. The hash is over (sorted) overlay files' +// (path, content, deleted, base_sha) tuples; identical pushes from a +// long sequence of tool calls hit the same entry and reuse the same +// parsed layer without re-running the per-language extractors. +type overlayLayerCacheEntry struct { + hash string + layer *graph.OverlayLayer + // Files captured in the entry, for invalidation lookups and for + // the diff tool's enumeration. + files []string +} + +// buildOverlayViewForCtx is the per-request entry called by +// wrapToolHandler. Returns (nil, nil) for non-overlay sessions, the +// overlay-view for overlay-active sessions, or (nil, err) when drift +// detection trips so the client knows to refresh and resubmit. +func (s *Server) buildOverlayViewForCtx(ctx context.Context) (*graph.OverlaidView, error) { + if s == nil || s.overlays == nil { + return nil, nil + } + sessID := SessionIDFromContext(ctx) + if sessID == "" { + return nil, nil + } + if s.overlays.FileCount(sessID) == 0 { + return nil, nil + } + _, files, err := s.overlays.SnapshotFor(sessID) + if err != nil { + // Session evaporated. Fast-path the non-overlay route. + return nil, nil + } + if len(files) == 0 { + return nil, nil + } + + // Drift check up front for every overlay that carries a BaseSHA. + // We do it here, before parsing, so a stale overlay never costs + // the extractor time and the client gets a clear error. + for _, ov := range files { + if ov.BaseSHA == "" { + continue + } + abs, resolveErr := s.resolveOverlayAbsPath(ov.Path) + if resolveErr != nil { + return nil, resolveErr + } + if abs == "" { + continue + } + if !overlaySHAMatches(abs, ov.BaseSHA) { + return nil, fmt.Errorf("%w: %s", daemon.ErrOverlayDrift, ov.Path) + } + } + + hash := hashOverlayFiles(files) + + // Cache hit: same session pushed the same buffers; reuse the + // parsed layer. The cache stores up to one entry per session; a + // changed content hash evicts the prior entry. + if v, ok := s.overlayLayerCache.Load(sessID); ok { + if entry := v.(*overlayLayerCacheEntry); entry.hash == hash { + return graph.NewOverlaidView(s.graph, entry.layer), nil + } + s.overlayLayerCache.Delete(sessID) + } + + // Cache miss: parse + resolve under a per-server build mutex so + // two requests for the same fresh content don't duplicate the + // extractor work. We re-check the cache under the lock. + s.overlayLayerBuildMu.Lock() + defer s.overlayLayerBuildMu.Unlock() + if v, ok := s.overlayLayerCache.Load(sessID); ok { + if entry := v.(*overlayLayerCacheEntry); entry.hash == hash { + return graph.NewOverlaidView(s.graph, entry.layer), nil + } + } + + layer, paths, err := s.constructOverlayLayer(files) + if err != nil { + return nil, err + } + if layer == nil { + return nil, nil + } + s.overlayLayerCache.Store(sessID, &overlayLayerCacheEntry{ + hash: hash, + layer: layer, + files: paths, + }) + return graph.NewOverlaidView(s.graph, layer), nil +} + +// resolveOverlayAbsPath turns the overlay's caller-supplied path into +// the absolute filesystem path the indexer would have used. Accepts +// absolute paths, repo-prefixed paths (multi-repo), and repo-relative +// paths (single-repo). Returns ("", nil) for paths that don't +// resolve to any tracked workspace — those overlays are silently +// skipped, matching how the on-disk path treats untracked files. +func (s *Server) resolveOverlayAbsPath(p string) (string, error) { + if p == "" { + return "", fmt.Errorf("overlay path is empty") + } + if filepath.IsAbs(p) { + return filepath.Clean(p), nil + } + if s.multiIndexer != nil { + if abs := s.multiIndexer.ResolveFilePath(p); abs != "" { + return abs, nil + } + } + if s.indexer != nil { + if root := s.indexer.RootPath(); root != "" { + return filepath.Join(root, p), nil + } + } + return "", nil +} + +// resolveOverlayGraphPath turns the overlay path into the +// `graph_path` form (repo-prefixed in multi-repo mode, repo-relative +// in single-repo mode) — the form `GetFileNodes` and friends use. +func (s *Server) resolveOverlayGraphPath(p, absPath string) string { + if s.multiIndexer != nil { + if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" { + if idx, _ := s.multiIndexer.IndexerForFile(absPath); idx != nil { + if root := idx.RootPath(); root != "" { + if rel, err := filepath.Rel(root, absPath); err == nil { + return prefix + "/" + filepath.ToSlash(rel) + } + } + } + } + } + if s.indexer != nil { + if root := s.indexer.RootPath(); root != "" { + if rel, err := filepath.Rel(root, absPath); err == nil { + return filepath.ToSlash(rel) + } + } + } + // Fall back to caller-supplied path — this is the single-repo + // repo-relative case where p is already the graph_path. + return filepath.ToSlash(p) +} + +// pickIndexerForPath chooses the per-repo Indexer (multi-repo) or +// the single Indexer (single-repo) that owns absPath. Returns nil +// when no Indexer owns the path (the overlay is silently skipped +// upstream). +func (s *Server) pickIndexerForPath(absPath string) *indexer.Indexer { + if s.multiIndexer != nil { + if idx, _ := s.multiIndexer.IndexerForFile(absPath); idx != nil { + return idx + } + } + if s.indexer != nil { + if root := s.indexer.RootPath(); root != "" { + if rel, err := filepath.Rel(root, absPath); err == nil && !strings.HasPrefix(rel, "..") { + return s.indexer + } + } + } + return nil +} + +// constructOverlayLayer is the parse-and-resolve pass. For each +// overlay file: +// +// - Resolve the absolute and graph-prefixed paths. +// - For deleted: true overlays, mark the graph path as a tombstone +// and continue (no parsing). +// - For content overlays, look up the per-language extractor via +// the indexer's parser.Registry and call Extract on the buffer. +// - Apply the repo prefix to the result so IDs match base's +// `/::` shape. +// - Add the parsed nodes + edges to the layer. +// - Track removed-by-overlay symbols (names that existed in base's +// view of the file but disappeared) so FindNodesByName drops +// those base hits. +// +// After parsing all files, run a local resolver pass that rewrites +// every `unresolved::*` edge in the overlay to point at a real node +// when one exists in `(layer ∪ base)`. The pass is conservative — +// simple name resolution — but covers the common cases: direct +// function calls, method calls in the same file, intra-package +// references. +func (s *Server) constructOverlayLayer(files []daemon.OverlayFile) (*graph.OverlayLayer, []string, error) { + if s.graph == nil { + return nil, nil, nil + } + layer := graph.NewOverlayLayer() + var coveredPaths []string + + for _, ov := range files { + absPath, err := s.resolveOverlayAbsPath(ov.Path) + if err != nil { + return nil, nil, err + } + if absPath == "" { + continue // untracked file — silent skip, matches disk path + } + graphPath := s.resolveOverlayGraphPath(ov.Path, absPath) + coveredPaths = append(coveredPaths, graphPath) + + if ov.Deleted { + // Tombstone: hide every base node for this file. + for _, n := range s.graph.GetFileNodes(graphPath) { + layer.MarkRemoved(n.Name, n.ID) + } + layer.MarkFile(graphPath, true) + continue + } + + idx := s.pickIndexerForPath(absPath) + if idx == nil { + continue + } + reg := idx.Registry() + if reg == nil { + continue + } + lang, ok := reg.DetectLanguage(absPath) + if !ok { + continue + } + ext, _ := reg.GetByLanguage(lang) + if ext == nil { + continue + } + root := idx.RootPath() + relPath := graphPath + if idx.RepoPrefix() != "" { + relPath = strings.TrimPrefix(graphPath, idx.RepoPrefix()+"/") + } else if root != "" { + if r, err := filepath.Rel(root, absPath); err == nil { + relPath = filepath.ToSlash(r) + } + } + result, err := ext.Extract(relPath, []byte(ov.Content)) + if err != nil { + return nil, nil, fmt.Errorf("overlay parse %s: %w", ov.Path, err) + } + // Track which base IDs disappear under the overlay so + // FindNodesByName / GetInEdges filter them. Build the set + // first from base, then mark every base ID that the overlay + // did NOT re-emit (by ID equality). + baseIDsByName := map[string]map[string]bool{} + for _, n := range s.graph.GetFileNodes(graphPath) { + set, ok := baseIDsByName[n.Name] + if !ok { + set = make(map[string]bool) + baseIDsByName[n.Name] = set + } + set[n.ID] = true + } + overlayIDsByName := map[string]map[string]bool{} + applyRepoPrefixToResult(result, idx.RepoPrefix()) + layer.MarkFile(graphPath, false) + for _, n := range result.Nodes { + layer.AddNode(graphPath, n) + set, ok := overlayIDsByName[n.Name] + if !ok { + set = make(map[string]bool) + overlayIDsByName[n.Name] = set + } + set[n.ID] = true + } + for _, e := range result.Edges { + layer.AddEdge(e) + } + // Names that existed in base but were not re-emitted by the + // overlay (same name, different ID, or absent entirely) get + // marked removed so FindNodesByName filters the base hits. + for name, baseIDs := range baseIDsByName { + overlayIDs := overlayIDsByName[name] + for id := range baseIDs { + if !overlayIDs[id] { + layer.MarkRemoved(name, id) + } + } + } + } + + if len(coveredPaths) == 0 { + return nil, nil, nil + } + sort.Strings(coveredPaths) + + // Local resolver pass: rewrite unresolved overlay edges to point + // at concrete IDs whenever a single best match exists in + // (overlay ∪ base). + s.resolveOverlayEdges(layer) + + return layer, coveredPaths, nil +} + +// applyRepoPrefixToResult prepends repoPrefix to every node/edge in +// an extraction result so IDs match base's shape in multi-repo mode. +// Mirrors `Indexer.applyRepoPrefix` but kept here to avoid having to +// expose that helper to non-indexer packages. +func applyRepoPrefixToResult(result *parser.ExtractionResult, repoPrefix string) { + if result == nil || repoPrefix == "" { + return + } + for _, n := range result.Nodes { + if n == nil { + continue + } + n.ID = repoPrefix + "/" + n.ID + if n.FilePath != "" { + n.FilePath = repoPrefix + "/" + n.FilePath + } + n.RepoPrefix = repoPrefix + } + for _, e := range result.Edges { + if e == nil { + continue + } + e.From = repoPrefix + "/" + e.From + if !strings.HasPrefix(e.To, unresolvedPrefix) { + e.To = repoPrefix + "/" + e.To + } + } +} + +// unresolvedPrefix matches the resolver's prefix for placeholder +// edge targets. Kept as a package constant so resolveOverlayEdges +// can recognise placeholders without importing the resolver package +// (which would create a layering cycle through the indexer). +const unresolvedPrefix = "unresolved::" + +// resolveOverlayEdges runs a conservative local resolver pass over +// the overlay layer's edges. For each placeholder `unresolved::*` +// edge: +// +// - Strip the `unresolved::::` prefix to recover the target +// name (and optional fully-qualified suffix). +// - Look the name up in (layer.nodesByName ∪ base.FindNodesByName). +// Prefer overlay matches over base; prefer a single unambiguous +// match over multiple. +// - On a unique match, rewrite the edge's To and re-index it in +// the layer's outEdges / inEdges maps. +// +// The pass deliberately does NOT replicate the full resolver +// (interface dispatch, import-path attribution, dataflow, etc.) — +// overlay buffers are transient and the common case the editor +// cares about is "I added a call to Foo; does find_usages of Foo +// now include this site?" Direct name resolution covers that. +func (s *Server) resolveOverlayEdges(layer *graph.OverlayLayer) { + if layer == nil { + return + } + // Collect every From → []Edge that the layer holds. We iterate + // over a copy of the map so we can rewrite layer edges + // in-place via AddEdge / removal pattern (layer is meant + // to be append-only post-construction; the resolver pass runs + // before the layer is handed to the View, so we still own it). + for from, edges := range layer.OutEdgesByFromAll() { + for _, e := range edges { + if !strings.HasPrefix(e.To, unresolvedPrefix) { + continue + } + target := strings.TrimPrefix(e.To, unresolvedPrefix) + // Strip kind segment if present (e.g. "call::FooBar"). + if i := strings.Index(target, "::"); i > 0 { + target = target[i+2:] + } + // Strip trailing argument-count / disambiguator hints. + if i := strings.Index(target, "@"); i > 0 { + target = target[:i] + } + if target == "" { + continue + } + resolved := s.lookupOverlayTarget(layer, target, from) + if resolved == "" { + continue + } + e.To = resolved + } + _ = from + } + // Rebuild the layer's inEdges index now that targets may have + // changed. The layer exposes a Rebuild helper so we don't have + // to know the internal map shape. + layer.RebuildInEdges() +} + +// lookupOverlayTarget tries to find a unique node with the given +// short name in (layer ∪ base). Returns the node ID on a unique +// match, empty string otherwise. Tied matches return empty so the +// edge stays as a placeholder rather than picking the wrong target. +func (s *Server) lookupOverlayTarget(layer *graph.OverlayLayer, name, _fromID string) string { + overlay := layer.NodesByName(name) + if len(overlay) == 1 { + return overlay[0].ID + } + if len(overlay) > 1 { + return "" + } + if s.graph == nil { + return "" + } + hits := s.graph.FindNodesByName(name) + // Drop hits whose file is overlaid AND whose ID wasn't kept by + // the overlay — those are now-deleted symbols. + keep := hits[:0:0] + for _, n := range hits { + if layer.HasFile(graph.IDFile(n.ID)) { + if !layer.HasNode(n.ID) { + continue + } + } + keep = append(keep, n) + } + if len(keep) == 1 { + return keep[0].ID + } + return "" +} + +// hashOverlayFiles produces a stable content-hash of an overlay +// file set so the cache can detect "same set, reuse parse". +func hashOverlayFiles(files []daemon.OverlayFile) string { + sorted := make([]daemon.OverlayFile, len(files)) + copy(sorted, files) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path }) + h := sha256.New() + for _, f := range sorted { + fmt.Fprintf(h, "%s\x00%t\x00%s\x00", f.Path, f.Deleted, f.BaseSHA) + _, _ = h.Write([]byte(f.Content)) + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// overlayContentFor returns the editor-buffer content for an +// absolute path in the calling session's overlay, if any. Used by +// source-reading handlers (get_symbol_source, get_editing_context, +// smart_context) to substitute the overlay's text for the on-disk +// file when the request is overlay-active. Returns (content, true) +// on a hit, ("", false) otherwise — including the deleted-overlay +// case, since a tombstone has no content to return and callers +// should treat the file as absent. +func (s *Server) overlayContentFor(ctx context.Context, absPath string) (string, bool) { + if s == nil || s.overlays == nil || ctx == nil { + return "", false + } + sessID := SessionIDFromContext(ctx) + if sessID == "" { + return "", false + } + if s.overlays.FileCount(sessID) == 0 { + return "", false + } + _, files, err := s.overlays.SnapshotFor(sessID) + if err != nil || len(files) == 0 { + return "", false + } + cleanedAbs := filepath.Clean(absPath) + for _, ov := range files { + if ov.Deleted { + continue + } + ovAbs, _ := s.resolveOverlayAbsPath(ov.Path) + if ovAbs == "" { + continue + } + if filepath.Clean(ovAbs) == cleanedAbs { + return ov.Content, true + } + } + return "", false +} + +// overlayCacheInvalidate drops the cached layer for a session. Called +// by overlay_push / overlay_delete / overlay_drop so the next tool +// call re-parses with the fresh buffer state. +func (s *Server) overlayCacheInvalidate(sessID string) { + if s == nil || sessID == "" { + return + } + s.overlayLayerCache.Delete(sessID) +} + +// loggerForOverlay returns s.logger if non-nil, else a no-op logger. +// Helper so the overlay path can emit structured diagnostics without +// gating every emit on a nil check. +func (s *Server) loggerForOverlay() *zap.Logger { + if s == nil || s.logger == nil { + return zap.NewNop() + } + return s.logger +} + +// Compile-time sanity: a sync.Mutex usage placeholder so future +// linter-driven import pruning doesn't strip the package. +var _ sync.Mutex diff --git a/internal/mcp/prompts.go b/internal/mcp/prompts.go index b593683..2381236 100644 --- a/internal/mcp/prompts.go +++ b/internal/mcp/prompts.go @@ -41,7 +41,7 @@ func (s *Server) registerPrompts() { ) } -func (s *Server) handlePromptPreCommit(_ context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { +func (s *Server) handlePromptPreCommit(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { scope := req.Params.Arguments["scope"] if scope == "" { scope = "all" @@ -101,7 +101,7 @@ func (s *Server) handlePromptPreCommit(_ context.Context, req mcp.GetPromptReque } } - testFiles := s.findTestFiles(symbolIDs) + testFiles := s.findTestFiles(ctx, symbolIDs) if len(testFiles) > 0 { b.WriteString("\n### Tests to Run\n") for file, names := range testFiles { @@ -259,7 +259,7 @@ func (s *Server) handlePromptSafeToChange(ctx context.Context, req mcp.GetPrompt // — the prompt must not reveal cross-workspace symbols. var validIDs, notFound []string for _, id := range ids { - if n := s.engine.GetSymbol(id); n != nil && s.nodeInSessionScope(ctx, n) { + if n := s.engineFor(ctx).GetSymbol(id); n != nil && s.nodeInSessionScope(ctx, n) { validIDs = append(validIDs, id) } else { notFound = append(notFound, id) @@ -303,20 +303,20 @@ func (s *Server) handlePromptSafeToChange(ctx context.Context, req mcp.GetPrompt b.WriteString("### Edit Order\n") for _, id := range validIDs { - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { continue } fmt.Fprintf(&b, "1. `%s` — definition\n", node.FilePath) if node.Kind == graph.KindInterface { - impls := s.scopedNodeSlice(ctx, s.engine.FindImplementations(id)) + impls := s.scopedNodeSlice(ctx, s.engineFor(ctx).FindImplementations(id)) for _, impl := range impls { fmt.Fprintf(&b, "2. `%s` — implements %s\n", impl.FilePath, node.Name) } } - dependents := s.engine.GetDependents(id, query.QueryOptions{Depth: 2, Limit: 20, Detail: "brief", WorkspaceID: sessWS}) + dependents := s.engineFor(ctx).GetDependents(id, query.QueryOptions{Depth: 2, Limit: 20, Detail: "brief", WorkspaceID: sessWS}) depFiles := make(map[string]bool) for _, dn := range dependents.Nodes { if dn.Kind != graph.KindFile && dn.FilePath != node.FilePath && !isTestFile(dn.FilePath) && !depFiles[dn.FilePath] { @@ -326,7 +326,7 @@ func (s *Server) handlePromptSafeToChange(ctx context.Context, req mcp.GetPrompt } } - testFiles := s.findTestFiles(validIDs) + testFiles := s.findTestFiles(ctx, validIDs) if len(testFiles) > 0 { b.WriteString("\n### Tests to Verify\n") for file := range testFiles { @@ -347,10 +347,13 @@ func (s *Server) handlePromptSafeToChange(ctx context.Context, req mcp.GetPrompt } // findTestFiles traces callers of the given symbols and returns test files with function names. -func (s *Server) findTestFiles(symbolIDs []string) map[string][]string { +// ctx scopes graph reads to the caller's overlay view (when present) +// so an editor's unsaved test edits surface in the impact summary. +func (s *Server) findTestFiles(ctx context.Context, symbolIDs []string) map[string][]string { + eng := s.engineFor(ctx) testFiles := make(map[string]map[string]bool) for _, id := range symbolIDs { - callers := s.engine.GetCallers(id, query.QueryOptions{Depth: 3, Limit: 50, Detail: "brief"}) + callers := eng.GetCallers(id, query.QueryOptions{Depth: 3, Limit: 50, Detail: "brief"}) for _, cn := range callers.Nodes { if isTestFile(cn.FilePath) { if testFiles[cn.FilePath] == nil { @@ -388,7 +391,7 @@ func (s *Server) findTopReferenced(ctx context.Context, limit int) []refEntry { continue } count := 0 - for _, e := range s.engine.GetInEdges(n.ID) { + for _, e := range s.engineFor(ctx).GetInEdges(n.ID) { if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences { count++ } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index ffa5b4c..95460c9 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -150,22 +150,25 @@ type Server struct { toolScopes *scopeRegistry // overlays is the optional editor-overlay manager. When non-nil, - // every `tools/call` is wrapped (via s.addTool) with the - // apply/revert middleware in overlay.go so MCP tools see the - // caller's editor-buffer content for the duration of the call. - // Wired post-construction by SetOverlayManager. + // every `tools/call` whose session carries overlay buffers is + // wrapped (via s.addTool → wrapToolHandler) with the per-request + // shadow-graph middleware that builds an OverlaidView over the + // immutable base graph. Wired post-construction by + // SetOverlayManager. overlays *daemon.OverlayManager - // overlayApplyMu serialises overlay-active tool calls so two - // in-flight requests can't race on the same overlay path's - // evict/re-add pair. Held for the full apply+handler+revert - // window; transparent (untaken) when the caller has no overlay. - overlayApplyMu sync.Mutex + // overlayLayerCache memoises per-session parsed overlay layers + // keyed by (sessionID, content-hash sum). Cache hits avoid the + // per-request re-parse when an editor pushes the same buffer to a + // long sequence of tool calls. Entries are dropped on overlay + // session Drop / Push / Delete via overlayCacheInvalidate. + overlayLayerCache sync.Map // map[string]*overlayLayerCacheEntry; key = layerCacheKey + overlayLayerBuildMu sync.Mutex // registerOverlayToolsOnce gates the overlay MCP tool family // (overlay_register / overlay_push / overlay_list / - // overlay_delete / overlay_drop) so a second SetOverlayManager - // call doesn't double-register them. + // overlay_delete / overlay_drop / compare_with_overlay) so a + // second SetOverlayManager call doesn't double-register them. registerOverlayToolsOnce sync.Once } diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 61210aa..8c75787 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -200,7 +200,7 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe s.ensureFresh([]string{fp}) s.sessionFor(ctx).recordFile(fp) - sg := s.engine.GetFileSymbols(fp) + sg := s.engineFor(ctx).GetFileSymbols(fp) if len(sg.Nodes) == 0 { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } @@ -264,7 +264,7 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe callerSeen := make(map[string]bool) for _, n := range sg.Nodes { if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { - callers := s.engine.GetCallers(n.ID, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief", WorkspaceID: sessWS}) + callers := s.engineFor(ctx).GetCallers(n.ID, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief", WorkspaceID: sessWS}) for _, cn := range callers.Nodes { if cn.FilePath != fp && !callerSeen[cn.ID] { callerSeen[cn.ID] = true @@ -283,7 +283,7 @@ func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRe callSeen := make(map[string]bool) for _, n := range sg.Nodes { if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { - chain := s.engine.GetCallChain(n.ID, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief", WorkspaceID: sessWS}) + chain := s.engineFor(ctx).GetCallChain(n.ID, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief", WorkspaceID: sessWS}) for _, cn := range chain.Nodes { if cn.FilePath != fp && !callSeen[cn.ID] { callSeen[cn.ID] = true @@ -333,7 +333,7 @@ func (s *Server) handleFindImportPath(ctx context.Context, req mcp.CallToolReque return mcp.NewToolResultError("path is required"), nil } - candidates := s.scopedNodeSlice(ctx, s.engine.FindSymbols(symbolName)) + candidates := s.scopedNodeSlice(ctx, s.engineFor(ctx).FindSymbols(symbolName)) if len(candidates) == 0 { return mcp.NewToolResultError("symbol not found: " + symbolName), nil } @@ -361,7 +361,7 @@ func (s *Server) handleFindImportPath(ctx context.Context, req mcp.CallToolReque // Check if already imported. alreadyImported := false - fileSymbols := s.engine.GetFileSymbols(targetFile) + fileSymbols := s.engineFor(ctx).GetFileSymbols(targetFile) if len(fileSymbols.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileSymbols.Nodes[0]) { fileSymbols = nil } @@ -442,7 +442,7 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ s.ensureFresh([]string{parts[0]}) } - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id), nil } @@ -484,7 +484,7 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ return mcp.NewToolResultError(resolveErr.Error()), nil } - source, startLine, totalFileChars, err := readLines(absPath, node.StartLine, node.EndLine, contextLines) + source, startLine, totalFileChars, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not read source: %v", err)), nil } @@ -530,6 +530,10 @@ func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequ // readLines reads lines from a file, with optional context lines above/below. // Returns the source text, the first line number, the total file size in characters // (for token savings estimation), and any error. +// +// Disk path only — used by helpers that have no MCP request context. +// Production handlers should call (*Server).readLinesForCtx so the +// editor-buffer overlay is honoured when active. func readLines(path string, startLine, endLine, contextLines int) (string, int, int, error) { f, err := os.Open(path) if err != nil { @@ -562,6 +566,53 @@ func readLines(path string, startLine, endLine, contextLines int) (string, int, return strings.Join(lines, "\n"), from, totalChars, nil } +// readLinesForCtx is the overlay-aware counterpart to readLines. +// When ctx carries an editor-overlay view AND the path is covered by +// the overlay, the buffer content is used instead of reading from +// disk — so get_symbol_source / get_editing_context / smart_context +// return the editor's unsaved view of the file. Falls back to +// readLines transparently when no overlay applies. +func (s *Server) readLinesForCtx(ctx context.Context, absPath string, startLine, endLine, contextLines int) (string, int, int, error) { + content, ok := s.overlayContentFor(ctx, absPath) + if !ok { + return readLines(absPath, startLine, endLine, contextLines) + } + return extractLinesFromContent(content, startLine, endLine, contextLines) +} + +// extractLinesFromContent applies the same line-slicing logic readLines +// uses to an in-memory buffer. Kept separate from readLines so the +// disk path stays a single os.Open / Scanner loop. +func extractLinesFromContent(content string, startLine, endLine, contextLines int) (string, int, int, error) { + from := startLine - contextLines + if from < 1 { + from = 1 + } + to := endLine + contextLines + + lines := strings.Split(content, "\n") + totalChars := 0 + for _, l := range lines { + totalChars += len(l) + 1 + } + if totalChars > 0 { + // strings.Split adds a phantom trailing entry when the + // content ends with a newline; account for the over-count by + // trimming one byte (matches readLines which counts the + // trailing \n only when Scanner produced a line for it). + totalChars-- + } + + var picked []string + for i, l := range lines { + lineNum := i + 1 + if lineNum >= from && lineNum <= to { + picked = append(picked, l) + } + } + return strings.Join(picked, "\n"), from, totalChars, nil +} + func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { idsStr, err := req.RequireString("ids") if err != nil { @@ -584,7 +635,7 @@ func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest var results []map[string]any for _, id := range ids { - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) // A node outside the session's workspace is reported as a // miss — identical to a genuinely absent ID so the boundary // stays opaque. @@ -610,7 +661,7 @@ func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest // Callers (depth 1). if node.Kind == graph.KindFunction || node.Kind == graph.KindMethod { - callers := s.engine.GetCallers(node.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief", WorkspaceID: sessWS}) + callers := s.engineFor(ctx).GetCallers(node.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief", WorkspaceID: sessWS}) var callerIDs []string for _, cn := range callers.Nodes { if cn.ID != node.ID { @@ -622,7 +673,7 @@ func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest } // Callees (depth 1). - callees := s.engine.GetCallChain(node.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief", WorkspaceID: sessWS}) + callees := s.engineFor(ctx).GetCallChain(node.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief", WorkspaceID: sessWS}) var calleeIDs []string for _, cn := range callees.Nodes { if cn.ID != node.ID { @@ -637,7 +688,7 @@ func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest // Source code (optional). if includeSource && node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(node); err == nil { - if source, fromLine, totalFileChars, err := readLines(absPath, node.StartLine, node.EndLine, contextLines); err == nil { + if source, fromLine, totalFileChars, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines); err == nil { entry["source"] = source entry["from_line"] = fromLine returned := tokens.CountInt64(source) @@ -730,7 +781,7 @@ func (s *Server) handleGetTestTargets(ctx context.Context, req mcp.CallToolReque coveredSymbols := make(map[string]bool) for _, id := range ids { - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { continue } @@ -740,7 +791,7 @@ func (s *Server) handleGetTestTargets(ctx context.Context, req mcp.CallToolReque // inverse-edge walk is one hop instead of the BFS-on-EdgeCalls // that this tool used to do, and it's exact (no isTestFile // post-filter needed). - if testers := s.engine.GetTesters(id); len(testers) > 0 { + if testers := s.engineFor(ctx).GetTesters(id); len(testers) > 0 { for _, tn := range testers { if tn == nil { continue @@ -758,7 +809,7 @@ func (s *Server) handleGetTestTargets(ctx context.Context, req mcp.CallToolReque // Fallback for graphs that haven't been re-indexed since the // EdgeTests pass shipped, or for indirect coverage (depth > 1). - callers := s.engine.GetCallers(id, query.QueryOptions{Depth: depth, Limit: 100, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(id, query.QueryOptions{Depth: depth, Limit: 100, Detail: "brief"}) for _, cn := range callers.Nodes { if !isTestFile(cn.FilePath) { continue @@ -832,7 +883,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque return mcp.NewToolResultError("id is required"), nil } - node := s.engine.GetSymbol(exampleID) + node := s.engineFor(ctx).GetSymbol(exampleID) if node == nil { return mcp.NewToolResultError("symbol not found: " + exampleID), nil } @@ -849,7 +900,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // 1. Get the example source. if node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(node); err == nil { - if source, _, _, err := readLines(absPath, node.StartLine, node.EndLine, 0); err == nil { + if source, _, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0); err == nil { result["example_source"] = source } } @@ -859,7 +910,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque } // 2. Find siblings — same kind, same file, similar naming pattern. - fileSymbols := s.engine.GetFileSymbols(node.FilePath) + fileSymbols := s.engineFor(ctx).GetFileSymbols(node.FilePath) if len(fileSymbols.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileSymbols.Nodes[0]) { fileSymbols = &query.SubGraph{} } @@ -882,7 +933,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque result["siblings_count"] = len(fileSymbols.Nodes) - 1 // exclude file node // 3. Find how the example is wired/registered (callers at depth 1). - callers := s.engine.GetCallers(exampleID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(exampleID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) var registration []map[string]any for _, cn := range callers.Nodes { if cn.ID == exampleID { @@ -897,7 +948,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // Get the registration source (the caller function that wires this symbol). if cn.StartLine > 0 && cn.EndLine > 0 { if absPath, err := s.resolveNodePath(cn); err == nil { - if source, _, _, err := readLines(absPath, cn.StartLine, cn.EndLine, 0); err == nil { + if source, _, _, err := s.readLinesForCtx(ctx, absPath, cn.StartLine, cn.EndLine, 0); err == nil { entry["source"] = source } } @@ -910,7 +961,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque var testPatterns []map[string]any if prefix != "" { // Search for test functions that match the example name. - testSearch := s.scopedNodeSlice(ctx, s.engine.SearchSymbols(node.Name, 20)) + testSearch := s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(node.Name, 20)) for _, tn := range testSearch { if !isTestFile(tn.FilePath) { continue @@ -927,7 +978,7 @@ func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolReque // Get test source. if tn.StartLine > 0 && tn.EndLine > 0 { if absPath, err := s.resolveNodePath(tn); err == nil { - if source, _, _, err := readLines(absPath, tn.StartLine, tn.EndLine, 0); err == nil { + if source, _, _, err := s.readLinesForCtx(ctx, absPath, tn.StartLine, tn.EndLine, 0); err == nil { entry["source"] = source } } @@ -1015,7 +1066,7 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) // Order 0: The changed symbols themselves (definitions). for _, id := range ids { - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { continue } @@ -1024,7 +1075,7 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) // Check if symbol is an interface — implementations need updating. if node.Kind == graph.KindInterface { - impls := s.scopedNodeSlice(ctx, s.engine.FindImplementations(id)) + impls := s.scopedNodeSlice(ctx, s.engineFor(ctx).FindImplementations(id)) for _, impl := range impls { addFile(impl.FilePath, impl.Name, "implements "+node.Name+" — must conform to changes", 1) } @@ -1032,10 +1083,10 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) // Check MemberOf — if changing a type, its methods may need updating. if node.Kind == graph.KindType || node.Kind == graph.KindInterface { - inEdges := s.engine.GetInEdges(id) + inEdges := s.engineFor(ctx).GetInEdges(id) for _, e := range inEdges { if e.Kind == graph.EdgeMemberOf { - memberNode := s.engine.GetSymbol(e.From) + memberNode := s.engineFor(ctx).GetSymbol(e.From) if memberNode != nil { addFile(memberNode.FilePath, memberNode.Name, "member of "+node.Name, 1) } @@ -1046,7 +1097,7 @@ func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) // Order 2-N: Dependents at increasing depth (callers/importers). for _, id := range ids { - dependents := s.engine.GetDependents(id, query.QueryOptions{Depth: depth, Limit: 100, Detail: "brief"}) + dependents := s.engineFor(ctx).GetDependents(id, query.QueryOptions{Depth: depth, Limit: 100, Detail: "brief"}) for _, dn := range dependents.Nodes { if dn.Kind == graph.KindFile { continue @@ -1152,7 +1203,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest if len(kw) < 3 { continue } - matches := s.scopedNodeSlice(ctx, s.engine.SearchSymbols(kw, 10)) + matches := s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(kw, 10)) for _, m := range matches { if m.Kind == graph.KindFile || m.Kind == graph.KindImport { continue @@ -1168,10 +1219,10 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest var entryNode *graph.Node if entryPoint != "" { // Try as symbol ID first. - entryNode = s.engine.GetSymbol(entryPoint) + entryNode = s.engineFor(ctx).GetSymbol(entryPoint) if entryNode == nil { // Try as file path — get the most important symbol in the file. - fileSym := s.engine.GetFileSymbols(entryPoint) + fileSym := s.engineFor(ctx).GetFileSymbols(entryPoint) if len(fileSym.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileSym.Nodes[0]) { fileSym = &query.SubGraph{} } @@ -1226,7 +1277,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest if seen[missedID] { continue } - missedNode := s.graph.GetNode(missedID) + missedNode := s.readerFor(ctx).GetNode(missedID) if missedNode == nil { continue } @@ -1271,7 +1322,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest (sym.Kind == graph.KindFunction || sym.Kind == graph.KindMethod) && sym.StartLine > 0 && sym.EndLine > 0 { if absPath, err := s.resolveNodePath(sym); err == nil { - if source, _, totalFileChars, err := readLines(absPath, sym.StartLine, sym.EndLine, 0); err == nil { + if source, _, totalFileChars, err := s.readLinesForCtx(ctx, absPath, sym.StartLine, sym.EndLine, 0); err == nil { entry["source"] = source sourcesEmbedded++ returned := tokens.CountInt64(source) @@ -1290,13 +1341,13 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest crossSeen := make(map[string]bool) for _, sym := range relevantSymbols { // Check outgoing edges for cross-repo references. - outEdges := s.engine.GetOutEdges(sym.ID) + outEdges := s.engineFor(ctx).GetOutEdges(sym.ID) for _, e := range outEdges { if !e.CrossRepo || crossSeen[e.To] { continue } crossSeen[e.To] = true - targetNode := s.engine.GetSymbol(e.To) + targetNode := s.engineFor(ctx).GetSymbol(e.To) if targetNode == nil { continue } @@ -1322,7 +1373,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest // 6. If we have an entry point, get its pattern (registration, siblings, tests). if entryNode != nil { // File context: imports and structure. - fileCtx := s.engine.GetFileSymbols(entryNode.FilePath) + fileCtx := s.engineFor(ctx).GetFileSymbols(entryNode.FilePath) if len(fileCtx.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileCtx.Nodes[0]) { fileCtx = &query.SubGraph{} } @@ -1335,7 +1386,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest result["entry_file_symbols"] = fileSymbols // Callers and callees. - callers := s.engine.GetCallers(entryNode.ID, query.QueryOptions{Depth: 1, Limit: 5, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(entryNode.ID, query.QueryOptions{Depth: 1, Limit: 5, Detail: "brief"}) var callerIDs []string for _, cn := range callers.Nodes { if cn.ID != entryNode.ID { @@ -1346,7 +1397,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest result["callers"] = callerIDs } - callees := s.engine.GetCallChain(entryNode.ID, query.QueryOptions{Depth: 1, Limit: 5, Detail: "brief"}) + callees := s.engineFor(ctx).GetCallChain(entryNode.ID, query.QueryOptions{Depth: 1, Limit: 5, Detail: "brief"}) var calleeIDs []string for _, cn := range callees.Nodes { if cn.ID != entryNode.ID { @@ -1362,7 +1413,7 @@ func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest var testFiles []string testSeen := make(map[string]bool) for _, sym := range relevantSymbols { - callers := s.engine.GetCallers(sym.ID, query.QueryOptions{Depth: 2, Limit: 20, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(sym.ID, query.QueryOptions{Depth: 2, Limit: 20, Detail: "brief"}) for _, cn := range callers.Nodes { if isTestFile(cn.FilePath) && !testSeen[cn.FilePath] { testSeen[cn.FilePath] = true @@ -1456,7 +1507,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest return mcp.NewToolResultError("new_name is required"), nil } - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id), nil } @@ -1507,7 +1558,7 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest } // 2. All graph usages (calls, references, instantiates). - usages := s.engine.FindUsages(id) + usages := s.engineFor(ctx).FindUsages(id) for _, edge := range usages.Edges { if edge.Line == 0 { continue @@ -1534,12 +1585,12 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest // 3. MemberOf edges — if renaming a type, its methods' receiver annotations may reference it. if node.Kind == graph.KindType || node.Kind == graph.KindInterface { - inEdges := s.engine.GetInEdges(id) + inEdges := s.engineFor(ctx).GetInEdges(id) for _, edge := range inEdges { if edge.Kind != graph.EdgeMemberOf { continue } - memberNode := s.engine.GetSymbol(edge.From) + memberNode := s.engineFor(ctx).GetSymbol(edge.From) if memberNode == nil { continue } @@ -1628,7 +1679,7 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) return mcp.NewToolResultError("old_source and new_source are identical"), nil } - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id), nil } diff --git a/internal/mcp/tools_core.go b/internal/mcp/tools_core.go index 67f5c26..466895d 100644 --- a/internal/mcp/tools_core.go +++ b/internal/mcp/tools_core.go @@ -790,7 +790,7 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( s.ensureFresh([]string{parts[0]}) } - node := s.engine.GetSymbol(id) + node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id), nil } @@ -812,8 +812,8 @@ func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) ( } // Full: include node + direct edges. - out := s.engine.GetOutEdges(node.ID) - in := s.engine.GetInEdges(node.ID) + out := s.engineFor(ctx).GetOutEdges(node.ID) + in := s.engineFor(ctx).GetInEdges(node.ID) return s.respondJSONOrTOON(ctx, req, map[string]any{ "node": node, "out_edges": out, @@ -864,9 +864,9 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques var nodes []*graph.Node var primaryCount int if len(expandedTerms) > 0 { - nodes, primaryCount = fetchAndMergeBM25(s, q, expandedTerms, fetchLimit, scope) + nodes, primaryCount = fetchAndMergeBM25(s.engineFor(ctx), q, expandedTerms, fetchLimit, scope) } else { - nodes = s.engine.SearchSymbolsScoped(q, fetchLimit, scope) + nodes = s.engineFor(ctx).SearchSymbolsScoped(q, fetchLimit, scope) primaryCount = len(nodes) } mergedCount := len(nodes) // pre-filter; comparable to primaryCount @@ -1003,7 +1003,7 @@ func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolReques if len(pageBreakdown) > limit { pageBreakdown = pageBreakdown[:limit] } - resp["rerank"] = encodeRerankBreakdown(pageBreakdown, s.engine.Rerank()) + resp["rerank"] = encodeRerankBreakdown(pageBreakdown, s.engineFor(ctx).Rerank()) } return s.respondJSONOrTOON(ctx, req, resp) } @@ -1068,7 +1068,7 @@ func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolReque // Auto re-index stale file before querying. s.ensureFresh([]string{fp}) - sg := s.engine.GetFileSymbols(fp) + sg := s.engineFor(ctx).GetFileSymbols(fp) if len(sg.Nodes) == 0 { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } @@ -1127,7 +1127,7 @@ func (s *Server) handleGetDependencies(ctx context.Context, req mcp.CallToolRequ WorkspaceID: scopeWS, ProjectID: scopeProj, } - sg := s.engine.GetDependencies(id, opts) + sg := s.engineFor(ctx).GetDependencies(id, opts) sg.FilterByMinTier(minTier) enrichSubGraphEdges(sg) return s.returnSubGraph(ctx, req, sg) @@ -1148,7 +1148,7 @@ func (s *Server) handleGetDependents(ctx context.Context, req mcp.CallToolReques WorkspaceID: scopeWS, ProjectID: scopeProj, } - sg := s.engine.GetDependents(id, opts) + sg := s.engineFor(ctx).GetDependents(id, opts) sg.FilterByMinTier(minTier) enrichSubGraphEdges(sg) return s.returnSubGraph(ctx, req, sg) @@ -1169,7 +1169,7 @@ func (s *Server) handleGetCallChain(ctx context.Context, req mcp.CallToolRequest WorkspaceID: scopeWS, ProjectID: scopeProj, } - sg := s.engine.GetCallChain(id, opts) + sg := s.engineFor(ctx).GetCallChain(id, opts) // Apply repo/project/ref filter. allowed, filterErr := s.resolveRepoFilter(ctx, req) @@ -1198,7 +1198,7 @@ func (s *Server) handleGetCallers(ctx context.Context, req mcp.CallToolRequest) ProjectID: scopeProj, ExcludeTests: req.GetBool("exclude_tests", false), } - sg := s.engine.GetCallers(id, opts) + sg := s.engineFor(ctx).GetCallers(id, opts) sg.FilterByMinTier(minTier) enrichSubGraphEdges(sg) return s.returnSubGraph(ctx, req, sg) @@ -1214,9 +1214,9 @@ func (s *Server) handleFindOverrides(ctx context.Context, req mcp.CallToolReques var nodes []*graph.Node switch direction { case "parents", "overridden": - nodes = s.engine.FindOverridden(id) + nodes = s.engineFor(ctx).FindOverridden(id) default: - nodes = s.engine.FindOverridesMinTier(id, minTier) + nodes = s.engineFor(ctx).FindOverridesMinTier(id, minTier) } // Confine results to the session's workspace — these engine // methods don't take QueryOptions, so the boundary is enforced @@ -1256,7 +1256,7 @@ func (s *Server) handleFindImplementations(ctx context.Context, req mcp.CallTool return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") - impls := s.engine.FindImplementationsMinTier(id, minTier) + impls := s.engineFor(ctx).FindImplementationsMinTier(id, minTier) // Confine results to the session's workspace — FindImplementations // doesn't take QueryOptions, so the boundary is enforced here. impls = s.scopedNodeSlice(ctx, impls) @@ -1311,7 +1311,7 @@ func (s *Server) handleGetClassHierarchy(ctx context.Context, req mcp.CallToolRe ProjectID: scopeProj, MinTier: minTier, } - sg := s.engine.ClassHierarchy(id, direction, depth, includeMethods, opts) + sg := s.engineFor(ctx).ClassHierarchy(id, direction, depth, includeMethods, opts) enrichSubGraphEdges(sg) return s.returnSubGraph(ctx, req, sg) } @@ -1329,7 +1329,7 @@ func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) workspaceArg := req.GetString("workspace", "") projectArg := req.GetString("project", "") scopeWS, scopeProj := s.resolveQueryScope(ctx, workspaceArg, projectArg) - sg := s.engine.FindUsagesScoped(id, query.QueryOptions{ + sg := s.engineFor(ctx).FindUsagesScoped(id, query.QueryOptions{ WorkspaceID: scopeWS, ProjectID: scopeProj, ExcludeTests: req.GetBool("exclude_tests", false), @@ -1362,7 +1362,7 @@ func (s *Server) handleGetCluster(ctx context.Context, req mcp.CallToolRequest) WorkspaceID: scopeWS, ProjectID: scopeProj, } - sg := s.engine.GetCluster(id, opts) + sg := s.engineFor(ctx).GetCluster(id, opts) enrichSubGraphEdges(sg) return s.returnSubGraph(ctx, req, sg) } @@ -1375,7 +1375,7 @@ func (s *Server) handleGraphStats(ctx context.Context, req mcp.CallToolRequest) // emits. Shared with the `gortex://stats` resource so both surfaces // stay byte-for-byte equal. func (s *Server) buildGraphStatsPayload(ctx context.Context) map[string]any { - stats := s.engine.Stats() + stats := s.engineFor(ctx).Stats() result := map[string]any{ "total_nodes": stats.TotalNodes, "total_edges": stats.TotalEdges, @@ -1384,7 +1384,7 @@ func (s *Server) buildGraphStatsPayload(ctx context.Context) map[string]any { } if s.multiIndexer != nil && s.multiIndexer.IsMultiRepo() { - result["per_repo"] = s.graph.RepoStats() + result["per_repo"] = s.readerFor(ctx).RepoStats() } result["token_savings"] = s.tokenStatsFor(ctx).snapshot() diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index b53b8c2..3f0115e 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -457,7 +457,7 @@ func (s *Server) handlePrefetchContext(ctx context.Context, req mcp.CallToolRequ // 1. BM25 search on task description (weight 0.4) if task != "" { - searchResults := s.scopedNodeSlice(ctx, s.engine.SearchSymbols(task, 30)) + searchResults := s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(task, 30)) maxScore := 1.0 for i, n := range searchResults { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { @@ -493,7 +493,7 @@ func (s *Server) handlePrefetchContext(ctx context.Context, req mcp.CallToolRequ } } // Get neighbors at depth 1-2 - sg := s.engine.GetDependencies(rid, query.QueryOptions{Depth: 2, Limit: 30, Detail: "brief"}) + sg := s.engineFor(ctx).GetDependencies(rid, query.QueryOptions{Depth: 2, Limit: 30, Detail: "brief"}) for _, n := range sg.Nodes { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue @@ -514,7 +514,7 @@ func (s *Server) handlePrefetchContext(ctx context.Context, req mcp.CallToolRequ } } // Also check dependents (callers) - callers := s.engine.GetCallers(rid, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(rid, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief"}) for _, n := range callers.Nodes { if n.ID == rid || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue @@ -2253,7 +2253,7 @@ func (s *Server) handleDiffContext(ctx context.Context, req mcp.CallToolRequest) } // Callers (depth 1) - callers := s.engine.GetCallers(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) for _, cn := range callers.Nodes { if cn.ID != cs.ID { info.Callers = append(info.Callers, cn.ID) @@ -2261,7 +2261,7 @@ func (s *Server) handleDiffContext(ctx context.Context, req mcp.CallToolRequest) } // Callees (depth 1) - callees := s.engine.GetCallChain(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) + callees := s.engineFor(ctx).GetCallChain(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) for _, cn := range callees.Nodes { if cn.ID != cs.ID { info.Callees = append(info.Callees, cn.ID) @@ -2604,7 +2604,7 @@ func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) ( var ordered []editWithOrder for _, edit := range edits { - node := s.engine.GetSymbol(edit.SymbolID) + node := s.engineFor(ctx).GetSymbol(edit.SymbolID) order := 50 // default middle priority filePath := "" if node != nil { @@ -2619,7 +2619,7 @@ func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) ( continue } // Check if other calls this symbol - callers := s.engine.GetCallers(edit.SymbolID, query.QueryOptions{Depth: 1, Limit: 100, Detail: "brief"}) + callers := s.engineFor(ctx).GetCallers(edit.SymbolID, query.QueryOptions{Depth: 1, Limit: 100, Detail: "brief"}) for _, cn := range callers.Nodes { if cn.ID == other.SymbolID { order = 10 // this is a dependency — edit first @@ -2684,7 +2684,7 @@ func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) ( continue } - node := s.engine.GetSymbol(o.edit.SymbolID) + node := s.engineFor(ctx).GetSymbol(o.edit.SymbolID) if node == nil { results = append(results, batchEditResult{ SymbolID: o.edit.SymbolID, diff --git a/internal/mcp/tools_overlay.go b/internal/mcp/tools_overlay.go index 0e45c54..63085c4 100644 --- a/internal/mcp/tools_overlay.go +++ b/internal/mcp/tools_overlay.go @@ -60,6 +60,11 @@ func (s *Server) registerOverlayTools() { ), s.handleOverlayDrop, ) + + // compare_with_overlay runs a query against both base and the + // session's overlay view and returns the delta — the core + // payoff of the shadow-graph design. + s.registerOverlayDiffTool() } // overlaySessionID returns the calling MCP session ID, or a structured diff --git a/internal/mcp/tools_overlay_diff.go b/internal/mcp/tools_overlay_diff.go new file mode 100644 index 0000000..651d3e0 --- /dev/null +++ b/internal/mcp/tools_overlay_diff.go @@ -0,0 +1,158 @@ +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 +} diff --git a/internal/mcp/tools_planning.go b/internal/mcp/tools_planning.go index c00cb8a..e754072 100644 --- a/internal/mcp/tools_planning.go +++ b/internal/mcp/tools_planning.go @@ -46,7 +46,7 @@ func (s *Server) handlePlanTurn(ctx context.Context, req mcp.CallToolRequest) (* if len(kw) < 3 { continue } - for _, m := range s.scopedNodeSlice(ctx, s.engine.SearchSymbols(kw, 10)) { + for _, m := range s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(kw, 10)) { if m.Kind == graph.KindFile || m.Kind == graph.KindImport { continue } diff --git a/internal/mcp/tools_search_assist.go b/internal/mcp/tools_search_assist.go index b8fd83a..42c5606 100644 --- a/internal/mcp/tools_search_assist.go +++ b/internal/mcp/tools_search_assist.go @@ -160,8 +160,8 @@ func expandSearchTerms(ctx context.Context, s *Server, query string) []string { // primaryCount is the size of the original-query BM25 result before // merging; useful for diagnostic / debug surfaces that want to show // how many candidates expansion contributed. -func fetchAndMergeBM25(s *Server, original string, expanded []string, fetchLimit int, scope query.QueryOptions) (merged []*graph.Node, primaryCount int) { - primary := s.engine.SearchSymbolsScoped(original, fetchLimit, scope) +func fetchAndMergeBM25(eng *query.Engine, original string, expanded []string, fetchLimit int, scope query.QueryOptions) (merged []*graph.Node, primaryCount int) { + primary := eng.SearchSymbolsScoped(original, fetchLimit, scope) primaryCount = len(primary) if len(expanded) == 0 { return primary, primaryCount @@ -180,7 +180,7 @@ func fetchAndMergeBM25(s *Server, original string, expanded []string, fetchLimit if term == "" { continue } - extra := s.engine.SearchSymbolsScoped(term, fetchLimit, scope) + extra := eng.SearchSymbolsScoped(term, fetchLimit, scope) for _, n := range extra { if seen[n.ID] { continue @@ -245,11 +245,11 @@ const verifyCallersPerCand = 3 // each with name + truncated signature. The query depth is 1 (direct // callers only) and the brief detail level keeps memory pressure low. // Returns nil for non-callable kinds or when GetCallers yields nothing. -func topCallersForVerify(s *Server, n *graph.Node) []llm.CallerInfo { +func topCallersForVerify(eng *query.Engine, n *graph.Node) []llm.CallerInfo { if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod { return nil } - sg := s.engine.GetCallers(n.ID, query.QueryOptions{ + sg := eng.GetCallers(n.ID, query.QueryOptions{ Depth: 1, Limit: verifyCallersPerCand + 4, // over-fetch a little: self + non-callers get filtered Detail: "brief", @@ -369,7 +369,7 @@ func verifyWithLLM(ctx context.Context, s *Server, query string, nodes []*graph. Name: n.Name, Signature: sig, Body: extractBodyForVerify(s, n), - Callers: topCallersForVerify(s, n), + Callers: topCallersForVerify(s.engineFor(ctx), n), } idx[n.ID] = n dbg.Considered[i] = n.ID diff --git a/internal/mcp/tools_search_assist_test.go b/internal/mcp/tools_search_assist_test.go index cad9830..69968ce 100644 --- a/internal/mcp/tools_search_assist_test.go +++ b/internal/mcp/tools_search_assist_test.go @@ -171,7 +171,7 @@ func TestFetchAndMergeBM25_DedupesAcrossTerms(t *testing.T) { // Merging with the same term as an "expansion" must produce the // same list, not duplicates. - merged, primaryCount := fetchAndMergeBM25(srv, "helper", []string{"helper"}, 20, scope) + merged, primaryCount := fetchAndMergeBM25(srv.engine, "helper", []string{"helper"}, 20, scope) assert.Equal(t, len(primary), primaryCount) assert.Equal(t, idsOf(primary), idsOf(merged)) } @@ -183,7 +183,7 @@ func TestFetchAndMergeBM25_AppendsNewMatches(t *testing.T) { scope := query.QueryOptions{} primary := srv.engine.SearchSymbolsScoped("helper", 20, scope) - merged, primaryCount := fetchAndMergeBM25(srv, "helper", []string{"main"}, 20, scope) + merged, primaryCount := fetchAndMergeBM25(srv.engine, "helper", []string{"main"}, 20, scope) assert.Equal(t, len(primary), primaryCount) primaryIDs := idsOf(primary) diff --git a/internal/mcp/tools_winnow.go b/internal/mcp/tools_winnow.go index 9c291f2..861dd27 100644 --- a/internal/mcp/tools_winnow.go +++ b/internal/mcp/tools_winnow.go @@ -47,7 +47,7 @@ func (s *Server) WinnowForEval(query string, extras map[string]any, limit int) [ } } } - results := s.winnowSymbols(c, nil) + results := s.winnowSymbols(context.Background(), c, nil) out := make([]string, 0, len(results)) for _, r := range results { out = append(out, r.Node.ID) @@ -128,7 +128,7 @@ func (s *Server) handleWinnowSymbols(ctx context.Context, req mcp.CallToolReques return mcp.NewToolResultError(filterErr.Error()), nil } - results := s.winnowSymbols(c, allowed) + results := s.winnowSymbols(ctx, c, allowed) total := len(results) offset := decodeCursor(req.GetString("cursor", "")) if offset > total { @@ -146,7 +146,7 @@ func (s *Server) handleWinnowSymbols(ctx context.Context, req mcp.CallToolReques if s.isGCX(ctx, req) { var weights map[string]float64 - if pipeline := s.engine.Rerank(); pipeline != nil { + if pipeline := s.engineFor(ctx).Rerank(); pipeline != nil { weights = pipeline.Weights() } return s.gcxResponseWithBudget(req)(encodeWinnowSymbols(results, total, c.Limit, weights)) @@ -226,21 +226,25 @@ func parseWinnowConstraints(req mcp.CallToolRequest) (winnowConstraints, error) // winnowSymbols applies the constraint chain and returns ranked results. It // is package-internal so tests can exercise the filter/rank logic without -// the MCP request plumbing. -func (s *Server) winnowSymbols(c winnowConstraints, allowed map[string]bool) []winnowResult { +// the MCP request plumbing. The ctx parameter scopes graph reads to the +// caller's overlay view (when any) so winnow honours editor-buffer state +// just like search_symbols. +func (s *Server) winnowSymbols(ctx context.Context, c winnowConstraints, allowed map[string]bool) []winnowResult { + eng := s.engineFor(ctx) + reader := s.readerFor(ctx) var candidates []*graph.Node textScores := make(map[string]float64) - if c.TextMatch != "" && s.engine != nil { + if c.TextMatch != "" && eng != nil { // Pull a wider slice so structural filters have headroom. width := c.Limit*10 + 50 - nodes := s.engine.SearchSymbols(c.TextMatch, width) + nodes := eng.SearchSymbols(c.TextMatch, width) for rank, n := range nodes { textScores[n.ID] = 1.0 / float64(rank+1) candidates = append(candidates, n) } } else { - candidates = s.graph.AllNodes() + candidates = reader.AllNodes() } candidates = filterNodes(candidates, allowed) @@ -257,7 +261,7 @@ func (s *Server) winnowSymbols(c winnowConstraints, allowed map[string]bool) []w candidates = applyWinnowPrefilter(candidates, c) - fanIn, fanOut := computeFanInOut(s.graph, candidates) + fanIn, fanOut := computeFanInOut(reader, candidates) var nodeToComm map[string]string var labelToID map[string]string @@ -308,7 +312,7 @@ func (s *Server) winnowSymbols(c winnowConstraints, allowed map[string]bool) []w // pre-filtering above (kind / path / community / min-counts) stays // intact — winnow is a constraint chain that hands its kept rows // to the pipeline for ranking. - pipeline := s.engine.Rerank() + pipeline := eng.Rerank() if pipeline != nil && len(rows) > 0 { cands := make([]*rerank.Candidate, len(rows)) idxByID := make(map[string]int, len(rows)) @@ -336,7 +340,7 @@ func (s *Server) winnowSymbols(c winnowConstraints, allowed map[string]bool) []w cands[i] = &rerank.Candidate{Node: r.Node, TextRank: tr, VectorRank: -1} idxByID[r.Node.ID] = i } - rctx := s.buildRerankContext(context.Background(), c.TextMatch) + rctx := s.buildRerankContext(ctx, c.TextMatch) pipeline.Rerank(c.TextMatch, cands, rctx) ordered := make([]winnowResult, 0, len(cands)) @@ -466,7 +470,7 @@ func applyWinnowPrefilter(nodes []*graph.Node, c winnowConstraints) []*graph.Nod // computeFanInOut walks incoming and outgoing edges for each candidate and // counts fan-in (calls + references) and fan-out (calls). Scoped to the // candidate set so cost scales with |candidates|, not total graph size. -func computeFanInOut(g *graph.Graph, nodes []*graph.Node) (map[string]int, map[string]int) { +func computeFanInOut(g graph.Reader, nodes []*graph.Node) (map[string]int, map[string]int) { fanIn := make(map[string]int, len(nodes)) fanOut := make(map[string]int, len(nodes)) if g == nil { diff --git a/internal/mcp/tools_winnow_test.go b/internal/mcp/tools_winnow_test.go index b58e0f8..0db6f5a 100644 --- a/internal/mcp/tools_winnow_test.go +++ b/internal/mcp/tools_winnow_test.go @@ -75,7 +75,7 @@ func TestWinnowSymbols_FilterByKindAndPath(t *testing.T) { PathPrefix: []string{"svc/"}, Limit: 10, } - rows := srv.winnowSymbols(c, nil) + rows := srv.winnowSymbols(context.Background(), c, nil) require.NotEmpty(t, rows) for _, r := range rows { assert.Equal(t, graph.KindFunction, r.Node.Kind) @@ -88,7 +88,7 @@ func TestWinnowSymbols_LanguageFilterExcludesOthers(t *testing.T) { srv := buildWinnowGraph(t) c := winnowConstraints{Language: "typescript", Limit: 10} - rows := srv.winnowSymbols(c, nil) + rows := srv.winnowSymbols(context.Background(), c, nil) require.Len(t, rows, 1) assert.Equal(t, "web/api.ts::fetchUser", rows[0].Node.ID) } @@ -99,7 +99,7 @@ func TestWinnowSymbols_MinFanInDropsLeaves(t *testing.T) { // validateToken has fan_in=3 (2 calls + 1 reference); Login has 1; // Logout + HandleUser + fetchUser have 0. c := winnowConstraints{MinFanIn: 2, Limit: 10} - rows := srv.winnowSymbols(c, nil) + rows := srv.winnowSymbols(context.Background(), c, nil) require.Len(t, rows, 1) assert.Equal(t, "svc/auth.go::validateToken", rows[0].Node.ID) assert.Equal(t, 3, rows[0].FanIn) @@ -109,7 +109,7 @@ func TestWinnowSymbols_RankingByFanIn(t *testing.T) { srv := buildWinnowGraph(t) c := winnowConstraints{Kinds: []graph.NodeKind{graph.KindFunction}, Language: "go", Limit: 10} - rows := srv.winnowSymbols(c, nil) + rows := srv.winnowSymbols(context.Background(), c, nil) require.NotEmpty(t, rows) // validateToken should top the list because fan_in dominates when no @@ -123,8 +123,8 @@ func TestWinnowSymbols_RankingByFanIn(t *testing.T) { func TestWinnowSymbols_CommunityFilterByIDAndLabel(t *testing.T) { srv := buildWinnowGraph(t) - byID := srv.winnowSymbols(winnowConstraints{Community: "community-0", Limit: 10}, nil) - byLabel := srv.winnowSymbols(winnowConstraints{Community: "authz", Limit: 10}, nil) + byID := srv.winnowSymbols(context.Background(), winnowConstraints{Community: "community-0", Limit: 10}, nil) + byLabel := srv.winnowSymbols(context.Background(), winnowConstraints{Community: "authz", Limit: 10}, nil) require.Equal(t, len(byID), len(byLabel)) require.Greater(t, len(byID), 0) @@ -143,7 +143,7 @@ func TestWinnowSymbols_ChurnFilter(t *testing.T) { srv.symHistory.Record("svc/auth.go::Logout", false) c := winnowConstraints{MinChurn: 2, Limit: 10} - rows := srv.winnowSymbols(c, nil) + rows := srv.winnowSymbols(context.Background(), c, nil) require.Len(t, rows, 1) assert.Equal(t, "svc/auth.go::Login", rows[0].Node.ID) assert.Equal(t, 2, rows[0].Churn) diff --git a/internal/query/engine.go b/internal/query/engine.go index e17cc3e..a2afd86 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -15,12 +15,39 @@ import ( type SearchProvider func() search.Backend // Engine provides higher-level query operations over the graph. +// +// The graph is held as a `graph.Reader` rather than a concrete +// `*graph.Graph` so the same engine instance can serve both base- +// graph queries and overlay-aware queries (an `*graph.OverlaidView` +// also implements `graph.Reader`). `WithReader` returns a shallow +// clone that swaps the reader; the MCP overlay middleware uses it +// to scope a tool call to the calling session's shadow view without +// constructing a fresh Engine per request. type Engine struct { - g *graph.Graph + g graph.Reader searchProvider SearchProvider rerank *rerank.Pipeline } +// WithReader returns a shallow clone of the engine that reads +// through r instead of the original graph. The search provider and +// rerank pipeline are shared with the source engine. Pass the +// base graph reader to undo a previous swap. +func (e *Engine) WithReader(r graph.Reader) *Engine { + if e == nil { + return nil + } + clone := *e + clone.g = r + return &clone +} + +// Reader returns the engine's currently-bound graph reader. Tool +// handlers that need to walk the same view the engine sees use this +// to keep their direct-graph reads consistent with the engine's +// internal walks. +func (e *Engine) Reader() graph.Reader { return e.g } + // NewEngine creates a query engine wrapping the given graph. The // default 11-signal rerank.Pipeline is wired in; callers wanting a // custom signal set / weights override via SetRerank. diff --git a/internal/search/rerank/context.go b/internal/search/rerank/context.go index 240673d..a9ad128 100644 --- a/internal/search/rerank/context.go +++ b/internal/search/rerank/context.go @@ -11,10 +11,13 @@ import ( // All fields are optional; signals must gracefully degrade when a // data source is absent. The zero value is a valid Context. type Context struct { - // Graph is the indexed knowledge graph. Required for any signal - // that reads node metadata or walks edges (FanIn, FanOut, - // MinHash). When nil, those signals contribute 0. - Graph *graph.Graph + // Graph is the indexed knowledge graph reader. Required for any + // signal that reads node metadata or walks edges (FanIn, FanOut, + // MinHash). When nil, those signals contribute 0. Held as the + // `graph.Reader` interface so the editor-overlay path can pass + // an `*OverlaidView` here and have rerank signals score against + // the overlay's shadow graph just like base. + Graph graph.Reader // CommunityOf maps a node ID to its detected community ID. When // nil, the community signal contributes 0. From dab1add70e7e516a08f3030bd5b77f3c93478b3f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 15 May 2026 15:50:14 +0200 Subject: [PATCH 3/4] daemon, mcp: bind overlay lifecycle to MCP session; keepalive; longer TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "abandoned buffer pinned in the daemon, reachable to anyone who learns the session ID" attack surface, and fixes the related in-active-use TTL trip where a long query loop without further pushes would let the idle timer reap a session the editor still cares about. - Server.ReleaseSession (called when an MCP client disconnects) now drops the corresponding overlay synchronously and invalidates the cached parse layer. The TTL becomes a fail-safe for missed disconnects rather than the primary cleanup path. - OverlayManager.SnapshotFor (the per-request view-build read path) promotes to a write lock and bumps LastUsed. Any tools/call against a live overlay counts as activity; reading no longer races with writing for the lock either way since the lookup → bump sequence was already a tight critical section. - OverlayManager gains a Touch primitive (idempotent LastUsed refresh without altering files) and StatusFor (read-only liveness query — workspace, created, last_used, idle_seconds, idle_ttl_seconds, expires_at). StatusFor deliberately does NOT bump LastUsed so that a misconfigured editor polling overlay_list can't keep a dropped session alive forever. - daemon.DefaultOverlayIdleTTL raised 5m → 30m. New daemon.OverlayIdleTTLFromEnv(override) resolves precedence: caller-supplied non-zero > GORTEX_OVERLAY_IDLE_TTL env var (time.ParseDuration syntax) > default. Garbage env values fall through to the default; we don't fail startup over a typo'd duration. Wired at both construction sites (cmd/gortex/server.go, cmd/gortex/daemon_state.go). - New overlay_keepalive MCP tool — explicit no-op heartbeat for genuine idle gaps (debugger pause, refactor wizard, deliberation) cheaper than re-pushing buffer content. Returns fresh expires_at / idle_seconds metadata so the editor can schedule the next keepalive. - overlay_list now returns created_at / last_used_at / expires_at / idle_seconds / idle_ttl_seconds and switches to the non-LastUsed-bumping StatusFor + Files read path so polling doesn't abuse the lease. The expired-session branch surfaces expired: true so the editor can detect and recover without comparing to its own state. - overlay_push / overlay_delete / overlay_drop now invalidate the per-session parsed-overlay cache so the next tools/call re-parses with fresh buffer state. (Previously the invalidate only ran on drop; mid-session pushes could observe a stale cached parse on the very next call.) - Error surfacing on stale-session paths consolidated: overlay_keepalive returns "session has been dropped or never registered — call overlay_register before pushing" so the editor knows to recover; tools/call falls through to base (no error, no leak); overlay_push self-heals. - 8 new tests across daemon + mcp covering: SnapshotFor bumps LastUsed but StatusFor does not; Touch extends lease and errors on unknown session; OverlayIdleTTLFromEnv override/env/garbage/unset precedence; ReleaseSession drops the overlay synchronously and subsequent tools/call doesn't see it; overlay_keepalive happy path + missing-session error; overlay_list response shape includes expiry metadata. Full suite: 3846 pass under go test -race ./... - CLAUDE.md, internal/agents/instructions.go, README.md describe the new lifecycle / TTL story (session-bound; 30m default; GORTEX_OVERLAY_IDLE_TTL; keepalive; reads-bump-LastUsed). --- CLAUDE.md | 6 +- README.md | 3 +- cmd/gortex/daemon_state.go | 14 ++-- cmd/gortex/server.go | 15 ++-- internal/agents/instructions.go | 6 +- internal/daemon/overlay.go | 122 ++++++++++++++++++++++++++++++- internal/daemon/overlay_test.go | 106 +++++++++++++++++++++++++++ internal/mcp/overlay_e2e_test.go | 84 +++++++++++++++++++++ internal/mcp/server.go | 14 ++++ internal/mcp/tools_overlay.go | 88 +++++++++++++++++++--- 10 files changed, 428 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0a41133..7c59314 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -253,7 +253,11 @@ Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex compo **HTTP transport.** `gortex server` exposes the same surface at `POST /v1/overlay/sessions` (optional `session_id` binds an overlay to a known MCP session), `PUT /v1/overlay/sessions/{id}/files`, `DELETE /v1/overlay/sessions/{id}/files`, `GET /v1/overlay/sessions/{id}/files`, `DELETE /v1/overlay/sessions/{id}`. The `/v1/tools/` HTTP entry point reads the active session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=` (test fallback). -**Sessions auto-expire** after 5 minutes of inactivity, so a crashed extension never leaks unsaved buffers indefinitely. +**Lifecycle and lease.** The overlay is **bound to the MCP session that registered it.** When the MCP session ends — for any reason (clean disconnect, dropped TCP, daemon proxy teardown) — the overlay is dropped synchronously. That closes the "abandoned buffer pinned in the daemon, reachable by anyone who learns the session ID" attack surface that a pure-TTL lifecycle would expose. + +The idle TTL is a fail-safe for the case where the daemon never observes the disconnect (e.g. the process was killed -9 mid-stream). Default **30 minutes**, configurable via `GORTEX_OVERLAY_IDLE_TTL` (`30m` / `1h` / `45s` / `0` to disable for tests). Every tool call against a live overlay session refreshes the idle timer (`SnapshotFor` bumps `LastUsed`), so an editor that's actively querying never trips the TTL. The MCP `overlay_keepalive` tool exists for genuine idle gaps (debugger pause, IDE wizard) — it bumps the timer without re-pushing content. `overlay_list` returns `expires_at` / `idle_seconds` / `idle_ttl_seconds` so the extension can schedule keepalives proactively. + +If a tools/call references a stale (reaped) session ID, the call falls through to base — no error, no corruption, no content leak. The next `overlay_push` self-heals (auto-registers the session); `overlay_keepalive` / `overlay_delete` surface explicit "session has been dropped" errors so the editor can take corrective action. ### MCP Resources diff --git a/README.md b/README.md index dd4d720..c30e771 100644 --- a/README.md +++ b/README.md @@ -451,9 +451,10 @@ Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex compo | `overlay_list` | List every overlay attached to the session — path / size / deleted / base_sha | | `overlay_delete` | Remove one overlay from the session | | `overlay_drop` | Tear down the session and discard every overlay | +| `overlay_keepalive` | Refresh the session's idle timer without re-pushing buffer content; cheap option for debugger / wizard pauses | | `compare_with_overlay` | Run `find_usages` / `get_callers` / `get_call_chain` / `get_dependencies` / `get_dependents` against base AND overlay; returns added / removed / common ID sets | -HTTP transport mirrors the surface at `/v1/overlay/sessions/*`; the `/v1/tools/` entry point reads the overlay session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=`. Sessions auto-expire after 5 minutes of inactivity. +HTTP transport mirrors the surface at `/v1/overlay/sessions/*`; the `/v1/tools/` entry point reads the overlay session from `Mcp-Session-Id` (preferred), `X-Gortex-Overlay-Session`, or `?session_id=`. **Overlays are bound to their MCP session** — when the session ends the overlay is dropped synchronously, so abandoned buffers never linger. Idle TTL is a fail-safe (default 30 min, configurable via `GORTEX_OVERLAY_IDLE_TTL`); every tool call against a live overlay refreshes it. ## MCP Resources (16) diff --git a/cmd/gortex/daemon_state.go b/cmd/gortex/daemon_state.go index 00f9729..b7dd543 100644 --- a/cmd/gortex/daemon_state.go +++ b/cmd/gortex/daemon_state.go @@ -289,14 +289,12 @@ func buildDaemonState(logger *zap.Logger) (*daemonState, error) { gortexmcp.Version = version srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules, multiOpts...) - // Editor-overlay manager for live-buffer indexing. The MCP - // dispatcher binds the inbound MCP session ID to an overlay - // session at every `tools/call`; the tool handler middleware - // applies the overlay's editor buffers to the in-memory graph - // for the duration of the call. 5-minute idle TTL: long enough - // for an editor's keystroke→tool-call loop, short enough that a - // crashed extension doesn't leak unsaved buffers forever. - overlays := daemon.NewOverlayManager(5 * time.Minute) + // Editor-overlay manager. Idle TTL resolved via + // GORTEX_OVERLAY_IDLE_TTL > daemon.DefaultOverlayIdleTTL (30m). + // Server.ReleaseSession (called on MCP-client disconnect) drops + // the overlay synchronously, so the TTL is only the fallback + // path for missed disconnects. + overlays := daemon.NewOverlayManager(daemon.OverlayIdleTTLFromEnv(0)) srv.SetOverlayManager(overlays) // Semantic manager, feedback, savings — same wiring as runServe. diff --git a/cmd/gortex/server.go b/cmd/gortex/server.go index e5dcdc9..a5502fd 100644 --- a/cmd/gortex/server.go +++ b/cmd/gortex/server.go @@ -362,14 +362,15 @@ func runServer(_ *cobra.Command, _ []string) error { // an MCP client to push, query, and tear down between turns; // short enough that a crashed client doesn't leak buffers // indefinitely. - overlays := daemon.NewOverlayManager(5 * time.Minute) + // Editor-overlay manager. Idle TTL resolution: + // GORTEX_OVERLAY_IDLE_TTL env var > daemon.DefaultOverlayIdleTTL. + // Tests can disable expiry by setting GORTEX_OVERLAY_IDLE_TTL=0. + // Most relevant security guarantee: when the MCP session ends, + // Server.ReleaseSession drops the overlay immediately, so the + // TTL is a fail-safe for missed disconnects rather than the + // primary cleanup path. + overlays := daemon.NewOverlayManager(daemon.OverlayIdleTTLFromEnv(0)) serverHandler.SetOverlayManager(overlays) - // Wire the same manager into the MCP server so the `tools/call` - // middleware can apply per-session overlays to the in-memory - // graph for the duration of each tool call, and so the - // overlay_register / overlay_push / overlay_list / overlay_delete - // / overlay_drop MCP tools become live for editor extensions - // speaking MCP rather than the parallel /v1/overlay/* HTTP API. srv.SetOverlayManager(overlays) // Wire the multi-server router. When `~/.gortex/servers.toml` is diff --git a/internal/agents/instructions.go b/internal/agents/instructions.go index 78716ce..e9c6cb3 100644 --- a/internal/agents/instructions.go +++ b/internal/agents/instructions.go @@ -385,7 +385,11 @@ Editor extensions push in-flight (unsaved) buffers as **overlays**. Gortex compo | Tearing down an overlay session | ` + "`overlay_drop`" + ` — discards every overlay attached to the session in one call | | Previewing impact of an unsaved edit | ` + "`compare_with_overlay`" + ` with ` + "`kind: find_usages / get_callers / get_call_chain / get_dependencies / get_dependents`" + ` — runs the query against base and overlay simultaneously, returns added / removed / common ID sets | -Pass an editor-captured git blob SHA as ` + "`base_sha`" + ` on ` + "`overlay_push`" + ` to enable drift detection: when the next tool call needs that path, Gortex compares ` + "`base_sha`" + ` to the on-disk hash and returns ` + "`overlay base SHA mismatch`" + ` if they disagree, so the client knows to re-read and resubmit. Push with ` + "`deleted: true`" + ` to model a tombstone — the file's symbols vanish from the shadow view (but are untouched in base). Sessions auto-expire after 5 minutes of inactivity. HTTP transport mirrors the surface at ` + "`/v1/overlay/sessions/*`" + `; the ` + "`/v1/tools/`" + ` entry reads the active session from ` + "`Mcp-Session-Id`" + ` / ` + "`X-Gortex-Overlay-Session`" + ` / ` + "`?session_id=`" + `. +Pass an editor-captured git blob SHA as ` + "`base_sha`" + ` on ` + "`overlay_push`" + ` to enable drift detection: when the next tool call needs that path, Gortex compares ` + "`base_sha`" + ` to the on-disk hash and returns ` + "`overlay base SHA mismatch`" + ` if they disagree, so the client knows to re-read and resubmit. Push with ` + "`deleted: true`" + ` to model a tombstone — the file's symbols vanish from the shadow view (but are untouched in base). + +**Lifecycle.** Overlays are bound to the MCP session that registered them — when the MCP session ends, the overlay is dropped synchronously. Idle TTL (default 30 min, configurable via ` + "`GORTEX_OVERLAY_IDLE_TTL`" + `) is a fail-safe for missed disconnects; every tool call against a live overlay refreshes it. Use ` + "`overlay_keepalive`" + ` for genuine idle gaps without re-pushing content. ` + "`overlay_list`" + ` returns ` + "`expires_at`" + ` / ` + "`idle_seconds`" + ` so extensions can schedule keepalives proactively. + +HTTP transport mirrors the surface at ` + "`/v1/overlay/sessions/*`" + `; the ` + "`/v1/tools/`" + ` entry reads the active session from ` + "`Mcp-Session-Id`" + ` / ` + "`X-Gortex-Overlay-Session`" + ` / ` + "`?session_id=`" + `. ### MCP Resources diff --git a/internal/daemon/overlay.go b/internal/daemon/overlay.go index 8b1383c..5c1b3e5 100644 --- a/internal/daemon/overlay.go +++ b/internal/daemon/overlay.go @@ -2,11 +2,50 @@ package daemon import ( "errors" + "os" "sort" "sync" "time" ) +// DefaultOverlayIdleTTL is the default per-session idle expiry for +// editor-buffer overlays. Raised from the original 5-minute draft to +// 30 minutes after field feedback: realistic coding sessions +// (deliberation, debugger pauses, IDE refactor wizards) commonly +// exceed five minutes without producing a Push, and the read-time +// LastUsed bump in SnapshotFor already keeps the session alive for +// any tool call. Thirty minutes is comfortably above typical +// inactivity gaps and well below the point where a forgotten +// disconnected client would meaningfully pin memory. +const DefaultOverlayIdleTTL = 30 * time.Minute + +// OverlayIdleTTLFromEnv resolves the idle-TTL setting that +// `gortex server` / `gortex daemon` should construct their +// OverlayManager with. Precedence: +// +// 1. Caller-supplied override (non-zero). +// 2. `GORTEX_OVERLAY_IDLE_TTL` env var, parsed by time.ParseDuration +// ("30m", "1h", "45s", etc.). +// 3. DefaultOverlayIdleTTL. +// +// A negative parse result clamps to 0 (which means "no expiry" — +// useful only for tests). Garbage env values fall back to the +// default; we deliberately don't fail startup over a typo'd duration. +func OverlayIdleTTLFromEnv(override time.Duration) time.Duration { + if override > 0 { + return override + } + if raw := os.Getenv("GORTEX_OVERLAY_IDLE_TTL"); raw != "" { + if d, err := time.ParseDuration(raw); err == nil { + if d < 0 { + d = 0 + } + return d + } + } + return DefaultOverlayIdleTTL +} + // OverlayFile is one editor-buffer override pushed by an MCP client. // The daemon (or a remote graph service over the gateway) merges // these on top of the base graph view for the duration of a session. @@ -157,6 +196,77 @@ func (m *OverlayManager) FileCount(sessionID string) int { return len(sess.files) } +// Touch refreshes a session's idle timer without altering its overlay +// files. Called by the MCP `overlay_keepalive` tool so an editor can +// explicitly extend the lease without re-pushing buffer content. +// Returns ErrSessionNotFound when the session doesn't exist so the +// keepalive tool can surface "session lost; please re-register". +func (m *OverlayManager) Touch(sessionID string) error { + if m == nil { + return ErrSessionNotFound + } + m.mu.Lock() + defer m.mu.Unlock() + sess, ok := m.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + sess.LastUsed = time.Now() + return nil +} + +// IdleTTL returns the configured idle expiry duration. Exposed so the +// overlay_list tool can compute and surface an `expires_at` hint to +// editor extensions that want to schedule a keepalive proactively. +// Zero means "no expiry" (test-mode). +func (m *OverlayManager) IdleTTL() time.Duration { + if m == nil { + return 0 + } + return m.idleTTL +} + +// SessionStatus is the per-session liveness snapshot reported through +// overlay_list. Callers compare `IdleSeconds` to `IdleTTLSeconds` to +// decide when to push a keepalive; `ExpiresAt` (RFC3339) is the +// concrete wall-clock instant the janitor would reap the session if +// no further activity arrived. +type SessionStatus struct { + WorkspaceID string + Created time.Time + LastUsed time.Time + IdleSeconds float64 + IdleTTLSeconds float64 + ExpiresAt time.Time // zero when idleTTL <= 0 +} + +// StatusFor returns liveness metadata for a session without touching +// LastUsed (unlike SnapshotFor, which is a read-with-bump). Used by +// overlay_list to render expiry hints without resetting the timer +// every time the editor polls. +func (m *OverlayManager) StatusFor(sessionID string) (SessionStatus, error) { + if m == nil { + return SessionStatus{}, ErrSessionNotFound + } + m.mu.RLock() + defer m.mu.RUnlock() + sess, ok := m.sessions[sessionID] + if !ok { + return SessionStatus{}, ErrSessionNotFound + } + st := SessionStatus{ + WorkspaceID: sess.WorkspaceID, + Created: sess.Created, + LastUsed: sess.LastUsed, + IdleSeconds: time.Since(sess.LastUsed).Seconds(), + IdleTTLSeconds: m.idleTTL.Seconds(), + } + if m.idleTTL > 0 { + st.ExpiresAt = sess.LastUsed.Add(m.idleTTL) + } + return st, nil +} + // SnapshotFor returns the overlay files for a session in a stable, // path-sorted order along with the workspace slug captured at register // time. Returns ErrSessionNotFound when the session doesn't exist. The @@ -171,12 +281,20 @@ func (m *OverlayManager) SnapshotFor(sessionID string) (workspace string, files if m == nil { return "", nil, ErrSessionNotFound } - m.mu.RLock() - defer m.mu.RUnlock() + // Promoted to write lock so we can refresh LastUsed alongside + // the snapshot copy: every tool-call view-build flows through + // here, and that activity must reset the idle timer. Without + // this, a session that only queries (no further Push) would + // trip the TTL while in active use. The cost is one extra + // mutex promotion per overlay-active tool call — negligible + // against the parse work the view builder is about to do. + m.mu.Lock() + defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return "", nil, ErrSessionNotFound } + sess.LastUsed = time.Now() out := make([]OverlayFile, 0, len(sess.files)) for _, f := range sess.files { out = append(out, f) diff --git a/internal/daemon/overlay_test.go b/internal/daemon/overlay_test.go index 4792c04..4120a49 100644 --- a/internal/daemon/overlay_test.go +++ b/internal/daemon/overlay_test.go @@ -2,6 +2,7 @@ package daemon import ( "errors" + "os" "testing" "time" @@ -108,3 +109,108 @@ func TestOverlayManager_SweepIdleHonoursTTL(t *testing.T) { require.Equal(t, 1, dropped, "session past idleTTL must be reaped") require.False(t, m.Has(id)) } + +// TestOverlayManager_SnapshotForBumpsLastUsed proves the load-bearing +// "reads count as activity" guarantee: a session that only queries +// (no further Push) keeps its lease alive as long as the view-build +// path keeps calling SnapshotFor. Without this a long sequence of +// tool calls without intervening pushes would let the TTL trip the +// session in active use. +func TestOverlayManager_SnapshotForBumpsLastUsed(t *testing.T) { + m := NewOverlayManager(60 * time.Millisecond) + id := m.Register("ws") + require.NoError(t, m.Push(id, OverlayFile{Path: "x.go", Content: "x"}, nil)) + + // Two TTL/3 sleeps with a SnapshotFor in between — total span > + // TTL — but the read bumps LastUsed so the session survives. + time.Sleep(30 * time.Millisecond) + _, _, err := m.SnapshotFor(id) + require.NoError(t, err, "SnapshotFor must bump LastUsed") + time.Sleep(30 * time.Millisecond) + require.True(t, m.Has(id), "session must survive when reads bump LastUsed") + + // Now truly idle: skip the read, sleep past TTL, sweep — should + // be reaped. + time.Sleep(80 * time.Millisecond) + dropped := m.SweepIdle() + require.Equal(t, 1, dropped) + require.False(t, m.Has(id)) +} + +// TestOverlayManager_TouchExtendsLease verifies the keepalive +// primitive: Touch refreshes LastUsed without altering files. +func TestOverlayManager_TouchExtendsLease(t *testing.T) { + m := NewOverlayManager(40 * time.Millisecond) + id := m.Register("ws") + require.NoError(t, m.Push(id, OverlayFile{Path: "y.go", Content: "y"}, nil)) + + time.Sleep(25 * time.Millisecond) + require.NoError(t, m.Touch(id)) + time.Sleep(25 * time.Millisecond) + require.True(t, m.Has(id), "Touch must keep the session alive past the original TTL window") + + // Touch on unknown session reports a structured error. + require.ErrorIs(t, m.Touch("nope"), ErrSessionNotFound) +} + +// TestOverlayManager_StatusForDoesNotBumpLastUsed makes sure the +// liveness-query path is read-only: polling overlay_list every +// second must NOT extend the lease (otherwise a misconfigured +// editor could keep a dropped session alive forever just by +// polling). +func TestOverlayManager_StatusForDoesNotBumpLastUsed(t *testing.T) { + m := NewOverlayManager(40 * time.Millisecond) + id := m.Register("ws") + require.NoError(t, m.Push(id, OverlayFile{Path: "z.go", Content: "z"}, nil)) + + // Poll status every 10ms; total elapsed > TTL. Without + // LastUsed-bump on StatusFor, the sweeper still reaps. + for i := 0; i < 6; i++ { + _, err := m.StatusFor(id) + require.NoError(t, err) + time.Sleep(10 * time.Millisecond) + } + dropped := m.SweepIdle() + require.Equal(t, 1, dropped, "StatusFor must NOT bump LastUsed; sweep should reap the idle session") + require.False(t, m.Has(id)) +} + +// TestOverlayManager_StatusForReportsExpiryMetadata: overlay_list +// surfaces this metadata so editor extensions can schedule +// keepalive proactively. +func TestOverlayManager_StatusForReportsExpiryMetadata(t *testing.T) { + m := NewOverlayManager(2 * time.Minute) + id := m.Register("ws-alpha") + st, err := m.StatusFor(id) + require.NoError(t, err) + require.Equal(t, "ws-alpha", st.WorkspaceID) + require.False(t, st.Created.IsZero()) + require.False(t, st.LastUsed.IsZero()) + require.InDelta(t, 0, st.IdleSeconds, 1.0, "newly-registered session has near-zero idle time") + require.InDelta(t, 120, st.IdleTTLSeconds, 0.5) + require.False(t, st.ExpiresAt.IsZero()) + require.True(t, st.ExpiresAt.After(time.Now()), "expires_at must be in the future") +} + +// TestOverlayIdleTTLFromEnv covers the three branches of the +// configuration resolution: explicit override > env var > default. +func TestOverlayIdleTTLFromEnv(t *testing.T) { + original := os.Getenv("GORTEX_OVERLAY_IDLE_TTL") + defer os.Setenv("GORTEX_OVERLAY_IDLE_TTL", original) + + // Explicit non-zero override wins over env. + os.Setenv("GORTEX_OVERLAY_IDLE_TTL", "1h") + require.Equal(t, 7*time.Minute, OverlayIdleTTLFromEnv(7*time.Minute)) + + // Env var (no override). + os.Setenv("GORTEX_OVERLAY_IDLE_TTL", "45m") + require.Equal(t, 45*time.Minute, OverlayIdleTTLFromEnv(0)) + + // Garbage env: fall through to default (don't fail startup). + os.Setenv("GORTEX_OVERLAY_IDLE_TTL", "garbage") + require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0)) + + // Unset env: default. + os.Unsetenv("GORTEX_OVERLAY_IDLE_TTL") + require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0)) +} diff --git a/internal/mcp/overlay_e2e_test.go b/internal/mcp/overlay_e2e_test.go index 2f2fad5..0db8d40 100644 --- a/internal/mcp/overlay_e2e_test.go +++ b/internal/mcp/overlay_e2e_test.go @@ -454,6 +454,90 @@ func Caller() { require.Contains(t, body, "caller.go") } +// TestOverlay_DroppedOnMCPSessionRelease is the security-critical +// guarantee: when an MCP session ends, its overlay must die +// immediately so no future connection that learns or guesses the +// same session ID can re-attach to abandoned buffers. The TTL is a +// fail-safe; ReleaseSession is the fast path. +func TestOverlay_DroppedOnMCPSessionRelease(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "secure-bind" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n\nfunc Secret() {}\n", + }, nil)) + require.True(t, srv.OverlayManager().Has(sessID)) + + srv.ReleaseSession(sessID) + require.False(t, srv.OverlayManager().Has(sessID), + "MCP session release must drop the overlay synchronously") + + // A subsequent tools/call carrying the (now-stale) session ID + // must fall through to base — NOT re-attach to abandoned state. + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{ + "path": filepath.Base(targetFile), + }) + require.NotContains(t, toolText(res), "Secret", + "a tools/call after MCP session release must not see the dropped overlay") +} + +// TestOverlay_KeepaliveExtendsLease exercises the MCP keepalive +// tool: an editor that's paused (on a breakpoint, in a long +// refactor wizard) can extend the lease without re-pushing buffer +// content. +func TestOverlay_KeepaliveExtendsLease(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "keepalive-test" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n", + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "overlay_keepalive", map[string]any{}) + require.False(t, res.IsError, "overlay_keepalive: %s", toolText(res)) + body := toolText(res) + require.Contains(t, body, `"session_id":"keepalive-test"`) + require.Contains(t, body, `"idle_seconds"`) + require.Contains(t, body, `"idle_ttl_seconds"`) +} + +// TestOverlay_KeepaliveOnMissingSessionReturnsError: keepalive on +// an unknown / reaped session surfaces a clear "session has been +// dropped" error so the editor knows to overlay_register and re-push. +func TestOverlay_KeepaliveOnMissingSessionReturnsError(t *testing.T) { + srv, _, _, _ := setupOverlayServer(t) + ctx := WithSessionID(context.Background(), "ghost-session") + res := callToolByName(t, srv, ctx, "overlay_keepalive", map[string]any{}) + require.True(t, res.IsError, "missing session must surface as a structured error") + require.Contains(t, toolText(res), "dropped or never registered") +} + +// TestOverlay_ListExposesExpiryMetadata: overlay_list reports the +// per-session liveness fields so editor extensions can schedule +// the next keepalive proactively. +func TestOverlay_ListExposesExpiryMetadata(t *testing.T) { + srv, _, targetFile, _ := setupOverlayServer(t) + sessID := "expires-test" + require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, "")) + require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{ + Path: targetFile, + Content: "package main\n\nfunc Target() {}\n", + }, nil)) + + ctx := WithSessionID(context.Background(), sessID) + res := callToolByName(t, srv, ctx, "overlay_list", map[string]any{}) + require.False(t, res.IsError) + body := toolText(res) + require.Contains(t, body, `"expires_at"`) + require.Contains(t, body, `"last_used_at"`) + require.Contains(t, body, `"idle_seconds"`) + require.Contains(t, body, `"idle_ttl_seconds"`) +} + // baseNodeIDs returns a sorted slice of every node ID in the base // graph. Used to verify the shadow-graph design's load-bearing // invariant: base is never mutated during overlay processing. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 95460c9..fbefc0e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -201,6 +201,20 @@ func (s *Server) ReleaseSession(id string) { if s.diagBroadcaster != nil { s.diagBroadcaster.unsubscribe(id) } + // Editor-overlay sessions are pinned to the MCP session that + // registered them. When the MCP session ends — for any reason — + // the overlay must die immediately. Holding overlay state past + // the disconnect would (a) leak unsaved buffer content into the + // daemon's address space indefinitely and (b) let a future + // connection that learns or guesses the same session ID re- + // attach to abandoned buffers; that's a credential / data-leak + // vector we don't want. The TTL is a fail-safe for "client + // crashed and we never observed the disconnect"; this is the + // fast path that closes the window when we DO observe it. + if s.overlays != nil { + s.overlays.Drop(id) + } + s.overlayCacheInvalidate(id) } // sessionState tracks recent agent activity for context recovery after compaction. diff --git a/internal/mcp/tools_overlay.go b/internal/mcp/tools_overlay.go index 63085c4..1f4360a 100644 --- a/internal/mcp/tools_overlay.go +++ b/internal/mcp/tools_overlay.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "sort" + "time" "github.com/mark3labs/mcp-go/mcp" @@ -60,6 +62,12 @@ func (s *Server) registerOverlayTools() { ), s.handleOverlayDrop, ) + s.mcpServer.AddTool( + mcp.NewTool("overlay_keepalive", + mcp.WithDescription("Refresh the calling MCP session's overlay idle timer without changing any overlay content. Cheaper than re-pushing buffer content when the editor needs to extend the lease (e.g. the user is debugging or paused on a breakpoint and won't push for a while). Returns the resulting expires_at / idle_seconds so the editor can schedule the next keepalive. The MCP session disconnect path already drops the overlay synchronously, so keepalive is only needed for genuine idle gaps below the disconnect-detection threshold."), + ), + s.handleOverlayKeepalive, + ) // compare_with_overlay runs a query against both base and the // session's overlay view and returns the delta — the core @@ -148,6 +156,9 @@ func (s *Server) handleOverlayPush(ctx context.Context, req mcp.CallToolRequest) return mcp.NewToolResultError(err.Error()), nil } } + // Invalidate the cached parsed-overlay layer for this session so + // the next tools/call re-parses with the fresh buffer state. + s.overlayCacheInvalidate(id) return mcp.NewToolResultText(jsonOK(map[string]any{ "path": overlay.Path, "content_size": len(overlay.Content), @@ -161,18 +172,30 @@ func (s *Server) handleOverlayList(ctx context.Context, _ mcp.CallToolRequest) ( if errRes != nil { return errRes, nil } - ws, files, err := s.overlays.SnapshotFor(id) - if err != nil { - if errors.Is(err, daemon.ErrSessionNotFound) { + // StatusFor + Files (rather than SnapshotFor) so listing the + // overlay doesn't accidentally bump LastUsed. Polling + // overlay_list every second should NOT extend the lease; + // otherwise a misconfigured editor could keep a dropped MCP + // session's overlay alive forever just by polling. + status, statusErr := s.overlays.StatusFor(id) + if statusErr != nil { + if errors.Is(statusErr, daemon.ErrSessionNotFound) { return mcp.NewToolResultText(jsonOK(map[string]any{ "session_id": id, "workspace_id": "", "count": 0, "files": []any{}, + "expired": true, })), nil } - return mcp.NewToolResultError(err.Error()), nil + return mcp.NewToolResultError(statusErr.Error()), nil + } + rawFiles, _ := s.overlays.Files(id) + files := make([]daemon.OverlayFile, 0, len(rawFiles)) + for _, f := range rawFiles { + files = append(files, f) } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) out := make([]map[string]any, 0, len(files)) for _, f := range files { out = append(out, map[string]any{ @@ -182,12 +205,20 @@ func (s *Server) handleOverlayList(ctx context.Context, _ mcp.CallToolRequest) ( "base_sha": f.BaseSHA, }) } - return mcp.NewToolResultText(jsonOK(map[string]any{ - "session_id": id, - "workspace_id": ws, - "count": len(out), - "files": out, - })), nil + resp := map[string]any{ + "session_id": id, + "workspace_id": status.WorkspaceID, + "count": len(out), + "files": out, + "created_at": status.Created.UTC().Format(time.RFC3339), + "last_used_at": status.LastUsed.UTC().Format(time.RFC3339), + "idle_seconds": status.IdleSeconds, + "idle_ttl_seconds": status.IdleTTLSeconds, + } + if !status.ExpiresAt.IsZero() { + resp["expires_at"] = status.ExpiresAt.UTC().Format(time.RFC3339) + } + return mcp.NewToolResultText(jsonOK(resp)), nil } func (s *Server) handleOverlayDelete(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -205,6 +236,7 @@ func (s *Server) handleOverlayDelete(ctx context.Context, req mcp.CallToolReques } return mcp.NewToolResultError(err.Error()), nil } + s.overlayCacheInvalidate(id) return mcp.NewToolResultText(jsonOK(map[string]any{ "path": path, "ok": true, @@ -217,12 +249,48 @@ func (s *Server) handleOverlayDrop(ctx context.Context, _ mcp.CallToolRequest) ( return errRes, nil } s.overlays.Drop(id) + s.overlayCacheInvalidate(id) return mcp.NewToolResultText(jsonOK(map[string]any{ "session_id": id, "ok": true, })), nil } +// handleOverlayKeepalive refreshes the session's idle timer and +// returns fresh expires_at / idle_seconds metadata. Returns an +// explicit "session expired" error when the daemon already reaped +// the session (or never knew about it) — the editor must +// overlay_register + re-push to recover. +func (s *Server) handleOverlayKeepalive(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, errRes := s.overlaySessionID(ctx) + if errRes != nil { + return errRes, nil + } + if err := s.overlays.Touch(id); err != nil { + if errors.Is(err, daemon.ErrSessionNotFound) { + return mcp.NewToolResultError("overlay session has been dropped or never registered — call overlay_register before pushing"), nil + } + return mcp.NewToolResultError(err.Error()), nil + } + status, statusErr := s.overlays.StatusFor(id) + if statusErr != nil { + // Race: session was dropped between Touch and StatusFor — + // extremely unlikely, but surface honestly. + return mcp.NewToolResultError(statusErr.Error()), nil + } + resp := map[string]any{ + "session_id": id, + "workspace_id": status.WorkspaceID, + "last_used_at": status.LastUsed.UTC().Format(time.RFC3339), + "idle_seconds": status.IdleSeconds, + "idle_ttl_seconds": status.IdleTTLSeconds, + } + if !status.ExpiresAt.IsZero() { + resp["expires_at"] = status.ExpiresAt.UTC().Format(time.RFC3339) + } + return mcp.NewToolResultText(jsonOK(resp)), nil +} + // jsonOK marshals v to a compact JSON string. Tool handlers use it to // build a text body that's both machine-readable and gcx-friendly. func jsonOK(v any) string { From 9547feab24f1fc78ebf44bf8bd5f1e7d1440ddfd Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 15 May 2026 16:48:24 +0200 Subject: [PATCH 4/4] Linter --- internal/daemon/overlay_test.go | 17 ++++++++--------- internal/graph/overlay.go | 14 ++++++-------- internal/mcp/overlay.go | 7 ++++++- internal/mcp/overlay_e2e_test.go | 2 +- internal/mcp/overlay_view.go | 14 +------------- internal/mcp/tools_overlay.go | 2 +- 6 files changed, 23 insertions(+), 33 deletions(-) diff --git a/internal/daemon/overlay_test.go b/internal/daemon/overlay_test.go index 4120a49..c2563b9 100644 --- a/internal/daemon/overlay_test.go +++ b/internal/daemon/overlay_test.go @@ -2,7 +2,6 @@ package daemon import ( "errors" - "os" "testing" "time" @@ -194,23 +193,23 @@ func TestOverlayManager_StatusForReportsExpiryMetadata(t *testing.T) { // TestOverlayIdleTTLFromEnv covers the three branches of the // configuration resolution: explicit override > env var > default. +// t.Setenv auto-restores the original value on test completion, so +// the test never leaks env state into sibling tests in the package. func TestOverlayIdleTTLFromEnv(t *testing.T) { - original := os.Getenv("GORTEX_OVERLAY_IDLE_TTL") - defer os.Setenv("GORTEX_OVERLAY_IDLE_TTL", original) - // Explicit non-zero override wins over env. - os.Setenv("GORTEX_OVERLAY_IDLE_TTL", "1h") + t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "1h") require.Equal(t, 7*time.Minute, OverlayIdleTTLFromEnv(7*time.Minute)) // Env var (no override). - os.Setenv("GORTEX_OVERLAY_IDLE_TTL", "45m") + t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "45m") require.Equal(t, 45*time.Minute, OverlayIdleTTLFromEnv(0)) // Garbage env: fall through to default (don't fail startup). - os.Setenv("GORTEX_OVERLAY_IDLE_TTL", "garbage") + t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "garbage") require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0)) - // Unset env: default. - os.Unsetenv("GORTEX_OVERLAY_IDLE_TTL") + // Empty env (t.Setenv to "" is the documented way to model "unset" + // in a test-scoped way): default. + t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "") require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0)) } diff --git a/internal/graph/overlay.go b/internal/graph/overlay.go index fbc26f7..ca29bfd 100644 --- a/internal/graph/overlay.go +++ b/internal/graph/overlay.go @@ -366,14 +366,12 @@ func (v *OverlaidView) FindNodesByName(name string) []*Node { for _, n := range v.base.FindNodesByName(name) { if v.layer != nil { if v.layer.HasFile(IDFile(n.ID)) { - // Overlaid file. Surface base node only if the - // overlay re-emitted it (same ID). Otherwise the - // overlay has either replaced it (already in `out` - // via the layer) or deleted it. - if _, kept := v.layer.nodeByID[n.ID]; !kept { - continue - } - // kept — but it's already in out via the layer path. + // Overlaid file: base's node for this name is + // always hidden. If the overlay re-emitted the same + // ID it's already in `out` from the layer's + // nodesByName prepend above; if the overlay deleted + // the symbol it must not surface at all. Either way + // we skip — no need to discriminate. continue } if v.layer.nameRemoved[name] != nil && v.layer.nameRemoved[name][n.ID] { diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index 7b4e8ac..2016810 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -103,7 +103,12 @@ func overlaySHAMatches(absPath, expected string) bool { return false } h := sha1.New() - fmt.Fprintf(h, "blob %d\x00", len(data)) + // 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)) == expected } diff --git a/internal/mcp/overlay_e2e_test.go b/internal/mcp/overlay_e2e_test.go index 0db8d40..e959831 100644 --- a/internal/mcp/overlay_e2e_test.go +++ b/internal/mcp/overlay_e2e_test.go @@ -178,7 +178,7 @@ func TestOverlay_BaseSHA_MatchProceeds(t *testing.T) { data, err := os.ReadFile(targetFile) require.NoError(t, err) h := sha1.New() - fmt.Fprintf(h, "blob %d\x00", len(data)) + _, _ = fmt.Fprintf(h, "blob %d\x00", len(data)) _, _ = h.Write(data) baseSHA := hex.EncodeToString(h.Sum(nil)) diff --git a/internal/mcp/overlay_view.go b/internal/mcp/overlay_view.go index 0b26660..135a1e9 100644 --- a/internal/mcp/overlay_view.go +++ b/internal/mcp/overlay_view.go @@ -10,8 +10,6 @@ import ( "strings" "sync" - "go.uber.org/zap" - "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" @@ -521,7 +519,7 @@ func hashOverlayFiles(files []daemon.OverlayFile) string { sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path }) h := sha256.New() for _, f := range sorted { - fmt.Fprintf(h, "%s\x00%t\x00%s\x00", f.Path, f.Deleted, f.BaseSHA) + _, _ = fmt.Fprintf(h, "%s\x00%t\x00%s\x00", f.Path, f.Deleted, f.BaseSHA) _, _ = h.Write([]byte(f.Content)) _, _ = h.Write([]byte{0}) } @@ -577,16 +575,6 @@ func (s *Server) overlayCacheInvalidate(sessID string) { s.overlayLayerCache.Delete(sessID) } -// loggerForOverlay returns s.logger if non-nil, else a no-op logger. -// Helper so the overlay path can emit structured diagnostics without -// gating every emit on a nil check. -func (s *Server) loggerForOverlay() *zap.Logger { - if s == nil || s.logger == nil { - return zap.NewNop() - } - return s.logger -} - // Compile-time sanity: a sync.Mutex usage placeholder so future // linter-driven import pruning doesn't strip the package. var _ sync.Mutex diff --git a/internal/mcp/tools_overlay.go b/internal/mcp/tools_overlay.go index 1f4360a..2ac3e86 100644 --- a/internal/mcp/tools_overlay.go +++ b/internal/mcp/tools_overlay.go @@ -105,7 +105,7 @@ func (s *Server) handleOverlayRegister(ctx context.Context, req mcp.CallToolRequ } if err := s.overlays.RegisterWithID(id, workspace); err != nil { if errors.Is(err, daemon.ErrSessionExists) { - return mcp.NewToolResultError(fmt.Sprintf("overlay session is already registered for a different workspace; call overlay_drop first")), nil + return mcp.NewToolResultError("overlay session is already registered for a different workspace; call overlay_drop first"), nil } return mcp.NewToolResultError(err.Error()), nil }