fix(mcp): skip fs.watch on WSL2 /mnt drives that hang MCP startup (#199) (#210)

Recursive fs.watch on a WSL2 /mnt NTFS/9p mount walks the directory tree
with every readdir/stat crossing the Windows boundary, stalling the event
loop long enough to blow past opencode's 30s MCP handshake timeout so the
tools never appear. This is the file-watcher half of the #172 fix, which
moved the DB/WASM open off the handshake but left the watcher on the
critical path.

- Add watchDisabledReason() policy: CODEGRAPH_NO_WATCH (off) >
  CODEGRAPH_FORCE_WATCH (force on) > WSL2 + /mnt auto-detect (off).
  FileWatcher.start() and the MCP server both honor it; the server now
  logs why watching is off and how to refresh.
- Add `codegraph serve --mcp --no-watch`.
- When watching is off, init/install offer git sync hooks (post-commit,
  post-merge, post-checkout) that run `codegraph sync` in the background,
  or fall back to manual sync; either way the user is told the index
  stays frozen until re-synced. uninit removes the hooks.
- Tests: watch-policy + git-hooks (idempotency, user-content preservation,
  core.hooksPath).

Root-cause analysis and workaround by @mengfanbo123.

Closes #199

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-05-20 10:32:08 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 79b9601aae
commit cf7db7cb98
10 changed files with 714 additions and 5 deletions
+26 -1
View File
@@ -415,6 +415,10 @@ program
clack.log.success(`${target.displayName}: ${file.action} ${file.path}`);
}
} catch { /* non-fatal */ }
try {
const { offerWatchFallback } = await import('../installer');
await offerWatchFallback(clack, projectPath);
} catch { /* non-fatal */ }
clack.outro('');
return;
}
@@ -459,6 +463,11 @@ program
clack.log.info('Run "codegraph index" to index the project');
}
try {
const { offerWatchFallback } = await import('../installer');
await offerWatchFallback(clack, projectPath);
} catch { /* non-fatal */ }
clack.outro('Done');
cg.destroy();
} catch (err) {
@@ -505,6 +514,15 @@ program
const cg = CodeGraph.openSync(projectPath);
cg.uninitialize();
// Clean up any git sync hooks we installed (no-op if none / not a repo).
try {
const { removeGitSyncHook } = await import('../sync/git-hooks');
const removed = removeGitSyncHook(projectPath);
if (removed.installed.length > 0) {
info(`Removed git ${removed.installed.join(', ')} sync hook${removed.installed.length > 1 ? 's' : ''}`);
}
} catch { /* non-fatal */ }
success(`Removed CodeGraph from ${projectPath}`);
} catch (err) {
error(`Failed to uninitialize: ${err instanceof Error ? err.message : String(err)}`);
@@ -1085,9 +1103,16 @@ program
.description('Start CodeGraph as an MCP server for AI assistants')
.option('-p, --path <path>', 'Project path (optional for MCP mode, uses rootUri from client)')
.option('--mcp', 'Run as MCP server (stdio transport)')
.action(async (options: { path?: string; mcp?: boolean }) => {
.option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)')
.action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => {
const projectPath = options.path ? resolveProjectPath(options.path) : undefined;
// Commander sets watch=false when --no-watch is passed. Route it through
// the same env-var chokepoint the watcher and MCP server already honor.
if (options.watch === false) {
process.env.CODEGRAPH_NO_WATCH = '1';
}
try {
if (options.mcp) {
// Start MCP server - it handles initialization lazily based on rootUri from client