feat(mcp): share one serve --mcp per project across MCP clients (#411)

One shared, detached daemon per project root: every `codegraph serve --mcp` is a thin stdio<->socket proxy (Unix socket / Windows named pipe) to it, so N agents in one repo share a single file watcher, SQLite connection, and tree-sitter warm-up instead of N copies. The daemon outlives any single session and reaps via client-refcount + idle timeout; `CODEGRAPH_NO_DAEMON=1` opts out.

Hardened during review: detached-process lifecycle (preserves the #277 watchdog via the proxy; the daemon no longer orphans on host SIGKILL), atomic lockfile + pid-verified stale-clear (no double-daemon on concurrent startup), realpath root canonicalization. Validated on macOS, Linux (Docker - 3x fewer inotify watches for 3 agents), and Windows (named pipes); A/B confirms byte-identical tool output vs direct mode. Closes #411.

Co-Authored-By: Colby McHenry <me@colbymchenry.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Morgan
2026-05-25 19:54:56 -05:00
committed by GitHub
co-authored by Colby McHenry Claude Opus 4.7
parent 2721165604
commit 995da54430
12 changed files with 2256 additions and 547 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* Daemon socket + lockfile path helpers — issue #411.
*
* One shared `codegraph serve --mcp` daemon per project root means we need a
* stable, project-keyed rendezvous between cooperating processes. The IPC
* surface area is just two file paths:
*
* - `daemon.sock` — Unix domain socket / named pipe the daemon listens on.
* - `daemon.pid` — atomic-create lockfile holding the daemon's pid + version.
*
* Both live under `.codegraph/` so the project-scoped uninstall (`codegraph
* uninit`) sweeps them up for free.
*
* Special-case: Unix domain socket paths have a hard length limit (~104 on
* macOS, ~108 on Linux); when the in-project path exceeds it we fall back to
* an absolute-path hash under `os.tmpdir()`. The pidfile always stays in the
* project (it doesn't have a length limit) — and acts as the authoritative
* pointer to the socket path the daemon chose.
*/
import * as crypto from 'crypto';
import * as os from 'os';
import * as path from 'path';
import { getCodeGraphDir } from '../directory';
/** Soft upper bound for in-project socket paths. */
const POSIX_SOCKET_PATH_LIMIT = 100;
/** Short stable identifier for a project root — used in tmpdir/pipe names. */
function projectHash(projectRoot: string): string {
return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
}
/**
* Compute the socket / named-pipe path the daemon should listen on (and the
* proxy should connect to) for `projectRoot`. Deterministic given a project
* root, so independent processes converge without coordination.
*/
export function getDaemonSocketPath(projectRoot: string): string {
if (process.platform === 'win32') {
return `\\\\.\\pipe\\codegraph-${projectHash(projectRoot)}`;
}
const inProject = path.join(getCodeGraphDir(projectRoot), 'daemon.sock');
if (inProject.length <= POSIX_SOCKET_PATH_LIMIT) return inProject;
// Long project paths (deep monorepos, Bazel out dirs) need tmpdir fallback
// or `bind` returns EADDRINUSE / ENAMETOOLONG. Hash keeps it project-scoped.
return path.join(os.tmpdir(), `codegraph-${projectHash(projectRoot)}.sock`);
}
/** Absolute path to the daemon pid lockfile for `projectRoot`. */
export function getDaemonPidPath(projectRoot: string): string {
return path.join(getCodeGraphDir(projectRoot), 'daemon.pid');
}
/** Structured contents of the pid lockfile. */
export interface DaemonLockInfo {
pid: number;
version: string;
socketPath: string;
startedAt: number;
}
/**
* Serialize a {@link DaemonLockInfo} for writing to the pidfile. JSON for
* human readability — operators occasionally `cat` this when debugging.
*/
export function encodeLockInfo(info: DaemonLockInfo): string {
return JSON.stringify(info, null, 2) + '\n';
}
/**
* Parse a pidfile body. Tolerant of old-format pidfiles (plain decimal pid) so
* a 0.10.x daemon doesn't trip over a 0.9.x lockfile if that ever happens —
* we treat such a lockfile as "process is unknown version, refuse to share."
*/
export function decodeLockInfo(raw: string): DaemonLockInfo | null {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
if (
parsed &&
typeof parsed.pid === 'number' &&
typeof parsed.version === 'string' &&
typeof parsed.socketPath === 'string' &&
typeof parsed.startedAt === 'number'
) {
return parsed as DaemonLockInfo;
}
return null;
} catch {
// Fall through to legacy plain-pid handling.
}
const pid = Number(trimmed);
if (Number.isFinite(pid) && pid > 0) {
return { pid, version: 'unknown', socketPath: '', startedAt: 0 };
}
return null;
}