Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,10 @@ describe('Shared MCP daemon (issue #411)', () => {
});

it('two invocations share ONE detached daemon; both attach as proxies', async () => {
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' };
const env = {
CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000',
CODEGRAPH_MCP_DEBUG: '1',
};

const first = spawnServer(tempDir, env);
servers.push(first);
Expand All @@ -203,6 +206,9 @@ describe('Shared MCP daemon (issue #411)', () => {

// The launcher is a PROXY (not the daemon itself) — that's the detach fix.
await waitFor(() => first.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);
// The local-handshake proxy also needs the main-thread liveness watchdog
// (#943), matching direct and detached-daemon mode.
await waitFor(() => first.stderr.some((l) => l.includes('[CodeGraph watchdog] armed')), 8000);

// A detached daemon came up and recorded itself.
await waitFor(() => fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid')), 8000);
Expand Down Expand Up @@ -416,10 +422,6 @@ describe('Shared MCP daemon (issue #411)', () => {
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
const daemonPid = readLockPid(realRoot)!;

// A warm call goes through the daemon.
sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
await waitFor(() => findResponse(server.stdout, 2), 10000);

// Kill the daemon out from under the live proxy.
process.kill(daemonPid, 'SIGTERM');
expect(await waitProcessExit(daemonPid, 8000)).toBe(true);
Expand Down
6 changes: 6 additions & 0 deletions src/mcp/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SERVER_INFO, PROTOCOL_VERSION } from './session';
import { SERVER_INSTRUCTIONS } from './server-instructions';
import { getStaticTools } from './tools';
import { getTelemetry, ClientInfo } from '../telemetry';
import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
import type { MCPEngine } from './engine';

/** Default poll cadence for the PPID watchdog (same as the direct server). */
Expand Down Expand Up @@ -202,6 +203,10 @@ export interface LocalHandshakeDeps {
* never costs the old fall-back-to-direct robustness.
*/
export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<void> {
// The proxy is long-lived and can serve fallback tool calls in-process. Match
// direct/daemon mode by killing this launcher if its main thread wedges, so an
// MCP host retry cannot accumulate abandoned `serve --mcp` wrapper processes.
const livenessWatchdog: WatchdogHandle | null = installMainThreadWatchdog();
let daemonStatus: 'connecting' | 'ready' | 'failed' = 'connecting';
let daemonSocket: net.Socket | null = null;
let clientInitId: unknown = undefined; // suppress the daemon's reply to the forwarded initialize
Expand Down Expand Up @@ -232,6 +237,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
};
const shutdown = (): void => {
if (shuttingDown) return; shuttingDown = true;
try { livenessWatchdog?.stop(); } catch { /* ignore */ }
try { daemonSocket?.destroy(); } catch { /* ignore */ }
try { engine?.stop(); } catch { /* ignore */ }
process.exit(0);
Expand Down