engine.ts 10 KB

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