engine.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. /**
  2. * MCP shared engine — the heavyweight, *shared* state for an MCP server:
  3. * the project's {@link CodeGraph} instance, file watcher, and the
  4. * {@link ToolHandler} cache for cross-project queries.
  5. *
  6. * One engine, many sessions:
  7. * - direct mode (single stdio session) instantiates one engine + one session;
  8. * - daemon mode instantiates one engine and a new session per socket
  9. * connection. Every session reads from the same SQLite WAL and the same
  10. * inotify watch set — that's the entire point of issue #411.
  11. */
  12. import * as os from 'os';
  13. import type CodeGraph from '../index';
  14. import { findNearestCodeGraphRoot } from '../directory';
  15. import { watchDisabledReason } from '../sync';
  16. import { ToolHandler } from './tools';
  17. import { QueryPool, resolvePoolSize } from './query-pool';
  18. // Lazy-load the heavy CodeGraph chain (sqlite + query/graph/context layers) OFF
  19. // the MCP startup path. It's only needed once a tool actually opens a project —
  20. // not to answer initialize/tools-list — so deferring it lets `serve --mcp` (and
  21. // the daemon it spawns) bind + register tools in ~Node-startup time instead of
  22. // ~800ms, closing the "No such tool available" cold-start race that made headless
  23. // agents flounder. require() is sync + cached on the CommonJS build.
  24. const loadCodeGraph = (): typeof import('../index').default =>
  25. (require('../index') as typeof import('../index')).default;
  26. export interface MCPEngineOptions {
  27. /**
  28. * Whether to start the file watcher when initializing. Daemon and direct
  29. * modes both want this true; tests may set it false to keep the engine
  30. * cheap. Honors {@link watchDisabledReason} regardless.
  31. */
  32. watch?: boolean;
  33. /**
  34. * Whether to off-load read-tool dispatch to a worker-thread pool. Only the
  35. * SHARED daemon wants this — it serves many concurrent clients on one event
  36. * loop, so without a pool concurrent explores serialize and starve the MCP
  37. * transport. Direct mode (one stdio client, no concurrency) leaves it off so a
  38. * single call never pays a worker round-trip. `CODEGRAPH_QUERY_POOL_SIZE=0`
  39. * disables it even in daemon mode.
  40. */
  41. queryPool?: boolean;
  42. }
  43. /**
  44. * Shared MCP engine. Thread-safe in the sense that multiple sessions can
  45. * call its methods concurrently — internally it serializes initialization
  46. * through a single promise so multiple sessions racing each other on first
  47. * connect never double-open the SQLite file.
  48. */
  49. export class MCPEngine {
  50. private cg: CodeGraph | null = null;
  51. private toolHandler: ToolHandler;
  52. // Project root we resolved to. Null until `ensureInitialized` succeeds
  53. // (or null forever if no .codegraph/ ever turned up — that's a valid
  54. // state for the engine, since cross-project queries still work).
  55. private projectPath: string | null = null;
  56. // Set on first `ensureInitialized` so subsequent sessions don't redo work.
  57. private initPromise: Promise<void> | null = null;
  58. private watcherStarted = false;
  59. private opts: Required<MCPEngineOptions>;
  60. private closed = false;
  61. // Off-loop read-tool pool (daemon mode only). Created lazily once the default
  62. // project is open — workers each hold their own WAL read connection.
  63. private queryPool: QueryPool | null = null;
  64. constructor(opts: MCPEngineOptions = {}) {
  65. this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false };
  66. this.toolHandler = new ToolHandler(null);
  67. }
  68. /**
  69. * Start the worker-thread query pool once a default project is open (daemon
  70. * mode only; honors `CODEGRAPH_QUERY_POOL_SIZE`). Idempotent and best-effort:
  71. * if workers can't spawn on this platform the ToolHandler keeps serving reads
  72. * in-process, so the pool can only help, never break, tool calls.
  73. */
  74. private maybeStartPool(root: string): void {
  75. if (!this.opts.queryPool || this.queryPool || this.closed) return;
  76. const size = resolvePoolSize(process.env.CODEGRAPH_QUERY_POOL_SIZE, os.cpus().length);
  77. if (size <= 0) {
  78. process.stderr.write('[CodeGraph MCP] Query pool disabled (CODEGRAPH_QUERY_POOL_SIZE=0); serving reads in-process.\n');
  79. return;
  80. }
  81. try {
  82. this.queryPool = new QueryPool({ root, size });
  83. this.toolHandler.setQueryPool(this.queryPool);
  84. process.stderr.write(`[CodeGraph MCP] Query pool: up to ${size} worker thread(s) for concurrent reads.\n`);
  85. } catch (err) {
  86. const msg = err instanceof Error ? err.message : String(err);
  87. process.stderr.write(`[CodeGraph MCP] Query pool unavailable (${msg}); serving reads in-process.\n`);
  88. this.queryPool = null;
  89. }
  90. }
  91. /**
  92. * Convenience for {@link MCPServer} compatibility: pre-seed an explicit
  93. * project path (from the `--path` CLI flag) without yet opening it. This
  94. * keeps the synchronous constructor cheap; the actual open happens on the
  95. * first `ensureInitialized` call.
  96. */
  97. setProjectPathHint(projectPath: string): void {
  98. this.projectPath = projectPath;
  99. this.toolHandler.setDefaultProjectHint(projectPath);
  100. }
  101. /** Project root that the engine resolved on first init (null if none). */
  102. getProjectPath(): string | null {
  103. return this.projectPath;
  104. }
  105. /** Shared ToolHandler — sessions delegate tool dispatch through this. */
  106. getToolHandler(): ToolHandler {
  107. return this.toolHandler;
  108. }
  109. /** Whether the default project's CodeGraph is open. */
  110. hasDefaultCodeGraph(): boolean {
  111. return this.toolHandler.hasDefaultCodeGraph();
  112. }
  113. /**
  114. * Walk up from `searchFrom` to find the nearest `.codegraph/` and open it.
  115. * Idempotent: concurrent callers share one in-flight init; subsequent
  116. * callers after success are no-ops.
  117. *
  118. * The original `MCPServer.tryInitializeDefault` carried the same retry-on-
  119. * subsequent-tool-call semantics; we preserve them by NOT throwing when the
  120. * search misses (just leaves `cg` null so the next call can retry).
  121. */
  122. async ensureInitialized(searchFrom: string): Promise<void> {
  123. if (this.closed) return;
  124. if (this.toolHandler.hasDefaultCodeGraph()) return;
  125. if (this.initPromise) {
  126. try { await this.initPromise; } catch { /* let caller retry */ }
  127. return;
  128. }
  129. this.initPromise = this.doInitialize(searchFrom).finally(() => {
  130. this.initPromise = null;
  131. });
  132. try {
  133. await this.initPromise;
  134. } catch {
  135. // Init errors are logged inside `doInitialize`; falling through here
  136. // matches MCPServer's previous "retry on next tool call" behavior.
  137. }
  138. }
  139. /**
  140. * Synchronous last-resort init used by the per-session retry loop when the
  141. * background `ensureInitialized` already finished (or failed) and we need
  142. * to pick up a project that appeared *after* the engine started.
  143. */
  144. retryInitializeSync(searchFrom: string): void {
  145. if (this.closed) return;
  146. if (this.toolHandler.hasDefaultCodeGraph()) return;
  147. this.toolHandler.setDefaultProjectHint(searchFrom);
  148. const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
  149. if (!resolvedRoot) return;
  150. try {
  151. // Close any previously failed instance to avoid leaking resources.
  152. if (this.cg) {
  153. try { this.cg.close(); } catch { /* ignore */ }
  154. this.cg = null;
  155. }
  156. this.cg = loadCodeGraph().openSync(resolvedRoot);
  157. this.projectPath = resolvedRoot;
  158. this.toolHandler.setDefaultCodeGraph(this.cg);
  159. this.startWatching();
  160. this.catchUpSync();
  161. this.maybeStartPool(resolvedRoot);
  162. } catch {
  163. // Still failing — caller will try again on the next tool call.
  164. }
  165. }
  166. /**
  167. * Close everything. Used on graceful daemon shutdown (SIGTERM/idle timeout)
  168. * and on direct-mode stop. Idempotent.
  169. */
  170. stop(): void {
  171. if (this.closed) return;
  172. this.closed = true;
  173. // Detach + terminate the worker pool first so no tool call routes to a
  174. // worker mid-teardown; outstanding pool calls resolve with graceful guidance.
  175. this.toolHandler.setQueryPool(null);
  176. if (this.queryPool) {
  177. void this.queryPool.destroy();
  178. this.queryPool = null;
  179. }
  180. this.toolHandler.closeAll();
  181. if (this.cg) {
  182. try { this.cg.close(); } catch { /* ignore */ }
  183. this.cg = null;
  184. }
  185. }
  186. private async doInitialize(searchFrom: string): Promise<void> {
  187. this.toolHandler.setDefaultProjectHint(searchFrom);
  188. const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
  189. if (!resolvedRoot) {
  190. // No .codegraph/ above searchFrom. Sessions may still discover one later via roots/list
  191. this.projectPath = searchFrom;
  192. return;
  193. }
  194. this.projectPath = resolvedRoot;
  195. try {
  196. this.cg = await loadCodeGraph().open(resolvedRoot);
  197. this.toolHandler.setDefaultCodeGraph(this.cg);
  198. this.startWatching();
  199. this.catchUpSync();
  200. this.maybeStartPool(resolvedRoot);
  201. } catch (err) {
  202. const msg = err instanceof Error ? err.message : String(err);
  203. process.stderr.write(`[CodeGraph MCP] Failed to open project at ${resolvedRoot}: ${msg}\n`);
  204. }
  205. }
  206. /**
  207. * Start file watching on the active CodeGraph instance. Idempotent — the
  208. * watcher is per-engine, not per-session, which is why the daemon path
  209. * collapses N inotify sets to one. The wording of the disabled-reason log
  210. * exactly matches the prior in-tree implementation so log-driven dashboards
  211. * keep working.
  212. */
  213. private startWatching(): void {
  214. if (!this.cg || this.watcherStarted || !this.opts.watch) return;
  215. const disabledReason = watchDisabledReason(this.projectPath ?? process.cwd());
  216. if (disabledReason) {
  217. process.stderr.write(
  218. `[CodeGraph MCP] File watcher disabled — ${disabledReason}. ` +
  219. `The graph will not auto-update; run \`codegraph sync\` (or install the git sync hooks via \`codegraph init\`) to refresh.\n`
  220. );
  221. this.watcherStarted = true;
  222. return;
  223. }
  224. // Optional override for the debounce window via env var (issue #403).
  225. // Useful for workspaces with bursty writes (formatter-on-save chains,
  226. // large generated outputs) where the 2s default fires too often. Clamped
  227. // to [100ms, 60s]; out-of-range / non-numeric values fall back to the
  228. // FileWatcher default. We log the active value so it's discoverable.
  229. const debounceMs = parseDebounceEnv(process.env.CODEGRAPH_WATCH_DEBOUNCE_MS);
  230. if (debounceMs !== undefined) {
  231. process.stderr.write(`[CodeGraph MCP] File watcher debounce: ${debounceMs}ms (CODEGRAPH_WATCH_DEBOUNCE_MS)\n`);
  232. }
  233. const started = this.cg.watch({
  234. debounceMs,
  235. onSyncComplete: (result) => {
  236. if (result.filesChanged > 0) {
  237. process.stderr.write(
  238. `[CodeGraph MCP] Auto-synced ${result.filesChanged} file(s) in ${result.durationMs}ms\n`
  239. );
  240. }
  241. },
  242. onSyncError: (err) => {
  243. process.stderr.write(`[CodeGraph MCP] Auto-sync error: ${err.message}\n`);
  244. },
  245. onDegraded: (reason) => {
  246. // Live watching gave up permanently (watch-resource exhaustion or a
  247. // write lock held past the retry budget). Say so loudly and ONCE — the
  248. // graph will no longer auto-update, so a long-running MCP session must
  249. // not keep assuming it's fresh. The reason already names the remedy
  250. // (`codegraph sync` / git sync hooks).
  251. process.stderr.write(`[CodeGraph MCP] File watcher degraded — ${reason}\n`);
  252. },
  253. });
  254. this.watcherStarted = true;
  255. if (started) {
  256. process.stderr.write('[CodeGraph MCP] File watcher active — graph will auto-sync on changes\n');
  257. } else {
  258. process.stderr.write(
  259. '[CodeGraph MCP] File watcher unavailable on this platform — run `codegraph sync` to refresh the graph after changes.\n'
  260. );
  261. }
  262. }
  263. /**
  264. * Reconcile the index with the current filesystem once, right after open —
  265. * catches edits, adds, deletes, and `git pull`/`checkout` changes made while
  266. * no watcher was running. Runs in the background, but the returned promise
  267. * is pushed into the ToolHandler as a one-shot gate so the *first* tool
  268. * call awaits completion before serving (without this, a tool call that
  269. * races past sync returns rows for files that no longer exist on disk —
  270. * and the per-file staleness banner can't help because `getPendingFiles()`
  271. * is populated by the watcher, not by catch-up).
  272. */
  273. private catchUpSync(): void {
  274. const cg = this.cg;
  275. if (!cg) return;
  276. const p = cg
  277. .sync()
  278. .then((result) => {
  279. const changed = result.filesAdded + result.filesModified + result.filesRemoved;
  280. if (changed > 0) {
  281. process.stderr.write(`[CodeGraph MCP] Caught up ${changed} file(s) changed since last run\n`);
  282. }
  283. })
  284. .catch((err) => {
  285. const msg = err instanceof Error ? err.message : String(err);
  286. process.stderr.write(`[CodeGraph MCP] Catch-up sync failed: ${msg}\n`);
  287. });
  288. this.toolHandler.setCatchUpGate(p);
  289. }
  290. }
  291. /**
  292. * Parse and clamp the CODEGRAPH_WATCH_DEBOUNCE_MS env override.
  293. *
  294. * Issue #403: workspaces with bursty writes (formatter-on-save, multi-file
  295. * refactors) sometimes want a longer quiet window before sync. Returns
  296. * `undefined` for unset / empty / non-numeric / out-of-range values so the
  297. * FileWatcher default (2000ms) takes over — never throws.
  298. *
  299. * Clamp range: 100ms (faster would mean a sync per keystroke) to 60s (longer
  300. * and the watcher feels broken). Out-of-range values are treated as "ignore
  301. * this misconfiguration" rather than capped, since silently capping a 0 or
  302. * a typoed value would mask a real config bug.
  303. */
  304. export function parseDebounceEnv(raw: string | undefined): number | undefined {
  305. if (!raw || !raw.trim()) return undefined;
  306. const n = Number(raw);
  307. if (!Number.isFinite(n) || !Number.isInteger(n)) return undefined;
  308. if (n < 100 || n > 60000) return undefined;
  309. return n;
  310. }