engine.ts 11 KB

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