package mcp import ( "context" "encoding/json" "fmt" "math" "os" "runtime/debug" "sort" "strings" "sync" "time" "go.uber.org/zap" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/artifacts" "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" "github.com/zzet/gortex/internal/llm/registry" "github.com/zzet/gortex/internal/llm/svc" "github.com/zzet/gortex/internal/modelhint" "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/review" "github.com/zzet/gortex/internal/savings" "github.com/zzet/gortex/internal/search" "github.com/zzet/gortex/internal/semantic" "github.com/zzet/gortex/internal/server/hub" "github.com/zzet/gortex/internal/telemetry" "github.com/zzet/gortex/internal/tokens" ) // Version is set at build time. var Version = "dev" // SymbolModification records a single modification event for a symbol. type SymbolModification struct { Timestamp time.Time `json:"timestamp"` SignatureChanged bool `json:"signature_changed"` } // symbolHistory tracks symbol modifications during the current session. type symbolHistory struct { mu sync.Mutex entries map[string][]SymbolModification // symbolID → modifications } // Record adds a modification entry for the given symbol. func (sh *symbolHistory) Record(symbolID string, signatureChanged bool) { sh.mu.Lock() defer sh.mu.Unlock() sh.entries[symbolID] = append(sh.entries[symbolID], SymbolModification{ Timestamp: time.Now(), SignatureChanged: signatureChanged, }) } // Get returns the modification history for a specific symbol. func (sh *symbolHistory) Get(symbolID string) []SymbolModification { sh.mu.Lock() defer sh.mu.Unlock() mods := sh.entries[symbolID] out := make([]SymbolModification, len(mods)) copy(out, mods) return out } // All returns a copy of the entire modification history. func (sh *symbolHistory) All() map[string][]SymbolModification { sh.mu.Lock() defer sh.mu.Unlock() out := make(map[string][]SymbolModification, len(sh.entries)) for k, v := range sh.entries { cp := make([]SymbolModification, len(v)) copy(cp, v) out[k] = cp } return out } // Server wraps the MCP server with Gortex-specific tools. type Server struct { mcpServer *server.MCPServer engine *query.Engine graph graph.Store indexer *indexer.Indexer watcher watcherHistory multiIndexer *indexer.MultiIndexer configManager *config.ConfigManager activeProject string // testIndexProbe caches, per repo prefix, which test-path fragments the // graph carries symbols for (see testFragmentsIndexed). The answer only // changes with a reindex under a different exclude set, so // daemon-lifetime caching is safe. testIndexProbe sync.Map // scopeWorkspace / scopeProject default-scope every query at this // server instance to a single (workspace, project) tuple. Set by // `gortex server --workspace [--scope-project ]`. // Tool handlers consult these via the QueryScope() helper rather // than reading the fields directly. scopeWorkspace string scopeProject string // scopeIntentDefaults gates Layer-B intent defaults: locate tools // start at the session repo, reach/analyze tools at the workspace. scopeIntentDefaults bool logger *zap.Logger // recorder counts allow-listed, consent-gated usage telemetry. nil or // disabled when telemetry is off; Record is nil-safe and fail-silent so // the dispatch hot path never branches on it. recorder *telemetry.Recorder communities *analysis.CommunityResult processes *analysis.ProcessResult pageRank *analysis.PageRankResult // hits holds the HITS authority/hub scores over the call graph. // Authority measures "depended on by load-bearing code"; the // search rerank consumes it as a complement to raw fan-in. // Rebuilt each RunAnalysis pass; guarded by analysisMu. hits *analysis.HITSResult // autoConcepts is the per-repo, LLM-free concept vocabulary mined // from symbol names -- the deterministic complement to LLM query // expansion. Rebuilt on every RunAnalysis pass; guarded by // analysisMu and read via getAutoConcepts. autoConcepts *search.AutoConcepts // leidenCache carries the last Leiden partition between // `analyze kind=clusters` calls so a re-run after only a couple // of packages changed re-partitions just those packages instead // of the whole graph. nil until the first clusters request; // guarded by analysisMu. leidenCache *analysis.LeidenPartitionCache // communitiesToken snapshots the graph identity that backed // s.communities — (NodeCount, EdgeCount, EdgeIdentityRevisions). // handleAnalyzeClusters reads this before calling the incremental // detector: if the token still matches the live graph, the cached // communities are reused without scanning AllNodes / AllEdges to // fingerprint packages. On a disk backend the fingerprint scan alone is // ~140s; the cache check is three scalar reads. communitiesToken communityCacheToken // hotspots is the default-threshold (mean + 2*stddev) hotspot // ranking. FindHotspots' inner ComputeBetweenness pass dominates // the wall clock of get_repo_outline / get_architecture / // gortex_wakeup / the analyze(hotspots) resource — caching it // once per RunAnalysis turn turns repeat calls into a map lookup. // Rebuilt each RunAnalysis pass; guarded by analysisMu. hotspots []analysis.HotspotEntry // adjacency is the compact CSR snapshot of the call / reference // graph, built once per RunAnalysis pass so seeded random-walk // queries (context_closure proximity ranking) never re-scan // AllNodes / AllEdges. nil until the first RunAnalysis; guarded by // analysisMu and read via getAdjacency. adjacency *analysis.AdjacencySnapshot // adjacencyToken snapshots the graph identity that backed // s.adjacency, on the same (NodeCount, EdgeCount, // EdgeIdentityRevisions) discipline as communitiesToken — so a // consumer can tell whether the snapshot still matches the live // graph before trusting it. adjacencyToken communityCacheToken analysisMu sync.RWMutex // cochange caches the git-history co-change graph. cochangeByFile // maps a file path to its co-changing file paths and association // scores (0..1); cochangeCount holds the matching commit-overlap // counts. Lazily populated once per daemon lifetime by // ensureCoChange — mining git log and materialising EdgeCoChange // edges. The find_co_changing_symbols tool reads both maps; the // search rerank pipeline consumes cochangeByFile as the co_change // signal via buildRerankContext. cochangeOnce sync.Once cochangeMu sync.RWMutex cochangeByFile map[string]map[string]float64 cochangeCount map[string]map[string]int // autoIndexOnce guards the opt-in (GORTEX_AUTOINDEX=1) zero-config // background index of an untracked cwd, fired at most once per session // from the first tool call. See auto_index.go. autoIndexOnce sync.Once // worktreeMismatch is computed once per server: true when the working // directory is a linked git worktree the indexed graph does not cover, // so read tools can warn that results reflect another checkout. worktreeMismatchOnce sync.Once worktreeMismatch bool // artifacts caches the materialised `.gortex.yaml::artifacts` // manifest. artifactEntries is the configured manifest (installed // via SetArtifacts); artifactList is the result of materialising // it into KindArtifact nodes + EdgeReferences edges, lazily and // once per daemon lifetime, by ensureArtifacts. artifactsOnce sync.Once artifactsMu sync.RWMutex artifactEntries []config.ArtifactEntry artifactList []artifacts.Artifact // namedQueries holds the config-defined `queries:` bundles — // reusable detector selections runnable via analyze kind=named. // Installed via SetNamedQueries; merged with the built-in // bundles at call time. namedQueries []config.NamedQuery // session / symHistory / tokenStats are the shared-default per-client // state for the embedded stdio path (one implicit client per process). // Tool handlers reach per-session activity via sessionFor(ctx); that // helper returns this default when ctx carries no session ID. session *sessionState symHistory *symbolHistory tokenStats *tokenStats // sessions multiplexes per-client sessionLocal for the daemon // transport. When ctx carries a session ID (WithSessionID), handlers // resolve through this map; otherwise the shared fields above are // used. sessions *sessionMap guardRules []config.GuardRule architecture config.ArchitectureConfig // eventRules is the declarative event-boundary rule family, installed via // SetEventRules alongside SetArchitecture and evaluated by change_contract. eventRules []config.EventRule // searchCfg carries the `.gortex.yaml::search` block — rerank // weights plus the search-behaviour knobs (keyword-soup rewrite, // equivalence-class expansion, prose indexing). Installed via // SetSearchConfig right after NewServer; the zero value keeps // every knob at its documented default. searchCfg config.SearchConfig // equivalence is the curated software-concept synonym table // (plus any repo-custom classes from searchCfg.EquivalenceExtra). // Built once by SetSearchConfig; immutable thereafter so no lock // is needed. Nil until SetSearchConfig runs -- callers nil-check. equivalence *search.EquivalenceTable contractRegistry *contracts.Registry semanticMgr *semantic.Manager // refsConfirmed is the lazy-enrichment ledger: symbol IDs whose incoming // references have been confirmed on demand (via ConfirmSymbolRefs) this // daemon session, so a repeat usages query serves from the confirmed graph // instead of re-spawning the language server. An entry is recorded after // the first attempt (success or empty) to bound per-query LSP work. refsConfirmed sync.Map // symbolID → struct{} feedback *feedbackManager notes *notesManager memories *memoryManager // promotedTools is the per-workspace learned tool surface: deferred // tools promoted into the eager surface after use, persisted so the // learning survives daemon restarts (with demotion after disuse). Nil // until InitLearnedTools wires it. promotedTools *promotedToolsManager // globalMemories holds the user-level memory store shared across // every workspace this user touches — lives at ~/.gortex/memories/. // Tools default to the workspace store; `scope:"global"` routes // to this one. Nil when InitMemories was called with the legacy // single-arg surface or when the user-home cannot be resolved. globalMemories *memoryManager // notebook is the repository-local persistent notebook — // .gortex/notebook/.md files committed alongside the repo. // Nil until InitNotebook fires; tools surface a clear error. notebook *notebookManager combo *comboManager frecency *frecencyTracker // suppressions holds the durable per-repo review-finding FP suppression // store (sidecar-backed). The review gate consults it to drop known false // positives by stable identity; the suppress_finding tool mutates it. Nil // until InitSuppressions fires; the review flow tolerates a nil store. suppressions *suppressionManager // packCache retains recent smart_context pack views keyed by pack // root so a later call with delta_from= returns only the // added/removed/changed symbols vs that prior pack. Always non-nil // after NewServer. packCache *packDeltaCache // pprCache memoizes seeded Random-Walk-with-Restart (Personalized // PageRank) results, keyed by content-addressed per-package Merkle // roots so only walks touching a changed package recompute. Shared // by the rerank ProximitySignal and context_closure. Always // non-nil after NewServer. pprCache *pprWalkCache // queryLog is the append-only retrieval query log (JSONL). It // records every retrieval-shaped tool call so offline recall // tuning and the eval harness have a substrate to measure. Always // non-nil after NewServer; a disabled logger is a cheap no-op. queryLog *queryLogger // prCache is a short-TTL cache of fetched forge.PR values keyed by // (repo, number), shared across the PR data tools so a triage // fan-out plus a follow-up impact call reuse one fetch. Always // non-nil after NewServer. prCache *prCache // diagBroadcaster forwards LSP `publishDiagnostics` payloads to // MCP clients as `notifications/diagnostics`. Lazy-initialised by // SetLSPDiagnosticsBroadcasting; nil until then. diagBroadcaster *diagnosticsBroadcaster // readinessBroadcaster fans `notifications/workspace_readiness` // at each warmup phase transition + re-index completion. Eagerly // constructed in NewServer; the publisher (daemon entrypoint) // calls Server.PublishReadiness at each phase. readinessBroadcaster *readinessBroadcaster // healthBroadcaster fans `notifications/daemon_health` on a // periodic ticker. Eagerly constructed in NewServer; the daemon // entrypoint wires the snapshot fn via AttachHealthSnapshot. healthBroadcaster *healthBroadcaster // staleRefsBroadcaster fans `notifications/stale_refs` per session // when the watcher reports symbol churn in a file the session has // touched. Eagerly constructed in NewServer; wired to the // watcher's symbol-change callback via SetWatcher. staleRefsBroadcaster *staleRefsBroadcaster // graphInvalidatedBroadcaster fans `notifications/graph_invalidated` // to subscribers whenever the graph is rebuilt — a coarse // "drop your caches" signal. Fired from RunAnalysis. graphInvalidatedBroadcaster *graphInvalidatedBroadcaster // sanitizeInjection gates the prompt-injection screening // middleware (see sanitize.go). Set from GORTEX_MCP_SANITIZE in // NewServer; on by default. sanitizeInjection bool // llmService is the optional LLM service backing the `ask` MCP tool // and the `search_symbols` assist modes. nil until SetLLMService is // called by the daemon entrypoint. The service wraps whichever // provider `llm.provider` selects (local llama.cpp / Anthropic / // OpenAI / Ollama); when the provider can't be constructed it // reports Enabled() == false and the dependent tools stay absent. llmService *svc.Service // resourcesNotifier overrides the live mcpServer when pushing // `notifications/resources/updated`. Test-only: production code // leaves it nil so the live server is used. resourcesNotifier resourcesUpdatedNotifier // reviewLLMGenOverride substitutes the review tool's plain (non-usage) LLM // re-location seam. Test-only: production leaves it nil and // reviewLLMGenWithUsage builds the closure over llmService.GenerateWithUsage. // A non-nil override is adapted up to the usage-aware shape (reporting zero // usage) so a test that only needs to drive the LLM review phase — without // asserting cost — can still do so without constructing a real provider. reviewLLMGenOverride func() review.LLMGen // reviewLLMGenWithUsageOverride substitutes the review tool's usage-aware // LLM seam (the one that feeds the per-review CostBreakdown). Test-only: // production leaves it nil and reviewLLMGenWithUsage builds the closure // over llmService.GenerateWithUsage. A non-nil override is returned // directly (gated on use_llm) so a test can drive the cost-bearing review // path — and assert the returned usage shows up in the response — without // constructing a real provider. reviewLLMGenWithUsageOverride func() review.LLMGenWithUsage // reviewPricingOverride substitutes the rate card the review cost block // prices token usage against. Test-only: production reads it from the LLM // service (Pricing). A non-nil override lets a test assert a deterministic // USD estimate from a stubbed usage seam. reviewPricingOverride *llm.ProviderPricing // critiqueLLMGenOverride substitutes the critique_review tool's LLM seam. // Test-only: production leaves it nil and the handler builds the closure // over llmService.Generate. A non-nil override stands in for the real // provider so a test can drive the self-critique pass without constructing // a backend. critiqueLLMGenOverride func() review.LLMGen // proxyHydrate is the cross-daemon proxy-edge lazy hydration hook. // nil unless the daemon installed one (federation.edges on); the // traversal tools call it to pull a proxy node's neighbour ring before // crossing into it. See proxy_hydrate.go. proxyHydrate func(ctx context.Context, proxyID string) (int, error) // toolScopes is the per-Server tool-name → ToolScope registry. // Populated by registerToolWithScope as tools are added; consulted // by ResolveToolScope before each handler runs. toolScopes *scopeRegistry // agentReg is the in-memory multi-agent coordination registry backing // the agent_registry tool (presence, cursors, advisory path locks). agentReg *agentRegistry // overlays is the optional editor-overlay manager. When non-nil, // 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 // 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 / compare_with_overlay) so a // second SetOverlayManager call doesn't double-register them. registerOverlayToolsOnce sync.Once // remoteOverrides bridges the session proxy-toggle tools // (proxy_enable / proxy_disable / proxy_status) to the daemon's // per-session remote-override state. nil in embedded mode (no // per-connection daemon session). registerProxyToolsOnce gates the // tool registration the way the overlay family is gated. remoteOverrides RemoteOverrideSink registerProxyToolsOnce sync.Once // lazy owns the deferred-tool catalog backing the tools_search // discovery tool. Tools whose names are not in hotEagerTools land // here instead of in the live MCP server; tools_search returns // their schemas on demand and promotes them via lazy.promote (a // closure wired in attachLazyRegistry). Nil only when the registry // is explicitly disabled — see lazyEnabledFromEnv. lazy *lazyToolRegistry // toolPolicy restricts the published tool surface to a named preset // / allow-deny set (see tool_presets.go). Resolved at construction // from MultiRepoOptions.ToolPolicy (the mcp.tools config block) plus // the GORTEX_TOOLS / GORTEX_TOOLS_MODE env overrides. Nil or // inactive means the full surface. Enforced in hide mode by // toolSurfaceFilter + checkToolGate and in defer mode by the lazy // registry's eager predicate. toolPolicy *toolPolicy // toolPolicyOperatorPinned records whether the global policy came // from a deliberate operator configuration (mcp.tools beyond the // shipped default, or GORTEX_TOOLS) — when true, the active // instruction profile does not adjust session tool surfaces. toolPolicyOperatorPinned bool // toolBudgetOnce / toolBudgetCached memoise the project-size-scaled // exploration-call budget appended to navigation tools' descriptions // (see tool_budget.go). Computed once from the graph node count. toolBudgetOnce sync.Once toolBudgetCached string // scopesOnce / scopes is the lazily-initialised, JSON-file-backed // saved-scope store (see scopes.go). scopesOnce sync.Once scopes *scopeStore } // sessionFor returns the session-scoped state for the current request. // If ctx was wrapped with WithSessionID, the per-session entry is used // (created on first access). Otherwise the shared default is returned, // preserving embedded-mode behavior exactly. // // Never returns nil — callers can chain `.recordFile(...)` etc. // unconditionally. func (s *Server) sessionFor(ctx context.Context) *sessionState { id := SessionIDFromContext(ctx) if id == "" || s.sessions == nil { return s.session } return s.sessions.get(id).session } // ReleaseSession drops per-session state for id. Called by the daemon // when a proxy disconnects, so idle entries don't accumulate for the // lifetime of the daemon process. Cascades into the diagnostics // broadcaster so a disconnecting subscriber's slot is reclaimed. func (s *Server) ReleaseSession(id string) { if id == "" { return } if s.sessions != nil { s.sessions.release(id) } if s.diagBroadcaster != nil { s.diagBroadcaster.unsubscribe(id) } if s.readinessBroadcaster != nil { s.readinessBroadcaster.unsubscribe(id) } if s.healthBroadcaster != nil { s.healthBroadcaster.unsubscribe(id) } if s.staleRefsBroadcaster != nil { s.staleRefsBroadcaster.unsubscribe(id) } if s.graphInvalidatedBroadcaster != nil { s.graphInvalidatedBroadcaster.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. type sessionState struct { mu sync.Mutex viewedSymbols []string // recently viewed symbol IDs (most recent first) viewedFiles []string // recently viewed file paths modifiedFiles []string // files modified via edit_symbol recentSearches []string // recent search queries // clientName is the MCP client identifier (claude-code / cursor / vscode / // zed / …) captured from the protocol's `initialize.clientInfo.name` // field by the daemon dispatcher. Drives the per-session default // wire format: known-decoder clients get `gcx` by default, others // fall back to JSON. Empty until the dispatcher sees the // `initialize` frame. clientName string // toolSpec / toolMode are the client-forwarded tool-surface // preference (GORTEX_TOOLS / --tools + mode) relayed by the daemon // from the connection handshake. They drive the per-session effective // tool policy: a forwarded spec wins over the client-name default, // which wins over the server's global preset. Empty = no preference. toolSpec string toolMode string // resolvedToolPolicy caches the effective per-session policy so it is // derived once per session, not on every tools/list. Invalidated when // toolSpec / clientName is (re)recorded. resolvedToolPolicy *toolPolicy toolPolicyResolved bool // lastSearch captures the most recent search_symbols call so that a // subsequent get_symbol_source / get_editing_context on one of its // results can be attributed back to the query — this is the raw input // to the combo tracker. Reset on every search. lastSearch lastSearchState // Workspace scope for this session, resolved lazily from the // session cwd on first query and cached here. scopeResolved // guards the one-time resolution. scopeBound is true when the // session has a cwd: query handlers then confine every result to // scopeWorkspaceID — the hard, un-widenable workspace boundary. // When scopeBound is true and scopeWorkspaceID names no real // workspace, the cwd matched no tracked repo and handlers fail // closed rather than widening to the global graph. scopeRepoPrefix // / scopeProjectID feed relevance ranking (same-repo > same-project // > same-workspace); they never relax the boundary. scopeResolved bool scopeBound bool scopeWorkspaceID string scopeProjectID string scopeRepoPrefix string // planningMode, when true, removes every editing tool from this // session's tool surface and hard-blocks edit calls — a runtime // no-writes guarantee toggled by set_planning_mode (tools_mode.go). planningMode bool // workflow, when non-nil, is the active phase-enforcement state // machine for this session (see tools_workflow.go). workflow *workflowState // responses is the ring of recent large tool responses captured // for the post-filter tools (ctx_grep / ctx_slice / …). Allocated // lazily on first capture. responses *responseBuffer // momentumReads counts this session's read/navigate tool calls; // momentumNudged latches the one-shot momentum note (momentum.go). // momentumStreak counts CONSECUTIVE granular reads (broken by // explore / smart_context and by any non-read call); // momentumEscalated latches the one-shot escalation note and // momentumExploreUsed records whether explore ran this session // (it drives the escalation's wording). momentumReads int momentumNudged bool momentumStreak int momentumEscalated bool momentumExploreUsed bool // cursor is the per-session stateful navigation cursor used by the // nav tool — a current symbol plus a back-history. Allocated lazily // on the first nav call and freed with the rest of sessionState on // disconnect. cursor *navCursor // emittedCues records which proactive related_tools discovery cues have // already been shown this session, so each is emitted at most once (a // repeated hint is noise). Keyed by the cue's tool name. emittedCues map[string]bool } // markCueOnce records that a related_tools cue keyed by `key` has been // emitted this session; it returns true only the FIRST time, so a caller // emits the cue iff this returns true. func (ss *sessionState) markCueOnce(key string) bool { ss.mu.Lock() defer ss.mu.Unlock() if ss.emittedCues[key] { return false } if ss.emittedCues == nil { ss.emittedCues = make(map[string]bool, 4) } ss.emittedCues[key] = true return true } type lastSearchState struct { query string // returned is the result IDs in rank order (0 = top); returnedIDs // maps an ID to its rank for O(1) membership + rank lookup. consumed // tracks which returned IDs the agent went on to use, so a later // search can record an implicit "skip-above" negative for the // higher-ranked results that were passed over. returned []string returnedIDs map[string]int consumed map[string]struct{} at time.Time } // tokenStats tracks estimated token savings for the current session. When a // savings.Store is attached, each record() call also increments the persistent // cumulative totals so "Gortex saved $X this month"-style narratives survive // server restarts. // // parent, when non-nil, is the process-wide aggregate (s.tokenStats) that // every per-session counter feeds. Without the fan-out, a fresh session's // counter is zero-initialised and graph_stats called from any new session // always reports `token_savings.calls_counted: 0` even when the daemon has // served thousands of source-fetching calls — the shared default never // receives observations because every real call carries a session ID. The // parent link aggregates so graph_stats shows a meaningful live total. type tokenStats struct { mu sync.Mutex tokensSaved int64 // cumulative tokens saved vs reading full files tokensReturned int64 // cumulative tokens actually returned callCount int64 // number of source-reading tool invocations persistent *savings.Store parent *tokenStats // process-wide aggregate (nil for the root) repoPath string // forwarded to savings for per-repo aggregation sessionID string // rides on persisted events; "" for the shared default clientName string // MCP client app (claude-code / cursor / …) from initialize; rides on events } // setClientName records the MCP client app on the session's tokenStats so // per-call savings observations can be attributed to it. Mirrors the // sessionState.recordClientName the daemon dispatcher already calls. func (ts *tokenStats) setClientName(name string) { if ts == nil || name == "" { return } ts.mu.Lock() ts.clientName = name ts.mu.Unlock() } // record adds a single savings observation. node is the symbol whose // source was returned — its RepoPrefix and Language are folded into the // per-repo / per-language buckets in the persistent store. node may be // nil for code paths that don't have a node handle, in which case the // observation only contributes to top-line totals. tool is the MCP tool // name that produced the call (e.g. "get_symbol_source") and is recorded // in the JSONL event log for the dashboard's per-tool breakdown. // // returned and fullFile are token counts (cl100k_base via internal/tokens). func (ts *tokenStats) record(node *graph.Node, tool string, returned, fullFile int64) { ts.mu.Lock() saved := max(fullFile-returned, 0) ts.tokensSaved += saved ts.tokensReturned += returned ts.callCount++ store := ts.persistent parent := ts.parent fallbackRepo := ts.repoPath sessionID := ts.sessionID clientName := ts.clientName ts.mu.Unlock() // Fan out to the process-wide aggregate so graph_stats called // outside this session — or from a freshly-created session that // has not itself made a recorded call yet — sees a live counter // that reflects daemon-wide activity. The parent never has a // further parent (we only nest one level deep), so this is bounded. if parent != nil { parent.mu.Lock() parent.tokensSaved += saved parent.tokensReturned += returned parent.callCount++ parent.mu.Unlock() } // Repo: prefer the node's RepoPrefix so multi-repo daemons attribute // correctly to the actual repo the symbol lives in. Fall back to the // rootPath captured at InitSavings only when the node has no prefix // (single-repo mode). repo := fallbackRepo var language string if node != nil { if node.RepoPrefix != "" { repo = node.RepoPrefix } language = node.Language } // Model attribution: the MCP protocol never tells a tool server which // LLM is calling, but the host's hook layer can — it drops a model // hint keyed by the session's working directory that we read back // here. Best-effort: an absent/stale hint leaves the model empty and // the dashboard falls back to its provider-neutral estimate. Keyed by // the InitSavings root path (the cwd the connection was established // in), with the hint's own global-last fallback inside Read. var model string if h, ok := modelhint.Read(fallbackRepo); ok { model = h.Model if clientName == "" { clientName = h.Client } } // Forward to the persistent store outside our lock — its own // synchronization guards concurrent writers, and the ledger write // shouldn't block new record() calls on the hot path. if store != nil { store.AddObservation(savings.Observation{ Repo: repo, Language: language, Tool: tool, SessionID: sessionID, Model: model, Client: clientName, Returned: returned, Saved: saved, }) } } // snapshot returns a copy of the current counters for inclusion in responses. func (ts *tokenStats) snapshot() map[string]any { ts.mu.Lock() defer ts.mu.Unlock() ratio := 0.0 if ts.tokensReturned > 0 { ratio = float64(ts.tokensSaved+ts.tokensReturned) / float64(ts.tokensReturned) } return map[string]any{ "tokens_saved": ts.tokensSaved, "tokens_returned": ts.tokensReturned, "calls_counted": ts.callCount, "efficiency_ratio": math.Round(ratio*10) / 10, } } const maxSessionItems = 20 func newSessionState() *sessionState { return &sessionState{} } func (ss *sessionState) recordSymbol(id string) { ss.mu.Lock() defer ss.mu.Unlock() ss.viewedSymbols = prependUnique(ss.viewedSymbols, id, maxSessionItems) } func (ss *sessionState) recordFile(path string) { ss.mu.Lock() defer ss.mu.Unlock() ss.viewedFiles = prependUnique(ss.viewedFiles, path, maxSessionItems) } func (ss *sessionState) recordModified(path string) { ss.mu.Lock() defer ss.mu.Unlock() ss.modifiedFiles = prependUnique(ss.modifiedFiles, path, maxSessionItems) } func (ss *sessionState) recordSearch(query string) { ss.mu.Lock() defer ss.mu.Unlock() ss.recentSearches = prependUnique(ss.recentSearches, query, 10) } // recordClientName captures the MCP client name from the `initialize` // frame. Idempotent — re-init overwrites. Empty input is ignored so a // late env-var fallback can't clobber a prior authoritative value. func (ss *sessionState) recordClientName(name string) { if name == "" { return } ss.mu.Lock() defer ss.mu.Unlock() ss.clientName = name // The client name feeds the client-aware preset default, so a late // authoritative name (from the `initialize` frame) must re-resolve the // session's effective tool policy. ss.resolvedToolPolicy = nil ss.toolPolicyResolved = false } // recordToolPolicy captures the client-forwarded tool-surface preference // relayed from the connection handshake. Invalidates the cached // effective policy so the next tools/list re-resolves. Idempotent. func (ss *sessionState) recordToolPolicy(spec, mode string) { ss.mu.Lock() defer ss.mu.Unlock() if ss.toolSpec == spec && ss.toolMode == mode && ss.toolPolicyResolved { return } ss.toolSpec = spec ss.toolMode = mode ss.resolvedToolPolicy = nil ss.toolPolicyResolved = false } // snapshotClientName returns the captured client name under the // session lock. Returns empty when the `initialize` frame hasn't // arrived yet (very early tool calls — rare but possible during // boot races). func (ss *sessionState) snapshotClientName() string { ss.mu.Lock() defer ss.mu.Unlock() return ss.clientName } // NoteSessionClient is called by the daemon dispatcher after it // snoops the MCP `initialize.clientInfo.name` value, so the per- // session sessionState can default tool wire-format based on the // client's decoder capability. Idempotent and safe to call before // the session is registered (no-op until sessionFor materialises // the entry). func (s *Server) NoteSessionClient(sessionID, name, version string) { if s == nil || sessionID == "" || name == "" { return } // Fold the client app into opt-in telemetry (consent-gated, fail-silent, // version dropped + name bounded by the recorder's sanitiser). Captures // which agents drive Gortex without ever recording a session identifier. telemetry.RecordClient(s.recorder, name) if s.sessions == nil { // Embedded mode — single shared session; just record on the // shared state. if s.session != nil { s.session.recordClientName(name) } s.tokenStats.setClientName(name) return } entry := s.sessions.get(sessionID) entry.session.recordClientName(name) // Mirror onto the session's tokenStats so per-call savings // observations carry the client app for the per-client breakdown. entry.tokenStats.setClientName(name) _ = version // reserved for per-version capability gates } // NoteSessionToolPolicy relays the client-forwarded tool-surface // preference (from the daemon connection handshake) onto the per-session // sessionState so tools/list and the call gate honour this client's // preset authoritatively. Empty spec+mode is a no-op (no preference). // Safe to call before the session is registered — sessionFor materialises // the entry. Called once per session by the daemon dispatcher. func (s *Server) NoteSessionToolPolicy(sessionID, spec, mode string) { if s == nil { return } if strings.TrimSpace(spec) == "" && strings.TrimSpace(mode) == "" { return } if s.sessions == nil { if s.session != nil { s.session.recordToolPolicy(spec, mode) } return } if sessionID == "" { return } s.sessions.get(sessionID).session.recordToolPolicy(spec, mode) } // defaultFormatForClient returns the most-compressed wire format the // named MCP client is known to decode. Resolution order is gcx > // toon > json: // // - GCX-capable: claude-code, cursor, vscode (via the @gortex/wire // extension that ships with the IDE plugin), zed (gortex-zed // plugin links gcx-go), aider, kilocode, opencode, openclaw, // codex (Anthropic CLI bundles the gcx decoder). // - TOON-capable but no GCX: kept for forward compat; today there // is no client we know to be in this bucket. Listed for the // mapping shape and as a placeholder — clients can be promoted // here when their plugin ships a TOON-only decoder. // - Everything else: empty string → JSON (the safe legacy default). // // Lower-cased client name is matched. Unknown clients are not a // failure — they just keep the JSON default until they're added. func defaultFormatForClient(name string) string { if isKnownAgentClient(name) { return "gcx" } return "" } // knownAgentClients is the set of MCP clients recognised as coding agents: // they ship a GCX decoder (so they default to the gcx wire format) AND they // get the lean `agent` tool preset by default. One list drives both // defaults so the two stay in lock-step. Matched case-insensitively on the // exact clientInfo.name. var knownAgentClients = map[string]bool{ "claude-code": true, "cursor": true, "vscode": true, "zed": true, "aider": true, "kilocode": true, "opencode": true, "openclaw": true, "codex": true, "omp-coding-agent": true, } // isKnownAgentClient reports whether the named MCP client is a recognised // coding agent (see knownAgentClients). func isKnownAgentClient(name string) bool { return knownAgentClients[strings.ToLower(strings.TrimSpace(name))] } // resolveSessionFormat returns the format the current session prefers // when a tool's `format` arg is absent. Pure read — used by isGCX / // isTOON when the caller didn't pin a format explicitly. func (s *Server) resolveSessionFormat(ctx context.Context) string { if s == nil { return "" } sess := s.sessionFor(ctx) if sess == nil { return "" } return defaultFormatForClient(sess.snapshotClientName()) } // comboWindow is how long after a search_symbols the session will still // attribute a consume call (get_symbol_source / get_editing_context) back // to that search's query for combo tracking. FFF uses a similar concept // with a T-second window; 5 minutes is long enough for agents that // interleave many tool calls but short enough that an unrelated later // consume doesn't get mis-attributed. const comboWindow = 5 * time.Minute // recordLastSearch captures the query + the IDs it returned so a later // consume call can be credited to this query. Truncating to the top N // results keeps the map small — only symbols the agent can plausibly // have seen are eligible. func (ss *sessionState) recordLastSearch(query string, ids []string) { ss.mu.Lock() defer ss.mu.Unlock() set := make(map[string]int, len(ids)) for i, id := range ids { set[id] = i } ss.lastSearch = lastSearchState{ query: query, returned: append([]string(nil), ids...), returnedIDs: set, consumed: make(map[string]struct{}), at: time.Now(), } } // attributedQuery returns the query string that should receive credit for // consuming symbolID, or "" if no recent search eligibly returned it. // Cleared from the caller's perspective but not from state — a single // search can legitimately credit several consume calls. func (ss *sessionState) attributedQuery(symbolID string) string { ss.mu.Lock() defer ss.mu.Unlock() if ss.lastSearch.query == "" || symbolID == "" { return "" } if time.Since(ss.lastSearch.at) > comboWindow { return "" } if _, ok := ss.lastSearch.returnedIDs[symbolID]; !ok { return "" } if ss.lastSearch.consumed == nil { ss.lastSearch.consumed = make(map[string]struct{}) } ss.lastSearch.consumed[symbolID] = struct{}{} return ss.lastSearch.query } // attributedConsumptionBatch credits a set of symbol IDs to the recent // search in one pass: it returns the search's query and the subset of // ids that the search returned within the attribution window (marking // each consumed). Used by the tool-call observer when the agent opens a // file — every symbol in it that the search surfaced is credited at // once. Returns ("", nil) when no fresh search is attributable. func (ss *sessionState) attributedConsumptionBatch(ids []string) (string, []string) { ss.mu.Lock() defer ss.mu.Unlock() if ss.lastSearch.query == "" || time.Since(ss.lastSearch.at) > comboWindow { return "", nil } if ss.lastSearch.consumed == nil { ss.lastSearch.consumed = make(map[string]struct{}) } var matched []string for _, id := range ids { if id == "" { continue } if _, ok := ss.lastSearch.returnedIDs[id]; !ok { continue } ss.lastSearch.consumed[id] = struct{}{} matched = append(matched, id) } return ss.lastSearch.query, matched } // hasFreshSearch reports whether a search is recent enough to attribute // a consume to. A cheap gate so file-open handlers skip the work of // enumerating a file's symbols when nothing could be credited anyway. func (ss *sessionState) hasFreshSearch() bool { ss.mu.Lock() defer ss.mu.Unlock() return ss.lastSearch.query != "" && time.Since(ss.lastSearch.at) <= comboWindow } // drainSkippedNegatives computes the implicit "skip-above" negatives for // the current last-search: the results ranked above the deepest one the // agent actually consumed but that were themselves never consumed — i.e. // passed over. Called when a search is about to be superseded (the state // is overwritten right after), so each skip is emitted at most once. // Returns ("", nil) when the agent consumed nothing or only the top hit. func (ss *sessionState) drainSkippedNegatives() (string, []string) { ss.mu.Lock() defer ss.mu.Unlock() ls := &ss.lastSearch if ls.query == "" || len(ls.consumed) == 0 || len(ls.returned) == 0 { return "", nil } maxRank := -1 for id := range ls.consumed { if r, ok := ls.returnedIDs[id]; ok && r > maxRank { maxRank = r } } if maxRank <= 0 { return "", nil // top pick (or nothing) — nothing was skipped over } var skipped []string for i := 0; i < maxRank && i < len(ls.returned); i++ { id := ls.returned[i] if _, ok := ls.consumed[id]; ok { continue } skipped = append(skipped, id) } return ls.query, skipped } func (ss *sessionState) snapshot() map[string]any { ss.mu.Lock() defer ss.mu.Unlock() return map[string]any{ "viewed_symbols": ss.viewedSymbols, "viewed_files": ss.viewedFiles, "modified_files": ss.modifiedFiles, "recent_searches": ss.recentSearches, } } func prependUnique(slice []string, item string, maxLen int) []string { // Remove existing occurrence. for i, s := range slice { if s == item { slice = append(slice[:i], slice[i+1:]...) break } } // Prepend. slice = append([]string{item}, slice...) if len(slice) > maxLen { slice = slice[:maxLen] } return slice } // MultiRepoOptions holds optional multi-repo components for the Server. // When nil or zero-valued, the server operates in single-repo mode. type MultiRepoOptions struct { MultiIndexer *indexer.MultiIndexer ConfigManager *config.ConfigManager ActiveProject string // ScopeWorkspace is the workspace slug filter applied as the // default scope on every query. Set by `gortex server --workspace // `. Empty disables the filter. ScopeWorkspace string // ScopeProject narrows further inside ScopeWorkspace (no effect // without it). ScopeProject string // ToolPolicy restricts the published MCP tool surface to a named // preset / allow-deny set (the `mcp.tools` config block). Nil leaves // the full surface; GORTEX_TOOLS / GORTEX_TOOLS_MODE still override. ToolPolicy *ToolPolicyConfig // ScopeIntentDefaults overrides the default-on intent scoping flag // from `.gortex.yaml::scope.intent_defaults`. ScopeIntentDefaults *bool } // serverInstructions is the server-level `instructions` field returned // in the MCP initialize response. MCP clients surface it to the agent // as guidance on how to drive this server — the place to say "prefer // the graph tools over raw file reads, and where to start." const serverInstructions = `Gortex is a code-intelligence graph server — it indexes repositories into a queryable knowledge graph. Prefer its graph tools over raw file reads and text search: - START WITH explore FOR EVERY TASK-SHAPED REQUEST (a bug report, a feature, "where is / how does X work"): one call returns the ranked neighborhood — the likely symbols with their source, call paths, and the files to change. Answer or start editing directly from its output instead of chaining search/read/callers calls; its locations are graph-verified, so no re-checking with file reads is needed. - For one known symbol: search_symbols (BM25, camelCase-aware) to find it, get_symbol_source to read it, batch_symbols for several bodies in one call; find_usages / get_callers for references and callers. - Before editing, call get_editing_context on the file; for refactors use edit_symbol / rename_symbol / batch_edit. - The cold tools/list shows a core set — call tools_search to discover the rest of the catalogue on demand. - Pass format:"gcx" to list-shaped tools for a compact, round-trippable wire format (~27% fewer tokens). ` + sharedParamLegend // ServerInstructionsUntracked is the inactive-state `instructions` variant // returned when a session's cwd is not covered by any tracked repo. Rather than // poisoning the connection with an errored initialize, the handshake succeeds // and the response tells the agent exactly how to activate the server — the // actionable affordance codegraph's silent empty-list lacks. func ServerInstructionsUntracked(cwd string, roots ...string) string { target := cwd if strings.TrimSpace(target) == "" { target = "." } msg := fmt.Sprintf("Gortex is connected but INACTIVE for this directory: %q is not covered by any tracked repository, "+ "so the graph tools have nothing to answer with yet.\n\n"+ "To activate: run `gortex track %s`, then reconnect — the full tool catalogue and the "+ "graph become available.\n\n"+ "Until then tools/list is intentionally empty; fall back to your own file reads and text search.", target, target) // Append the tracked roots so the mismatch is self-diagnosing: a // case-only or drive-letter difference between the cwd above and one // of these roots is the usual cause of an INACTIVE session (#277). if len(roots) > 0 { quoted := make([]string, len(roots)) for i, r := range roots { quoted[i] = fmt.Sprintf("%q", r) } msg += fmt.Sprintf("\n\nTracked repository roots: [%s]. If one of these is the same directory as %q "+ "under a different letter case, that is the mismatch.", strings.Join(quoted, ", "), target) } return msg } // afterInitializeInstructions is the server's OnAfterInitialize hook: it // rewrites the initialize result's Instructions to the variant that fits THIS // connection's cwd. Because it runs inside the handshake, every MCP client — // not just ones that execute a SessionStart hook — learns the live workspace // shape and warmup state directly from initialize. See stateAwareInstructions. func (s *Server) afterInitializeInstructions(ctx context.Context, _ any, _ *mcp.InitializeRequest, result *mcp.InitializeResult) { if s == nil || result == nil { return } result.Instructions = s.stateAwareInstructions(SessionCWDFromContext(ctx)) } // stateAwareInstructions chooses the initialize `instructions` text for a // connection rooted at cwd. An uncovered multi-repo cwd gets the terse // activation affordance (shared with the F4 handshake path); a covered cwd — // or a single-repo / cwd-less control client — gets the base guidance plus a // live-facts block describing the workspace and warmup state. // trackedRepoRoots returns the sorted absolute root of every tracked repo, // for the self-diagnosing INACTIVE instructions. func (s *Server) trackedRepoRoots() []string { if s.multiIndexer == nil { return nil } var roots []string for _, meta := range s.multiIndexer.AllMetadata() { if meta != nil && meta.RootPath != "" { roots = append(roots, meta.RootPath) } } sort.Strings(roots) return roots } func (s *Server) stateAwareInstructions(cwd string) string { if s.multiIndexer != nil && strings.TrimSpace(cwd) != "" { if _, _, _, ok := s.multiIndexer.ScopeForCWD(cwd); !ok { return ServerInstructionsUntracked(cwd, s.trackedRepoRoots()...) } } if facts := s.liveInstructionFacts(cwd); facts != "" { return serverInstructions + "\n\n" + facts } return serverInstructions } // liveInstructionFacts renders the per-connection state block appended to the // covered-cwd instructions: tracked-repo count + names, the active project // (multi-repo only), and the daemon warmup phase / ready flag. Returns "" when // there is nothing live to report so the base guidance stays clean. func (s *Server) liveInstructionFacts(cwd string) string { var lines []string if repos := s.trackedRepoNames(cwd); len(repos) > 0 { const maxNames = 8 shown, suffix := repos, "" if len(repos) > maxNames { shown = repos[:maxNames] suffix = fmt.Sprintf(", +%d more", len(repos)-maxNames) } lines = append(lines, fmt.Sprintf("Tracked repositories (%d): %s%s.", len(repos), strings.Join(shown, ", "), suffix)) } if proj := s.activeProjectName(cwd); proj != "" { lines = append(lines, fmt.Sprintf("Active project: %s.", proj)) } if s.readinessBroadcaster != nil { if snap := s.readinessBroadcaster.snapshot(); snap != nil { if phase, _ := snap["phase"].(string); phase != "" { ready, _ := snap["ready"].(bool) state := "warming up — results may be partial until ready" if ready { state = "ready" } lines = append(lines, fmt.Sprintf("Index status: %s (phase %q).", state, phase)) } } } if len(lines) == 0 { return "" } return "Live state for this connection:\n- " + strings.Join(lines, "\n- ") } // trackedRepoNames returns the sorted repo prefixes visible to a connection // rooted at cwd: the cwd's workspace siblings when it resolves to one, else the // full tracked set. Empty in single-repo (no multi-indexer) mode. func (s *Server) trackedRepoNames(cwd string) []string { if s.multiIndexer == nil { return nil } var set map[string]bool if strings.TrimSpace(cwd) != "" { if ws, _, _, ok := s.multiIndexer.ScopeForCWD(cwd); ok { set = s.multiIndexer.ReposInWorkspace(ws) } } var names []string if len(set) > 0 { for p := range set { names = append(names, p) } } else { names = append(names, s.multiIndexer.RepoPrefixes()...) } sort.Strings(names) return names } // activeProjectName resolves the project slug for a cwd in multi-repo mode: the // cwd's own project when it resolves, else the server's configured active // project. Empty in single-repo mode. func (s *Server) activeProjectName(cwd string) string { if s.multiIndexer == nil { return "" } if strings.TrimSpace(cwd) != "" { if _, proj, _, ok := s.multiIndexer.ScopeForCWD(cwd); ok && proj != "" { return proj } } return s.activeProject } // NewServer creates an MCP server with all Gortex tools registered. func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watcher *indexer.Watcher, logger *zap.Logger, guardRules []config.GuardRule, opts ...MultiRepoOptions) *Server { s := &Server{ engine: engine, graph: g, indexer: idx, logger: logger, session: newSessionState(), scopeIntentDefaults: true, tokenStats: &tokenStats{}, symHistory: &symbolHistory{ entries: make(map[string][]SymbolModification), }, sessions: newSessionMap(), guardRules: guardRules, toolScopes: newScopeRegistry(), agentReg: newAgentRegistry(), queryLog: newQueryLogger(), pprCache: newPPRWalkCache(), packCache: newPackDeltaCache(), prCache: newPRCache(prCacheTTL), } // Wire the process-wide tokenStats as the parent of every // per-session counter so record() fanout aggregates daemon-wide. s.sessions.setParentTokenStats(s.tokenStats) // Per-connection state-aware initialize instructions: the after-init // hook rewrites result.Instructions for THIS session's cwd — the terse // activation affordance for an uncovered cwd, or the base guidance plus // live workspace + warmup facts for a covered one. It fires for every // MCP client straight from the handshake, including non-Claude-Code // clients that never run a SessionStart hook (where the daemon-side // rewrite for the proxy path would not reach the embedded stdio server). initHooks := &server.Hooks{} initHooks.AddAfterInitialize(s.afterInitializeInstructions) // mcpServer is constructed after s exists so the per-session tool // filter can close over s — toolSurfaceFilter varies the tools/list // surface by the session's planning mode (see tools_mode.go). s.mcpServer = server.NewMCPServer("gortex", Version, // Surface "how to drive this server" to MCP clients in the // initialize response — see serverInstructions. server.WithInstructions(serverInstructions), // Rewrite that instructions field per connection (see above). server.WithHooks(initHooks), // listChanged=true: tools_search promotes deferred tools into // the live MCP server on demand, and a planning-mode flip // re-filters the surface — both rely on tools/list_changed. server.WithToolCapabilities(true), // subscribe=true lets clients call resources/subscribe for // bootstrap URIs and receive notifications/resources/updated // after each graph re-warm. listChanged=false — the resource // set is static for the server's lifetime. server.WithResourceCapabilities(true, false), server.WithRecovery(), // Per-session tools/list filter — hides editing tools while a // session is in planning mode (see tools_mode.go). server.WithToolFilter(s.toolSurfaceFilter), ) // Assign the watcher only when the caller actually supplied one. // Storing a typed-nil *indexer.Watcher in the watcherHistory // interface field would produce a non-nil interface wrapping a // nil pointer — `s.watcher == nil` checks in handlers would then // pass through to method calls and panic. if watcher != nil { s.watcher = watcher } // Apply multi-repo options if provided. if len(opts) > 0 { o := opts[0] s.multiIndexer = o.MultiIndexer s.configManager = o.ConfigManager s.activeProject = o.ActiveProject s.scopeWorkspace = o.ScopeWorkspace s.scopeProject = o.ScopeProject if o.ScopeIntentDefaults != nil { s.scopeIntentDefaults = *o.ScopeIntentDefaults } } // Proactive-notification broadcasters. Constructed up-front so // subscribe handlers can register listeners as soon as the first // session connects; the *publishers* are wired by the daemon // entrypoint (PublishReadiness at warmup phases, // AttachHealthSnapshot for the periodic ticker) and by // SetWatcher (stale_refs hooks into the symbol-change callback). s.readinessBroadcaster = newReadinessBroadcaster(s.mcpServer, logger) s.healthBroadcaster = newHealthBroadcaster(s.mcpServer, nil, logger) s.staleRefsBroadcaster = newStaleRefsBroadcaster(s.mcpServer, s.sessions, s.session, logger) s.graphInvalidatedBroadcaster = newGraphInvalidatedBroadcaster(s.mcpServer, logger) // Lazy-tool registry MUST be installed before any addTool calls so // non-hot tools land in the deferred catalog instead of the live // MCP server. attachLazyRegistry wires the promotion closure that // tools_search uses to migrate a tool from deferred → live on // first use. See lazy_tools.go for the hot-set selection rules. s.sanitizeInjection = sanitizeEnabledFromEnv() // Tool-surface preset (mcp.tools config + GORTEX_TOOLS env). In // defer mode the preset's allow-set becomes the lazy registry's // eager surface; hide mode is enforced later by toolSurfaceFilter / // checkToolGate. Resolved before the register sweep so every // addTool sees the policy. toolPolicyBase := toolPolicyBaseFromOptions(opts) s.toolPolicyOperatorPinned = operatorPinnedToolPolicy(toolPolicyBase) s.toolPolicy = resolveToolPolicy(toolPolicyBase, logger) s.lazy = newLazyToolRegistry(lazyEnabledFromEnv() || s.toolPolicy.deferMode()) if s.toolPolicy.deferMode() { s.lazy.SetEagerPredicate(s.toolPolicy.allows) } s.attachLazyRegistry() s.registerToolsSearch() s.registerCoreTools() s.registerExploreTool() s.registerFindFilesTool() s.registerCodingTools() s.registerMoveInlineTools() s.registerPostFilterTools() s.registerPlanningModeTool() s.registerWorkflowTool() s.registerScopeTools() s.registerAnalysisTools() s.registerEnhancementTools() s.registerLSPTools() s.registerLintTools() s.registerAgentRegistryTools() s.registerDiagnosticsTools() s.registerReadinessTools() s.registerHealthTools() s.registerStaleRefsTools() s.registerGraphInvalidatedTools() s.registerToolProfileTool() s.registerDataflowTools() s.registerCFGTools() s.registerASTTools() s.registerCloneTools() s.registerSimulationTools() s.registerChangeContractTools() s.registerNotesTools() s.registerMemoriesTools() s.registerWhyTool() s.registerNotebookTools() s.registerCitationTools() s.registerKnowledgeGapsTool() s.registerSurprisingConnectionsTool() s.registerReviewQuestionsTool() s.registerPRReviewContextTool() s.registerCritiqueReviewTool() s.registerArchitectureTool() s.registerReplayEpisodeTool() s.registerSafeDeleteSymbolTool() s.registerGenerateSkillTool() s.registerInspectionsTools() s.registerChurnRateTool() s.registerEnrichChurnTool() s.registerEnrichReleasesTool() s.registerCoChangeTool() s.registerArtifactTools() s.registerCouplingMetricsTool() s.registerExtractionCandidatesTool() s.registerCheckReferencesTool() s.registerWakeupTool() s.registerGraphCompletionTool() s.registerWikiTools() s.registerExportTools() s.registerAuditTool() s.registerWalkGraphTool() s.registerContextClosureTool() s.registerGraphQueryTool() s.registerNavTool() s.registerFindDeclarationTool() s.registerPRRiskTool() s.registerSuggestReviewersTool() s.registerReviewTools() s.registerPRTools() s.registerConflictsPRTool() s.registerResources() s.registerPrompts() // Register multi-repo tools when multi-repo components are available. if s.multiIndexer != nil || s.configManager != nil { s.registerMultiRepoTools() } // Workspace-scope bootstrap tools (list_repos, workspace_info). // Always registered — they degrade cleanly in single-project mode // to a one-member view. s.registerWorkspaceTools() // LLM-backed tools (`ask`) are NOT registered here — they're // gated on SetLLMService being called with an enabled service, // which happens post-construction from the daemon entrypoint. s.applyDefaultToolScopes() return s } // SetLLMService attaches the LLM service to the server and registers // the `ask` MCP tool. Call after NewServer; without this, the `ask` // MCP tool is not registered (clean degradation for deployments // without an LLM). // // Safe to call with a disabled service (no provider configured, or // provider construction failed) — registerLLMTools gates on // Service.Enabled() and skips registration in that case. // // Lifecycle: the server does NOT take ownership of the service; the // daemon entrypoint that constructed the service is responsible for // calling svc.Close() on shutdown. func (s *Server) SetLLMService(service *svc.Service) { s.llmService = service s.registerLLMTools() } // LLMService returns the attached LLM service, or nil when none was configured // (no provider, or provider construction failed). The daemon's shared-server // wiring uses it to register the service's Close in the process cleanup chain — // honouring the "caller owns Close" lifecycle noted on SetLLMService — so a // graceful shutdown unloads the loaded model instead of leaning on the idle // reaper as the only unload path. func (s *Server) LLMService() *svc.Service { return s.llmService } // SetupLLM is the convenience constructor used by daemon entrypoints. // It builds an in-process backend wired to this server's engine + // contract registry, constructs the service from cfg, and attaches // it. A zero or disabled cfg is a no-op — safe to call // unconditionally. // // The provider is chosen by cfg.Provider. Selecting "local" in a // binary built without `-tags llama` — or any provider with a missing // model / API key — leaves the service disabled; the construction // error is logged as a warning rather than failing daemon startup, so // a misconfigured `llm:` block degrades cleanly (the `ask` tool and // `search_symbols` assist modes are simply absent). func (s *Server) SetupLLM(cfg llm.Config) { cfg = cfg.MergeEnv() var customWarnings []string cfg, customWarnings = registry.Augment(cfg) for _, w := range customWarnings { s.logger.Warn("custom LLM provider", zap.String("warning", w)) } if !cfg.IsEnabled() { return } backend := svc.NewInProcessBackend(s.engine, s.effectiveContractRegistry) // The busy predicate lets the search-assist gate defer an expensive // local-model cold load while a background enrichment pass is in // flight — the two must not contend for CPU/GPU/RAM. Reads the // semantic manager lazily so it sees a manager attached after // SetupLLM, and reports "not busy" until one is. busy := func() bool { return s.semanticMgr != nil && s.semanticMgr.EnrichmentActive() } service := svc.NewService(cfg, backend, svc.WithBusyPredicate(busy), svc.WithLogger(s.logger)) s.SetLLMService(service) if err := service.ProviderErr(); err != nil { s.logger.Warn("LLM provider unavailable — `ask` tool and search assist disabled", zap.String("provider", cfg.ProviderName()), zap.Error(err)) } } // InitFeedback initializes the feedback manager for cross-session feedback persistence. // Call after NewServer with the cache directory and primary repo path. func (s *Server) InitFeedback(cacheDir, repoPath string) { s.feedback = newFeedbackManager(cacheDir, repoPath) } // InitNotes initializes the session-memory manager used by the // save_note / query_notes / distill_session tools. Call after // NewServer with the cache directory and primary repo path. // Empty arguments yield an in-memory-only manager (still wired // to the tools, just doesn't flush to disk). func (s *Server) InitNotes(cacheDir, repoPath string) { s.notes = newNotesManager(cacheDir, repoPath) } // InitLearnedTools wires the per-workspace learned tool surface and hydrates // it: it advances the session epoch, demotes promotions unused past the // hysteresis window, and re-promotes the survivors into the eager surface so // a tool the team promoted last session is already live this session. Call // after NewServer (once the register sweep has deferred the cold tools). // Empty arguments yield an in-memory-only, non-persistent surface. func (s *Server) InitLearnedTools(cacheDir, repoPath string) { s.promotedTools = newPromotedToolsManager(cacheDir, repoPath) for _, name := range s.promotedTools.Load() { // Re-promote persisted tools so they ship in the cold tools/list. // EnsureToolPromoted is a no-op for a tool that is not deferred // (already live, or absent), so this is safe under any preset. s.EnsureToolPromoted(name) } } // RecordLearnedPromotion persists that a deferred tool was promoted (or // re-used) for the session's workspace, so the learned surface survives a // daemon restart. cwd resolves the workspace for diagnostics. A no-op when // the learned surface is not wired. func (s *Server) RecordLearnedPromotion(name, cwd string) { if s == nil || s.promotedTools == nil || name == "" { return } // Floor / always-eager tools are never "learned" — only tools that were // genuinely deferred and promoted on demand belong in the learned set. if isAlwaysKeptTool(name) { return } s.promotedTools.Record(name, s.workspaceIDForCWD(cwd)) } // isLearnedPromoted reports whether a tool is in the per-workspace learned // surface — used to keep a learned tool visible on the lean agent surface // (which would otherwise narrow it out). func (s *Server) isLearnedPromoted(name string) bool { return s != nil && s.promotedTools.Has(name) } // ActivePreset returns the server's global tool-surface preset label + mode // for status / introspection. The per-session default may differ (a known // coding-agent client defaults to `agent`); this reports the server-level // baseline. func (s *Server) ActivePreset() (preset, mode string) { if s == nil || s.toolPolicy == nil { return "full", "" } return s.toolPolicy.preset, s.toolPolicy.mode } // LearnedToolCount returns the size of the per-workspace learned tool // surface (deferred tools promoted through use, persisted across restarts). func (s *Server) LearnedToolCount() int { if s == nil { return 0 } return s.promotedTools.Count() } // LearnedToolNames returns the learned tool surface, sorted. func (s *Server) LearnedToolNames() []string { if s == nil { return nil } return s.promotedTools.Names() } // NoteToolUse records a call to a tool for the learned-surface. It persists // only tools that were genuinely deferred (newly promoted this call) or are // already in the learned set — never the eager floor — so the learned // surface tracks exactly the tools worth promoting for this workspace. func (s *Server) NoteToolUse(name, cwd string, newlyPromoted bool) { if s == nil || s.promotedTools == nil || name == "" { return } if newlyPromoted || s.isLearnedPromoted(name) { s.RecordLearnedPromotion(name, cwd) } } // workspaceIDForCWD best-effort resolves a workspace slug from a session // cwd for learned-promotion diagnostics; returns "" when it cannot. func (s *Server) workspaceIDForCWD(cwd string) string { if s == nil || s.multiIndexer == nil || cwd == "" { return "" } if ws, _, _, ok := s.multiIndexer.ScopeForCWD(cwd); ok { return ws } return "" } // InitMemories initializes the cross-session development-memory // manager used by the store_memory / query_memories / // surface_memories tools. Call after NewServer with the cache // directory and primary repo path. Empty arguments yield an // in-memory-only manager (still wired to the tools, just doesn't // flush to disk). // // Memories persist across daemon restarts and context compactions // and are workspace-wide — every agent in the same workspace // shares the store. // InitNotebook mounts the repository-local persistent notebook // store at /.gortex/notebook/. Empty repoPath leaves // s.notebook nil; tools surface that as "notebook not initialised". // Distinct from notes (per-session) and memories (cache-dir cross- // session) — notebook entries are committed to git so they travel // with the repo and surface in PR reviews. func (s *Server) InitNotebook(repoPath string) { s.notebook = newNotebookManager(repoPath) } // InitSuppressions initializes the durable per-repo review-finding // false-positive suppression store used by the review gate and the // suppress_finding tool. Call after NewServer with the cache directory and // primary repo path. Empty arguments yield an in-memory-only store (still wired // to the tools, just doesn't flush to disk). Suppressions persist across daemon // restarts and are per-repo, scoped by the same cache key as notes / memories. func (s *Server) InitSuppressions(cacheDir, repoPath string) { s.suppressions = newSuppressionManager(cacheDir, repoPath) } func (s *Server) InitMemories(cacheDir, repoPath string) { s.memories = newMemoryManager(cacheDir, repoPath) // Mount the user-level global store. Defaults to ~/.gortex/memories; // an absolute $XDG_DATA_HOME relocates it to // /gortex/memories. Failures (no $HOME, unreadable // home) leave globalMemories nil; tools detect that and surface a // clear error rather than silently dropping global writes. if home, err := os.UserHomeDir(); err == nil && home != "" { s.globalMemories = newMemoryManager(platform.MemoriesDir(), "global") } } // resolveMemoryStore picks the right memoryManager for the requested // scope. Defaults to the workspace store; "global" returns the // user-level store mounted at ~/.gortex/memories-cache/. Unknown // scope values fall through to workspace so callers don't have to // guard against typos. func (s *Server) resolveMemoryStore(scope string) *memoryManager { switch strings.ToLower(strings.TrimSpace(scope)) { case "global": return s.globalMemories default: return s.memories } } // resolveMemoryStores returns every memoryManager that matches the // scope argument. `both` returns workspace + global; `workspace` // (default) returns just workspace; `global` returns just global. // Nil managers are excluded from the result so callers can rely on // the slice being non-empty before iterating. func (s *Server) resolveMemoryStores(scope string) []*memoryManager { switch strings.ToLower(strings.TrimSpace(scope)) { case "both": stores := []*memoryManager{} if s.memories != nil { stores = append(stores, s.memories) } if s.globalMemories != nil { stores = append(stores, s.globalMemories) } return stores case "global": if s.globalMemories != nil { return []*memoryManager{s.globalMemories} } return nil default: if s.memories != nil { return []*memoryManager{s.memories} } return nil } } // WorkspaceScope returns the default workspace slug filter applied // to every query (set by `gortex server --workspace`). Empty // means no scope; tools that consult it should fall back to the // global multi-workspace view. func (s *Server) WorkspaceScope() string { return s.scopeWorkspace } // ProjectScope returns the project slug filter; meaningful only // when WorkspaceScope() is non-empty. func (s *Server) ProjectScope() string { return s.scopeProject } // unresolvedWorkspacePrefix marks a session whose cwd is non-empty but // resolves to no tracked repo. Used as a QueryOptions.WorkspaceID // sentinel: it can never equal a real node's WorkspaceID/RepoPrefix, // so every node is rejected and the session fails closed instead of // widening to the global graph. const unresolvedWorkspacePrefix = "\x00gortex-unresolved-workspace:" // sessionScope resolves the workspace/project boundary for the current // request. When bound is true the session is confined to workspaceID // and query handlers MUST NOT return data outside it; an empty (or // sentinel) workspaceID with bound==true means the cwd resolved to no // tracked repo and handlers fail closed. When bound is false the // session is unbound (embedded stdio / control client / `gortex // server --workspace`) and callers fall back to the server-default // scope. // // Resolution happens once per session — derived from the immutable // session cwd — and is cached on sessionState. repoPrefix is the // session's home repo, used only for relevance ranking. func (s *Server) sessionScope(ctx context.Context) (workspaceID, projectID string, bound bool) { ss := s.sessionFor(ctx) if ss == nil { return "", "", false } ss.mu.Lock() defer ss.mu.Unlock() if ss.scopeResolved { return ss.scopeWorkspaceID, ss.scopeProjectID, ss.scopeBound } ss.scopeResolved = true cwd := SessionCWDFromContext(ctx) if cwd == "" || s.multiIndexer == nil { // No cwd (embedded stdio, control clients) or no multi-repo // indexer: unbound — the server-default scope applies. return "", "", false } ss.scopeBound = true ws, proj, repoPrefix, ok := s.multiIndexer.ScopeForCWD(cwd) if ok { ss.scopeWorkspaceID = ws ss.scopeProjectID = proj ss.scopeRepoPrefix = repoPrefix } else { // cwd is non-empty but maps to no tracked repo. The daemon // dispatcher rejects unreachable cwds before dispatch, so // this is defensive: the sentinel matches no node, so the // session sees nothing rather than the whole global graph. ss.scopeWorkspaceID = unresolvedWorkspacePrefix + cwd } return ss.scopeWorkspaceID, ss.scopeProjectID, true } // sessionLocality returns the session's home repo prefix and project // slug for relevance ranking — same-repo hits rank above same-project // above same-workspace. Both empty for unbound sessions. func (s *Server) sessionLocality(ctx context.Context) (repoPrefix, projectID string) { ss := s.sessionFor(ctx) if ss == nil { return "", "" } // Ensure scope is resolved (populates scopeRepoPrefix). s.sessionScope(ctx) ss.mu.Lock() defer ss.mu.Unlock() return ss.scopeRepoPrefix, ss.scopeProjectID } // sessionWorkspaceRepos returns {name, path} for every repo in the // current session's workspace, sorted by name. Empty for an unbound // session or when the multi-repo indexer is unavailable. Used by the // introspection tools so they report the session's real boundary. func (s *Server) sessionWorkspaceRepos(ctx context.Context) []map[string]string { sessWS, _, bound := s.sessionScope(ctx) if !bound || s.multiIndexer == nil { return nil } prefixes := s.multiIndexer.ReposInWorkspace(sessWS) meta := s.multiIndexer.AllMetadata() out := make([]map[string]string, 0, len(prefixes)) for p := range prefixes { entry := map[string]string{"name": p} if m := meta[p]; m != nil { entry["path"] = m.RootPath } out = append(out, entry) } sort.Slice(out, func(i, j int) bool { return out[i]["name"] < out[j]["name"] }) return out } // nodeInSessionScope reports whether a node may be surfaced to the // current session. For a workspace-bound session only nodes inside // the session's workspace pass; for an unbound session every node // passes (the server-default scope applies). This is the universal // per-node enforcement of the workspace boundary — used by by-id and // whole-graph handlers that don't route through the engine's scoped // traversal. func (s *Server) nodeInSessionScope(ctx context.Context, n *graph.Node) bool { sessWS, _, bound := s.sessionScope(ctx) if !bound { return true } if n == nil { return false } return query.QueryOptions{WorkspaceID: sessWS}.ScopeAllows(n) } // scopedNodes returns the graph nodes visible to the current session: // every node for an unbound session, only the session workspace's // nodes for a bound one. Whole-graph handlers (analyze, outline, // untested, resource rollups, …) iterate this instead of // graph.AllNodes() so a workspace-bound session can never observe // another workspace's nodes — not even in aggregate counts. func (s *Server) scopedNodes(ctx context.Context) []*graph.Node { all := s.graph.AllNodes() sessWS, _, bound := s.sessionScope(ctx) repoAllow := repoAllowFromContext(ctx) if !bound && len(repoAllow) == 0 { return all } opts := query.QueryOptions{RepoAllow: repoAllow} if bound { opts.WorkspaceID = sessWS } out := make([]*graph.Node, 0, len(all)) for _, n := range all { if opts.ScopeAllows(n) { out = append(out, n) } } return out } // scopedNodesByKinds is the kind-pushdown sibling of scopedNodes for // handlers that only need a specific kind set. When the backend // implements graph.NodesByKindsScanner the kind predicate runs server- // side (one kind-filtered scan over the node table) instead of // the legacy AllNodes()-then-Go-side filter. The metadata analyzers // (todos, stale_code, stale_flags, ownership, coverage_gaps, // coverage_summary, cgo_users, wasm_users, orphan_tables, // unreferenced_tables) each keep one or two kinds out of the whole // node table; pushing that filter is the entire win. // // Workspace-bound sessions still narrow Go-side: the capability does // not know about ScopeAllows, and adding workspace_id to every analyze // query would tie the capability to the session-scope concept. The // secondary filter is cheap because the kind pushdown already shrank // the row count by 1-2 orders of magnitude. // // Empty kinds returns nil — defensive against caller bugs that would // otherwise drop into the full-AllNodes fallback path. func (s *Server) scopedNodesByKinds(ctx context.Context, kinds []graph.NodeKind) []*graph.Node { if len(kinds) == 0 { return nil } var nodes []*graph.Node if scan, ok := s.graph.(graph.NodesByKindsScanner); ok { nodes = scan.NodesByKinds(kinds) } else { // Fallback: same behaviour as scopedNodes, kind-filtered Go-side. all := s.graph.AllNodes() allowed := make(map[graph.NodeKind]struct{}, len(kinds)) for _, k := range kinds { allowed[k] = struct{}{} } nodes = make([]*graph.Node, 0, len(all)) for _, n := range all { if _, ok := allowed[n.Kind]; ok { nodes = append(nodes, n) } } } sessWS, _, bound := s.sessionScope(ctx) repoAllow := repoAllowFromContext(ctx) if !bound && len(repoAllow) == 0 { return nodes } opts := query.QueryOptions{RepoAllow: repoAllow} if bound { opts.WorkspaceID = sessWS } out := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { if opts.ScopeAllows(n) { out = append(out, n) } } return out } // scopedNodeSlice filters an existing node slice to the session's // workspace. Convenience for handlers that already hold a node list // (engine list methods that don't take QueryOptions). func (s *Server) scopedNodeSlice(ctx context.Context, nodes []*graph.Node) []*graph.Node { sessWS, _, bound := s.sessionScope(ctx) repoAllow := repoAllowFromContext(ctx) if !bound && len(repoAllow) == 0 { return nodes } opts := query.QueryOptions{RepoAllow: repoAllow} if bound { opts.WorkspaceID = sessWS } out := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { if opts.ScopeAllows(n) { out = append(out, n) } } return out } // InitCombo initializes the query→symbol combo tracker. Persists per-repo, // same cache directory as feedback; zero-effect no-op when either argument // is empty. mode selects the max-age reap schedule (AI: 7 days, human: 30). func (s *Server) InitCombo(cacheDir, repoPath string, mode AgentMode) { s.combo = newComboManager(cacheDir, repoPath, mode) } // InitFrecency initializes the implicit symbol frecency tracker. mode // selects the decay regime — ModeAI (3-day half-life) for MCP server use; // ModeHuman (10-day) for interactive sessions. func (s *Server) InitFrecency(cacheDir, repoPath string, mode AgentMode) { s.frecency = newFrecencyTracker(cacheDir, repoPath, mode) } // InitSavings wires the persistent token-savings store into tokenStats so // every source-reading tool call accumulates cumulative totals. Call once // after NewServer; safe to skip when persistence isn't desired. // // Propagates to the sessionMap too so per-session counters (daemon path) // also flush to the shared persistent store. Without this propagation a // proxy that connects before InitSavings runs would hold a tokenStats // with nil persistent and silently drop observations. func (s *Server) InitSavings(store *savings.Store, repoPath string) { if store == nil || s.tokenStats == nil { return } s.tokenStats.mu.Lock() s.tokenStats.persistent = store s.tokenStats.repoPath = repoPath s.tokenStats.mu.Unlock() if s.sessions != nil { s.sessions.setPersistent(store, repoPath) } } // tokenStatsFor returns the tokenStats for the current request. Mirrors // sessionFor: when ctx carries a session ID the per-session counter is // returned, otherwise the shared default. Per-session counters share // the same persistent store so disk totals accumulate across clients. func (s *Server) tokenStatsFor(ctx context.Context) *tokenStats { id := SessionIDFromContext(ctx) if id == "" || s.sessions == nil { return s.tokenStats } return s.sessions.get(id).tokenStats } // FlushSavings is kept for shutdown-path compatibility. The sidecar-backed // ledger commits every observation as it is recorded, so there is nothing // buffered to write. func (s *Server) FlushSavings() error { store := s.savingsStore() if store == nil { return nil } return store.Flush() } // savingsStore extracts the persistent savings store via tokenStats. Returns // nil when persistence isn't initialized. func (s *Server) savingsStore() *savings.Store { if s == nil || s.tokenStats == nil { return nil } s.tokenStats.mu.Lock() store := s.tokenStats.persistent s.tokenStats.mu.Unlock() return store } // cumulativeSavingsSnapshot exposes the persistent savings state for // inclusion in graph_stats. Returns nil when persistence isn't wired so // single-shot CLI calls don't emit confusing empty totals. func (s *Server) cumulativeSavingsSnapshot() map[string]any { if s.tokenStats == nil { return nil } s.tokenStats.mu.Lock() store := s.tokenStats.persistent s.tokenStats.mu.Unlock() if store == nil { return nil } snap, err := store.Snapshot() if err != nil && s.logger != nil { s.logger.Warn("cumulative savings snapshot failed", zap.Error(err)) } costs := savings.CostAvoidedAll(snap.Totals.TokensSaved) out := map[string]any{ "first_seen": snap.FirstSeen.Format(time.RFC3339), "last_updated": snap.LastUpdated.Format(time.RFC3339), "tokens_saved": snap.Totals.TokensSaved, "tokens_returned": snap.Totals.TokensReturned, "calls_counted": snap.Totals.CallsCounted, "cost_avoided_usd": costs, } // cost_avoided_usd above is a counterfactual — the same neutral token // count priced against every model. When the host surfaced the model // that actually drove calls (via the hook model-hint bridge), add the // real per-model attribution: tokens rescaled into the model's own // tokenizer and priced at that model's rate. per_client mirrors it for // the MCP client app. if models, err := store.ModelTotals(time.Time{}); err == nil && len(models) > 0 { rows := make([]map[string]any, 0, len(models)) for _, m := range models { adj := tokens.ScaleFromCL100K(m.Name, m.TokensSaved) rows = append(rows, map[string]any{ "model": m.Name, "calls_counted": m.CallsCounted, "tokens_saved": adj, "cost_avoided_usd": savings.CostAvoided(adj, m.Name), }) } out["per_model_actual"] = rows } if clients, err := store.ClientTotals(time.Time{}); err == nil && len(clients) > 0 { rows := make([]map[string]any, 0, len(clients)) for _, c := range clients { rows = append(rows, map[string]any{ "client": c.Name, "calls_counted": c.CallsCounted, "tokens_saved": c.TokensSaved, }) } out["per_client"] = rows } if snap.DroppedObservations > 0 { out["dropped_observations"] = snap.DroppedObservations } return out } // ExportContext generates a portable context briefing for the given task. // This is the public API for the CLI command, delegating to the MCP handler. func (s *Server) ExportContext(ctx context.Context, task, entryPoint, format string, maxSymbols, tokenBudget int) (*mcp.CallToolResult, error) { args := map[string]any{ "task": task, "format": format, "max_symbols": float64(maxSymbols), "token_budget": float64(tokenBudget), } if entryPoint != "" { args["entry_point"] = entryPoint } argsJSON, _ := json.Marshal(args) req := mcp.CallToolRequest{} req.Params.Name = "export_context" _ = json.Unmarshal(argsJSON, &req.Params.Arguments) return s.handleExportContext(ctx, req) } // RegisterToolScope records the ToolScope for toolName so the // dispatcher can validate `repo` per call. Tools that don't register a // scope behave as if unscoped — legacy single-repo behavior — until // every tool is migrated. func (s *Server) RegisterToolScope(toolName string, scope ToolScope) { s.toolScopes.set(toolName, scope) } // ToolScope returns the registered scope for toolName and whether one // has been declared. Used by tests asserting that every tool has a // scope, and by the dispatcher. func (s *Server) ToolScope(toolName string) (ToolScope, bool) { return s.toolScopes.get(toolName) } // ToolScopeMap returns a snapshot of every registered tool name → // scope-name mapping. Used for diagnostics and for the // `workspace_info`-style introspection tool (scope: workspace). func (s *Server) ToolScopeMap() map[string]string { return s.toolScopes.snapshot() } // RegisteredScopedTools returns the registered tool names sorted // lexically. Test convenience. func (s *Server) RegisteredScopedTools() []string { return s.toolScopes.allTools() } // ResolveToolScope is the public entry point used by the dispatcher: // looks up the tool's scope, then validates the request's `repo` // argument against it. Returns either the resolved repo set or a // structured protocol error suitable for the caller to surface // verbatim. // // When the tool isn't in the registry, returns a nil ScopedRepos and // nil error — callers treat this as "unscoped, do not enforce" so // gradual migration doesn't break anything. func (s *Server) ResolveToolScope(toolName string, repo any) (*ScopedRepos, *mcp.CallToolResult) { scope, ok := s.toolScopes.get(toolName) if !ok { return nil, nil } return ResolveScopedRepos(scope, repo) } // communityCacheToken is the per-graph identity tuple // handleAnalyzeClusters checks before re-running the incremental // detector. EdgeIdentity moves on any structural mutation; NodeCount // and EdgeCount cover pure additions / removals that leave the // identity counter alone. A zero token is "never populated". type communityCacheToken struct { edgeIdentity int nodeCount int edgeCount int } func (s *Server) currentCommunityToken() communityCacheToken { return communityCacheToken{ edgeIdentity: s.graph.EdgeIdentityRevisions(), nodeCount: s.graph.NodeCount(), edgeCount: s.graph.EdgeCount(), } } // RunAnalysis performs community detection and process discovery on // the current graph, then pushes a `notifications/resources/updated` // for every bootstrap resource so subscribed clients can refresh // without polling. func (s *Server) RunAnalysis() { s.analysisMu.Lock() // Detect communities through the incremental path, threading the // partition cache. When a re-warm only touched a few packages // this recomputes just those; the cache is also left warm so the // next `analyze kind=clusters` call inherits it. The result is // shape-identical to a full DetectCommunities run. communities, cache, _ := analysis.DetectCommunitiesLeidenIncremental(s.graph, s.leidenCache) s.communities = communities s.leidenCache = cache s.communitiesToken = s.currentCommunityToken() // Feed the freshly computed per-package fingerprints to the // backend's bundle cache so it retires bundles for packages whose // content changed since the last pass and keeps the rest. The // fingerprints are edge-aware (DetectCommunitiesLeidenIncremental // folds each package's nodes and the edges touching them), so this // is the correct staleness signal for cached node + in/out edges. // A backend without a bundle cache simply doesn't satisfy the // interface and this no-ops. if sink, ok := s.backendStore().(graph.BundleFingerprintSink); ok && cache != nil { sink.SetBundleFingerprints(cache.PackageFingerprints()) } s.processes = analysis.DiscoverProcesses(s.graph) s.pageRank = analysis.ComputePageRank(s.graph) // Compact CSR adjacency over the same call / reference edge set // PageRank uses — the substrate for seeded random-walk proximity // queries. Built once here so per-query walks never re-scan the // graph; stamped with the current graph identity for the same // invalidation discipline as the community cache. s.adjacency = analysis.BuildAdjacencySnapshot(s.graph) s.adjacencyToken = s.currentCommunityToken() // Auto-concept vocabulary: mine domain phrases from symbol names // so equivalence-class expansion can bridge repo-specific terms // even with no LLM provider configured. s.autoConcepts = search.BuildAutoConcepts(s.graph) // HITS authority/hub scores -- fed into the search rerank as an // authority signal that complements raw fan-in. s.hits = analysis.ComputeHITS(s.graph) // Default-threshold hotspot ranking — cached because FindHotspots // triggers ComputeBetweenness which is the shared wall-clock // floor for outline / architecture / wakeup / the resource view. s.hotspots = analysis.FindHotspots(s.graph, communities, 0) s.analysisMu.Unlock() // The graph was just rebuilt, so the lazy-enrichment ledger — symbol // IDs whose incoming refs were confirmed against the *previous* graph // — is both potentially stale (a reindex can re-mint those IDs) and // unbounded across a long daemon session. Reset it so re-confirmation // runs against the new graph and the ledger stays scoped to one // analysis epoch. Kept outside analysisMu: refsConfirmed carries its // own synchronisation and a racing reader just re-confirms, which is // idempotent. s.resetConfirmedRefs() // Bootstrap-resource payloads (graph_stats, index_health, etc.) // can change after re-warm even when the analysis itself didn't // — node counts move on every reindex. Fire updates regardless. s.notifyBootstrapResourcesUpdated() // Coarse hot-reload signal: the graph has just been rebuilt, so // any cached query result a long-lived client holds may be stale. if s.graphInvalidatedBroadcaster != nil && s.graph != nil { s.graphInvalidatedBroadcaster.broadcast(s.graph.NodeCount(), s.graph.EdgeCount(), "reanalysis") } // A full analysis pass (PageRank / Leiden / HITS / hotspots over the // whole graph) is one of the daemon's largest on-demand allocation // bursts. Scavenge its high-water back to the OS so a client-triggered // reanalysis doesn't ratchet the idle footprint up and leave it there. freeOSMemoryAfterBurst(s.logger, "mcp_analysis") } // freeOSMemoryAfterBurst returns a completed whole-graph burst's heap // high-water to the OS. debug.FreeOSMemory forces a GC + scavenge; // GORTEX_DAEMON_MEMRELEASE=0 (or "false") disables it. The env check is // duplicated here (rather than shared) because the canonical release helper // lives in the cmd layer, which this package must not import. func freeOSMemoryAfterBurst(logger *zap.Logger, reason string) { if v := os.Getenv("GORTEX_DAEMON_MEMRELEASE"); v == "0" || strings.EqualFold(v, "false") { return } start := time.Now() debug.FreeOSMemory() if logger != nil { logger.Debug("mcp: released heap to OS", zap.String("reason", reason), zap.Duration("elapsed", time.Since(start))) } } // resetConfirmedRefs clears the lazy-enrichment ledger (see the // refsConfirmed field). sync.Map has no clear-all, so this ranges and // deletes; it is safe against concurrent confirmSymbolRefsOnDemand // readers because a racing miss just re-confirms the symbol, which is // idempotent — it re-lands the same lsp_resolved edges. func (s *Server) resetConfirmedRefs() { s.refsConfirmed.Range(func(k, _ any) bool { s.refsConfirmed.Delete(k) return true }) } func (s *Server) getCommunities() *analysis.CommunityResult { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.communities } // incrementalCommunities runs Leiden community detection through the // incremental path, threading the per-server partition cache so a // re-run after only a few packages changed re-partitions just those // packages. The cache it returns is stored back under analysisMu so // the next clusters request can build on it. The accompanying stats // describe whether the fast path or a full recompute ran. // // Short-circuits when the cached communities are still valid for the // live graph: the (NodeCount, EdgeCount, EdgeIdentityRevisions) token // captured by the last detector run is compared against the current // graph identity in three scalar reads. On a disk backend a match skips the // AllNodes / AllEdges fingerprint scan that otherwise dominates the // call (~140s on a fresh daemon) and serves the existing partition // straight from the cache. The reported stats describe a no-op // incremental run (no changed packages, no repartitioned nodes) so // callers see the cache hit on the wire. func (s *Server) incrementalCommunities() (*analysis.CommunityResult, analysis.IncrementalCommunityStats) { s.analysisMu.Lock() defer s.analysisMu.Unlock() cur := s.currentCommunityToken() if s.communities != nil && s.communitiesToken == cur { stats := analysis.IncrementalCommunityStats{ Incremental: true, } if s.leidenCache != nil { stats.TotalPackages = len(s.leidenCache.PackageFingerprints()) } if s.logger != nil { s.logger.Debug("incrementalCommunities cache hit", zap.Int("nodes", cur.nodeCount), zap.Int("edges", cur.edgeCount), zap.Int("edge_identity_rev", cur.edgeIdentity)) } return s.communities, stats } if s.logger != nil { // INFO-level on the miss path so a regression that re-introduces // a steady-state cache miss is visible without flipping the // daemon to debug. The full token diff is here precisely to // catch background-mutation regressions (some pass keeps drifting // the edge count under the cache and the Leiden walk runs every // call). A real first-call miss is a single line in the log. s.logger.Info("incrementalCommunities cache miss", zap.Bool("communities_nil", s.communities == nil), zap.Int("cached_nodes", s.communitiesToken.nodeCount), zap.Int("cur_nodes", cur.nodeCount), zap.Int("cached_edges", s.communitiesToken.edgeCount), zap.Int("cur_edges", cur.edgeCount), zap.Int("cached_edge_rev", s.communitiesToken.edgeIdentity), zap.Int("cur_edge_rev", cur.edgeIdentity)) } result, cache, stats := analysis.DetectCommunitiesLeidenIncremental(s.graph, s.leidenCache) s.communities = result s.leidenCache = cache // Capture the token AFTER the algo finishes — if the graph mutated // during the (potentially slow) detector run, the token reflects // the state the result was actually computed against, and the next // call's token comparison stays meaningful. s.communitiesToken = s.currentCommunityToken() return result, stats } func (s *Server) getProcesses() *analysis.ProcessResult { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.processes } func (s *Server) getPageRank() *analysis.PageRankResult { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.pageRank } // getAdjacency returns the cached CSR adjacency snapshot built by the // last RunAnalysis pass, or nil before the first pass. The snapshot is // immutable after construction, so the caller may run seeded walks over // it after releasing the read lock. func (s *Server) getAdjacency() *analysis.AdjacencySnapshot { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.adjacency } // getAutoConcepts returns the per-repo auto-mined concept // vocabulary. Nil until the first RunAnalysis pass; callers // nil-check (AutoConcepts.Expand is itself nil-safe). func (s *Server) getAutoConcepts() *search.AutoConcepts { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.autoConcepts } // getHITS returns the HITS authority/hub result. Nil until the // first RunAnalysis pass; callers nil-check (HITSResult accessors // are themselves nil-safe). func (s *Server) getHITS() *analysis.HITSResult { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.hits } // getHotspots returns the default-threshold hotspot ranking computed // by the most recent RunAnalysis pass. Nil/empty until the first // pass; callers use the live FindHotspots(threshold) path when they // need a non-default threshold. Returned slice is shared and must // not be mutated by the caller. func (s *Server) getHotspots() []analysis.HotspotEntry { s.analysisMu.RLock() defer s.analysisMu.RUnlock() return s.hotspots } // SetArchitecture installs the declarative architecture-rules DSL so // check_guards evaluates layered violations alongside the flat guard // rules. Called by the server / daemon entrypoint right after // NewServer; a no-op effect when the config carries no layers. func (s *Server) SetArchitecture(arch config.ArchitectureConfig) { s.architecture = arch } // SetEventRules installs the declarative event-boundary rule family so // change_contract evaluates pub/sub producer/consumer constraints. Called by // the server / daemon entrypoint right after NewServer; a no-op when the // config carries no event rules. func (s *Server) SetEventRules(rules []config.EventRule) { s.eventRules = rules } // WatchForReanalysis subscribes to hub events and re-runs analysis after // a debounce period of inactivity. It runs in a background goroutine. func (s *Server) WatchForReanalysis(h *hub.Hub, debounceMs int) { subID, events := h.Subscribe() debounce := time.Duration(debounceMs) * time.Millisecond go func() { var timer *time.Timer for ev := range events { _ = ev // any event triggers reanalysis if timer != nil { timer.Stop() } timer = time.AfterFunc(debounce, func() { s.logger.Info("re-running analysis after graph change") s.RunAnalysis() }) } // Channel closed — hub is shutting down. if timer != nil { timer.Stop() } _ = subID }() } // ServeStdio starts the MCP server on stdin/stdout. func (s *Server) ServeStdio() error { return server.ServeStdio(s.mcpServer) } // MCPServer returns the underlying MCP server instance. // This is used by the eval-server to wire tool dispatch into an HTTP handler. 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. // // Lazy-tool routing (N50): when the registry is enabled and the tool // name is not in hotEagerTools, the (tool, handler) pair is stashed // in s.lazy instead of registered with the live MCP server. The // tools_search discovery tool returns the schema on demand and calls // lazy.Promote(name), which lands the tool in mcpServer via the // closure wired in attachLazyRegistry. Net effect: the initial // tools/list payload drops from ~88 tools to ~25, reducing per- // session context burn for token-economical clients while keeping // the full surface reachable through a one-call discovery hop. func (s *Server) addTool(tool mcp.Tool, handler server.ToolHandlerFunc) { // Scrub control characters / ANSI escapes out of the tool's text // before it reaches any client's tools/list rendering. tool is a // value copy, so this mutates only the registered instance. scrubToolText(&tool) // Embed a project-size-scaled exploration-call budget in navigation // tools' descriptions so the model self-throttles. Runs before the // deferred-vs-live split so a tool keeps the hint after promotion. s.annotateToolBudget(&tool) // Replace the recurring-parameter prose (format / max_bytes / cursor / // fields / scope / repo / project / workspace / ref) with a terse gloss; // the full semantics live once in the server instructions legend. Runs // before the split so deferred tools carry the compact schema too. compactSharedToolParams(&tool) if s.lazy != nil && s.lazy.IsDeferred(tool.Name) { s.lazy.Register(tool, handler) return } s.mcpServer.AddTool(tool, s.wrapToolHandler(handler)) } // attachLazyRegistry wires the deferred catalog to the live MCP // server so a tools_search-driven promotion lands the tool in // mcpServer (which then fires notifications/tools/list_changed for // subscribed clients). Safe to call multiple times; the latest // closure wins. func (s *Server) attachLazyRegistry() { if s.lazy == nil { return } s.lazy.promote = func(dt *deferredTool) { s.mcpServer.AddTool(dt.tool, s.wrapToolHandler(dt.handler)) } } // EnsureToolPromoted makes a deferred tool callable by name. Under the // defer-mode tool surface (the `core` default) a tool that is held out of the // eager tools/list lives in the lazy registry and is unknown to the underlying // MCP server until tools_search promotes it — so a direct tools/call for it // returns "tool not found". A caller that dispatches a tools/call by name — // notably the CLI's `gortex call` and the curated `gortex` verbs, which reach // the daemon over the same socket — calls this first so the "reachable by name // regardless of tools/list visibility" contract actually holds. tools_search // stays the discovery path; this only makes an already-known name reachable // without a discovery round-trip. It is a no-op (returns false) when there is // no lazy registry or the tool is live, absent, or already promoted; a hidden // (hide-mode) tool is never deferred, so this never bypasses the hide gate. func (s *Server) EnsureToolPromoted(name string) bool { if s == nil || s.lazy == nil || name == "" { return false } if !s.lazy.IsDeferred(name) { return false } return len(s.lazy.Promote(name)) > 0 } // 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 // effectiveContractRegistry) so that repos tracked or re-indexed at runtime // are visible immediately. func (s *Server) SetContractRegistry(r *contracts.Registry) { s.contractRegistry = r } // effectiveContractRegistry resolves the current contract registry. It prefers // a live view over any snapshot: in multi-repo mode it re-merges per-repo // registries on every call so that track_repository / index_repository at // runtime take effect without a restart. Falls back to the single indexer, // then to the explicit override. func (s *Server) effectiveContractRegistry() *contracts.Registry { if s.multiIndexer != nil { return s.multiIndexer.MergedContractRegistry() } if s.indexer != nil { if cr := s.indexer.ContractRegistry(); cr != nil { return cr } } return s.contractRegistry } // SetSemanticManager sets the semantic enrichment manager for the MCP server. func (s *Server) SetSemanticManager(m *semantic.Manager) { s.semanticMgr = m } // SetTelemetryRecorder installs the consent-gated usage recorder. Passing a // disabled or nil recorder leaves telemetry off; the dispatch path is unchanged // either way because Record is nil-safe and fail-silent. func (s *Server) SetTelemetryRecorder(r *telemetry.Recorder) { s.recorder = r } // FlushTelemetry persists any buffered usage counts. Called on daemon // shutdown; a no-op when telemetry is off. func (s *Server) FlushTelemetry() { s.recorder.Flush() } // SemanticManager returns the semantic enrichment manager. func (s *Server) SemanticManager() *semantic.Manager { return s.semanticMgr } // watcherHistory is the subset of indexer.Watcher / indexer.MultiWatcher // the MCP server consumes. Defined as an interface so the server can // accept either a single-repo Watcher (legacy `gortex mcp --watch` path) // or a MultiWatcher (daemon path) through one SetWatcher call. The two // concrete types already expose the same surface; this interface just // names it. type watcherHistory interface { History() []indexer.GraphChangeEvent HistorySince(since time.Time) []indexer.GraphChangeEvent OnSymbolChange(cb indexer.SymbolChangeCallback) } // SetWatcher sets the watcher after background initialization and registers // a symbol change callback to record modifications in symbolHistory. // Accepts either a single-repo *indexer.Watcher or a multi-repo // *indexer.MultiWatcher — both satisfy watcherHistory. func (s *Server) SetWatcher(w watcherHistory) { s.watcher = w // Register callback to track symbol modifications for // get_symbol_history AND fan stale_refs notifications to any // subscribed sessions whose working set intersects the change. w.OnSymbolChange(func(filePath string, oldSymbols, newSymbols []*graph.Node) { // stale_refs broadcaster runs first so a slow notification // path (e.g. a clogged session channel) can't delay the // in-process symbol history bookkeeping below. if s.staleRefsBroadcaster != nil { s.staleRefsBroadcaster.handleSymbolChange(filePath, oldSymbols, newSymbols) } oldMap := make(map[string]string, len(oldSymbols)) // ID → signature for _, n := range oldSymbols { sig, _ := n.Meta["signature"].(string) oldMap[n.ID] = sig } newMap := make(map[string]string, len(newSymbols)) // ID → signature for _, n := range newSymbols { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } sig, _ := n.Meta["signature"].(string) newMap[n.ID] = sig } // Detect modified symbols (present in both old and new with changed signature). for id, oldSig := range oldMap { if newSig, exists := newMap[id]; exists { sigChanged := oldSig != newSig s.symHistory.Record(id, sigChanged) } } // Detect removed symbols (in old but not in new). for id := range oldMap { if _, exists := newMap[id]; !exists { s.symHistory.Record(id, true) } } // Detect added symbols (in new but not in old). for id := range newMap { if _, exists := oldMap[id]; !exists { s.symHistory.Record(id, false) } } }) }