| 228 | * daemon at start time. |
| 229 | */ |
| 230 | export class MCPServer { |
| 231 | private projectPath: string | null; |
| 232 | // Direct-mode-only state. In daemon mode the per-connection sessions live |
| 233 | // inside the Daemon class; in proxy mode there is no session at all. |
| 234 | private session: MCPSession | null = null; |
| 235 | private engine: MCPEngine | null = null; |
| 236 | private daemon: Daemon | null = null; |
| 237 | private ppidWatchdog: ReturnType<typeof setInterval> | null = null; |
| 238 | // Worker-thread liveness watchdog (#850). Long-lived modes only; SIGKILLs the |
| 239 | // process if the main thread wedges in a non-yielding sync loop. |
| 240 | private livenessWatchdog: WatchdogHandle | null = null; |
| 241 | // PPID watchdog baseline — from the CLI entry's earliest-possible capture |
| 242 | // (early-ppid.ts). Capturing here (construction) already lost the race when |
| 243 | // the launcher was killed during module loading (#1185). |
| 244 | private originalPpid: number = EARLY_PPID; |
| 245 | private hostPpid: number | null = parseHostPpid(process.env[HOST_PPID_ENV]); |
| 246 | // Idempotency guard for stop(). |
| 247 | private stopped = false; |
| 248 | private mode: 'unstarted' | 'direct' | 'proxy' | 'daemon' = 'unstarted'; |
| 249 | |
| 250 | constructor(projectPath?: string) { |
| 251 | this.projectPath = projectPath || null; |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Start the MCP server. |
| 256 | * |
| 257 | * Decision order: |
| 258 | * 1. `CODEGRAPH_NO_DAEMON=1` → direct mode (unchanged pre-#411 behavior). |
| 259 | * 2. `CODEGRAPH_DAEMON_INTERNAL=1` → we ARE the detached daemon; listen. |
| 260 | * 3. No `.codegraph/` reachable → direct mode (the daemon's lockfile and |
| 261 | * socket both live under `.codegraph/`). |
| 262 | * 4. Otherwise connect to (or spawn) the shared daemon and proxy to it. |
| 263 | * |
| 264 | * On any unexpected failure in step 4 we transparently fall back to direct |
| 265 | * mode — a misbehaving daemon must never block a session from starting. |
| 266 | */ |
| 267 | async start(): Promise<void> { |
| 268 | // Long-lived process (direct / proxy / daemon alike): flush buffered |
| 269 | // telemetry opportunistically. Fire-and-forget + unref'd — adds nothing |
| 270 | // to the handshake path and never keeps the process alive. |
| 271 | getTelemetry().startInterval(); |
| 272 | |
| 273 | // #1243: the MCP config launches the local binary, so a server left |
| 274 | // running drifts behind releases with no signal. Refresh the shared |
| 275 | // update-check cache in the background and log ONE stderr notice when a |
| 276 | // newer version exists (stderr only — stdout is the protocol channel). |
| 277 | // The notice also reaches the agent via the initialize instructions and |
| 278 | // codegraph_status. Fire-and-forget: adds nothing to the handshake path. |
| 279 | checkForUpdateInBackground(); |
| 280 | |
| 281 | // The detached daemon process itself. Checked before the opt-out so the |
| 282 | // daemon honors the same env it was spawned with (it never sets NO_DAEMON). |
| 283 | if (daemonInternalSet()) { |
| 284 | return this.startDaemonProcess(); |
| 285 | } |
| 286 | |
| 287 | // Direct mode if the user opted out. Setting the env var is sufficient to |
nothing calls this directly
no test coverage detected