Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

2658 lines
104 KiB
Go

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 <slug> [--scope-project <slug>]`.
// 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/<id>.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=<root> 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
// <slug>`. 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 <repoPath>/.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
// <XDG_DATA_HOME>/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)
}
}
})
}