import { type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ killProcessTreeMock: vi.fn(), spawnCliMock: vi.fn(), untrackCliProcessMock: vi.fn(), })); vi.mock('@main/utils/childProcess', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args), spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args), untrackCliProcess: (...args: unknown[]) => hoisted.untrackCliProcessMock(...args), }; }); import { AgentTeamsMcpHttpServer, type AgentTeamsMcpHttpServerDeps, } from '@main/services/team/AgentTeamsMcpHttpServer'; class FakeChildProcess extends EventEmitter { pid: number; stderr = new EventEmitter(); constructor(pid = 43123) { super(); this.pid = pid; } } function sha256Hex(value: string): string { return createHash('sha256').update(value).digest('hex'); } type TestLaunchSpec = { command: string; args: string[]; env?: Record; }; function buildLaunchSpecHash(launchSpec: TestLaunchSpec): string { const env = launchSpec.env ? Object.fromEntries( Object.entries(launchSpec.env).sort(([left], [right]) => left.localeCompare(right)) ) : {}; return sha256Hex( JSON.stringify({ command: launchSpec.command, args: launchSpec.args, env }) ); } async function createTempStatePath(): Promise<{ root: string; statePath: string }> { const root = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-state-test-')); const statePath = path.join(root, 'mcp-http-server', 'state.json'); await mkdir(path.dirname(statePath), { recursive: true }); return { root, statePath }; } function buildIdentity(input: { port: number; launchSpec: TestLaunchSpec; ownerInstanceId?: string; }) { return { schemaVersion: 1 as const, service: 'agent-teams-mcp-http' as const, transport: 'httpStream' as const, host: '127.0.0.1', port: input.port, endpoint: '/mcp', claudeDirHash: sha256Hex(getClaudeBasePath()), launchSpecHash: buildLaunchSpecHash(input.launchSpec), ownerInstanceId: input.ownerInstanceId ?? 'previous-owner', }; } function buildState(input: { port: number; pid?: number | null; launchSpec: TestLaunchSpec; ownerInstanceId?: string; }) { const identity = buildIdentity(input); const url = `http://127.0.0.1:${input.port}/mcp`; return { ...identity, url, urlHash: sha256Hex(url), pid: input.pid ?? null, startedAt: '2026-05-21T00:00:00.000Z', updatedAt: '2026-05-21T00:00:00.000Z', }; } async function allocateLoopbackPort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { server.close(() => reject(new Error('Failed to allocate port'))); return; } server.close(() => resolve(address.port)); }); }); } async function flushAsyncCleanup(): Promise { await new Promise((resolve) => setImmediate(resolve)); await Promise.resolve(); } describe('AgentTeamsMcpHttpServer', () => { beforeEach(() => { hoisted.killProcessTreeMock.mockReset(); hoisted.spawnCliMock.mockReset(); hoisted.untrackCliProcessMock.mockReset(); }); it('starts the MCP server over HTTP with hidden app-owned process env', async () => { const child = new FakeChildProcess(); const spawnProcess = vi.fn(() => child as unknown as ChildProcess); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], env: { ELECTRON_RUN_AS_NODE: '1' }, }), allocatePort: async () => 41001, spawnProcess, waitForPort: vi.fn(async () => undefined), }); const handle = await server.ensureStarted(); expect(handle).toMatchObject({ url: 'http://127.0.0.1:41001/mcp', port: 41001, pid: 43123, generation: 1, diagnostics: [], }); expect(handle.urlHash).toMatch(/^[a-f0-9]{64}$/); expect(handle.transportEvidence).toMatchObject({ schemaVersion: 1, transport: 'httpStream', host: '127.0.0.1', port: 41001, endpoint: '/mcp', url: 'http://127.0.0.1:41001/mcp', urlHash: handle.urlHash, generation: 1, }); expect(spawnProcess).toHaveBeenCalledWith( 'node', [ 'mcp-server/dist/index.js', '--transport', 'httpStream', '--host', '127.0.0.1', '--port', '41001', '--endpoint', '/mcp', ], expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1', AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1', AGENT_TEAMS_MCP_HTTP_PORT: '41001', AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp', }) ); }); it('uses a hidden default spawn without holding stdout open', async () => { const child = new FakeChildProcess(); hoisted.spawnCliMock.mockReturnValue(child as unknown as ChildProcess); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41005, waitForPort: vi.fn(async () => undefined), }); const handle = await server.ensureStarted(); expect(handle.pid).toBe(43123); expect(hoisted.spawnCliMock).toHaveBeenCalledWith( 'node', [ 'mcp-server/dist/index.js', '--transport', 'httpStream', '--host', '127.0.0.1', '--port', '41005', '--endpoint', '/mcp', ], expect.objectContaining({ env: expect.objectContaining({ AGENT_TEAMS_MCP_TRANSPORT: 'httpStream', AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1', AGENT_TEAMS_MCP_HTTP_PORT: '41005', AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp', }), stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, }) ); expect(hoisted.untrackCliProcessMock).toHaveBeenCalledWith(child); }); it('coalesces concurrent starts', async () => { const child = new FakeChildProcess(); const spawnProcess = vi.fn(() => child as unknown as ChildProcess); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41002, spawnProcess, waitForPort: async () => undefined, }); const [first, second] = await Promise.all([server.ensureStarted(), server.ensureStarted()]); expect(first).toBe(second); expect(spawnProcess).toHaveBeenCalledTimes(1); }); it('does not start after shutdown has disabled future starts', async () => { const spawnProcess = vi.fn(); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41026, spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'], waitForPort: vi.fn(async () => undefined), }); await server.stop({ preventRestart: true }); await expect(server.ensureStarted()).rejects.toThrow('startup is disabled during shutdown'); expect(spawnProcess).not.toHaveBeenCalled(); }); it('cancels an in-flight start before spawn when shutdown disables future starts', async () => { let resolveLaunchSpec!: (launchSpec: { command: string; args: string[] }) => void; const launchSpecPromise = new Promise<{ command: string; args: string[] }>((resolve) => { resolveLaunchSpec = resolve; }); const spawnProcess = vi.fn(); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => launchSpecPromise, allocatePort: async () => 41027, spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'], waitForPort: vi.fn(async () => undefined), }); const startPromise = server.ensureStarted(); await new Promise((resolve) => setImmediate(resolve)); await server.stop({ preventRestart: true }); resolveLaunchSpec({ command: 'node', args: ['mcp-server/dist/index.js'] }); await expect(startPromise).rejects.toThrow('startup is disabled during shutdown'); expect(spawnProcess).not.toHaveBeenCalled(); }); it('uses the persistent state lock so a concurrent second instance adopts the first', async () => { const { root, statePath } = await createTempStatePath(); const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; const child = new FakeChildProcess(43131); const spawnProcess = vi.fn(() => child as unknown as ChildProcess); const probeHealth = vi.fn(async (_host: string, port: number) => ({ healthy: true, statusCode: 200, identity: buildIdentity({ port, launchSpec, ownerInstanceId: 'first-instance' }), })); let lockTail = Promise.resolve(); let activeLocks = 0; let maxActiveLocks = 0; const withStateLock: NonNullable = async ( _filePath, fn ) => { const previous = lockTail; let release!: () => void; lockTail = new Promise((resolve) => { release = resolve; }); await previous; activeLocks += 1; maxActiveLocks = Math.max(maxActiveLocks, activeLocks); try { return await fn(); } finally { activeLocks -= 1; release(); } }; let releaseFirstReady!: () => void; const firstReady = new Promise((resolve) => { releaseFirstReady = resolve; }); const waitForPort = vi.fn(async () => { await firstReady; }); const firstServer = new AgentTeamsMcpHttpServer({ statePath, disableOrphanCleanup: true, resolveLaunchSpec: async () => launchSpec, allocatePort: async () => 41024, spawnProcess, waitForPort, probeHealth, withStateLock, }); const secondServer = new AgentTeamsMcpHttpServer({ statePath, disableOrphanCleanup: true, resolveLaunchSpec: async () => launchSpec, allocatePort: async () => 41025, spawnProcess, waitForPort, probeHealth, withStateLock, }); try { const firstStart = firstServer.ensureStarted(); await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(1)); const secondStart = secondServer.ensureStarted(); await new Promise((resolve) => setImmediate(resolve)); expect(spawnProcess).toHaveBeenCalledTimes(1); releaseFirstReady(); const [first, second] = await Promise.all([firstStart, secondStart]); expect(first.port).toBe(41024); expect(second.port).toBe(41024); expect(second.diagnostics).toContain('opencode_app_mcp_adopted_state_server:41024'); expect(spawnProcess).toHaveBeenCalledTimes(1); expect(waitForPort).toHaveBeenCalledTimes(1); expect(maxActiveLocks).toBe(1); } finally { releaseFirstReady(); await lockTail; await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); vi.mocked(console.warn).mockClear(); } }); it('adopts a healthy MCP HTTP server from persistent state without spawning', async () => { const { root, statePath } = await createTempStatePath(); const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; const port = 41021; const identity = buildIdentity({ port, launchSpec }); await writeFile( statePath, `${JSON.stringify(buildState({ port, pid: 51234, launchSpec }), null, 2)}\n` ); const spawnProcess = vi.fn(); const probeHealth = vi.fn(async () => ({ healthy: true, statusCode: 200, identity, })); const server = new AgentTeamsMcpHttpServer({ statePath, disableOrphanCleanup: true, resolveLaunchSpec: async () => launchSpec, spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'], probeHealth, }); try { const handle = await server.ensureStarted(); expect(handle).toMatchObject({ url: `http://127.0.0.1:${port}/mcp`, port, pid: 51234, diagnostics: [`opencode_app_mcp_adopted_state_server:${port}`], }); expect(spawnProcess).not.toHaveBeenCalled(); expect(probeHealth).toHaveBeenCalledWith('127.0.0.1', port); } finally { await rm(root, { recursive: true, force: true }); vi.mocked(console.warn).mockClear(); } }); it('ignores corrupt persistent state and starts a fresh server', async () => { const { root, statePath } = await createTempStatePath(); const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; await writeFile(statePath, '{not-json', 'utf8'); const child = new FakeChildProcess(); const spawnProcess = vi.fn(() => child as unknown as ChildProcess); const server = new AgentTeamsMcpHttpServer({ statePath, disableOrphanCleanup: true, resolveLaunchSpec: async () => launchSpec, allocatePort: async () => 41022, spawnProcess, waitForPort: vi.fn(async () => undefined), }); try { const handle = await server.ensureStarted(); expect(handle.port).toBe(41022); expect(handle.diagnostics).toContain('opencode_app_mcp_state_ignored:parse_failed'); expect(spawnProcess).toHaveBeenCalledTimes(1); } finally { await rm(root, { recursive: true, force: true }); vi.mocked(console.warn).mockClear(); } }); it('ignores persistent state when the MCP child env changed', async () => { const { root, statePath } = await createTempStatePath(); const previousLaunchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; const currentLaunchSpec = { command: 'node', args: ['mcp-server/dist/index.js'], env: { ELECTRON_RUN_AS_NODE: '1' }, }; await writeFile( statePath, `${JSON.stringify( buildState({ port: 41029, pid: 51235, launchSpec: previousLaunchSpec }), null, 2 )}\n` ); const child = new FakeChildProcess(); const spawnProcess = vi.fn(() => child as unknown as ChildProcess); const server = new AgentTeamsMcpHttpServer({ statePath, disableOrphanCleanup: true, resolveLaunchSpec: async () => currentLaunchSpec, allocatePort: async () => 41030, spawnProcess, waitForPort: vi.fn(async () => undefined), }); try { const handle = await server.ensureStarted(); expect(handle.port).toBe(41030); expect(handle.diagnostics).toContain('opencode_app_mcp_state_ignored:identity_mismatch'); expect(spawnProcess).toHaveBeenCalledTimes(1); } finally { await rm(root, { recursive: true, force: true }); vi.mocked(console.warn).mockClear(); } }); it('adopts a healthy matching MCP HTTP server on the configured stable port', async () => { const { root, statePath } = await createTempStatePath(); const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] }; const port = 41023; const identity = buildIdentity({ port, launchSpec }); const previousPortEnv = process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT; process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = String(port); const spawnProcess = vi.fn(); const server = new AgentTeamsMcpHttpServer({ statePath, disableOrphanCleanup: true, resolveLaunchSpec: async () => launchSpec, spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'], canListenOnPort: async () => false, probeHealth: vi.fn(async () => ({ healthy: true, statusCode: 200, identity, })), }); try { const handle = await server.ensureStarted(); await server.stop(); expect(handle).toMatchObject({ port, pid: null, diagnostics: [`opencode_app_mcp_adopted_port_server:${port}`], }); expect(spawnProcess).not.toHaveBeenCalled(); expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled(); } finally { if (previousPortEnv === undefined) { delete process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT; } else { process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = previousPortEnv; } await rm(root, { recursive: true, force: true }); vi.mocked(console.warn).mockClear(); } }); it('reuses an existing handle only after its health check still passes', async () => { const child = new FakeChildProcess(); const spawnProcess = vi.fn(() => child as unknown as ChildProcess); const waitForPort = vi.fn(async () => undefined); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41006, spawnProcess, waitForPort, }); const first = await server.ensureStarted(); const second = await server.ensureStarted(); expect(second).toBe(first); expect(spawnProcess).toHaveBeenCalledTimes(1); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 10_000); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 3_000); expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled(); }); it('restarts a cached HTTP MCP server handle when the health check goes stale', async () => { const firstChild = new FakeChildProcess(43123); const secondChild = new FakeChildProcess(43124); const spawnProcess = vi .fn() .mockReturnValueOnce(firstChild as unknown as ChildProcess) .mockReturnValueOnce(secondChild as unknown as ChildProcess); const allocatePort = vi.fn().mockResolvedValueOnce(41007).mockResolvedValueOnce(41008); const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => { if (port === 41007 && timeoutMs === 3_000) { throw new Error('stale health check'); } }); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort, spawnProcess, waitForPort, canListenOnPort: async () => true, probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })), }); const first = await server.ensureStarted(); const second = await server.ensureStarted(); expect(first.url).toBe('http://127.0.0.1:41007/mcp'); expect(second).toMatchObject({ url: 'http://127.0.0.1:41007/mcp', port: 41007, pid: 43124, generation: 2, diagnostics: ['opencode_app_mcp_restart_reason:health_reuse_failed'], }); expect(second.transportEvidence).toMatchObject({ port: 41007, url: 'http://127.0.0.1:41007/mcp', urlHash: second.urlHash, generation: 2, }); expect(spawnProcess).toHaveBeenCalledTimes(2); expect(allocatePort).toHaveBeenCalledTimes(1); expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL'); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 10_000); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000); expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 10_000); expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check'); expect(vi.mocked(console.warn).mock.calls[1]?.join(' ')).toContain( 'opencode_app_mcp_restart_reason:health_reuse_failed' ); vi.mocked(console.warn).mockClear(); }); it('falls back without killing unknown processes when the preferred restart port stays occupied', async () => { const firstChild = new FakeChildProcess(43123); const secondChild = new FakeChildProcess(43124); const blockedPort = 41041; const fallbackPort = 41042; const spawnProcess = vi .fn() .mockReturnValueOnce(firstChild as unknown as ChildProcess) .mockReturnValueOnce(secondChild as unknown as ChildProcess); const allocatePort = vi .fn() .mockResolvedValueOnce(blockedPort) .mockResolvedValueOnce(fallbackPort); const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => { if (port === blockedPort && timeoutMs === 3_000) { throw new Error('stale health check'); } }); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort, spawnProcess, waitForPort, canListenOnPort: async (_host, port) => port !== blockedPort, probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })), }); const first = await server.ensureStarted(); const second = await server.ensureStarted(); expect(first.url).toBe(`http://127.0.0.1:${blockedPort}/mcp`); expect(second).toMatchObject({ url: `http://127.0.0.1:${fallbackPort}/mcp`, port: fallbackPort, pid: 43124, generation: 2, }); expect(second.diagnostics).toContain('opencode_app_mcp_public_url_changed'); expect(second.diagnostics).toContain( `opencode_app_mcp_preferred_port_unavailable:${blockedPort}` ); expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1); expect(allocatePort).toHaveBeenCalledTimes(2); vi.mocked(console.warn).mockClear(); }); it('cleans up a proven legacy orphan MCP HTTP process without live consumers', async () => { const { root, statePath } = await createTempStatePath(); const child = new FakeChildProcess(43123); const orphanPort = 41031; const alivePids = new Set([9001, 9005]); const killProcess = vi.fn((pid: number) => { alivePids.delete(pid); }); const command = `node /repo/mcp-server/src/index.ts --transport httpStream --host 127.0.0.1 --port ${orphanPort} --endpoint /mcp`; const rows = [ { pid: 9001, ppid: 1, command }, { pid: 9005, ppid: 9001, command }, { pid: 43123, ppid: process.pid, command: 'current child' }, ]; const details = `${command} AGENT_TEAMS_MCP_CLAUDE_DIR=${getClaudeBasePath()} AGENT_TEAMS_MCP_TRANSPORT=httpStream AGENT_TEAMS_MCP_HTTP_HOST=127.0.0.1 AGENT_TEAMS_MCP_HTTP_PORT=${orphanPort} AGENT_TEAMS_MCP_HTTP_ENDPOINT=/mcp`; const server = new AgentTeamsMcpHttpServer({ statePath, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41030, spawnProcess: vi.fn(() => child as unknown as ChildProcess), waitForPort: vi.fn(async () => undefined), listProcessRows: async () => rows, readProcessDetails: async (pid) => (pid === 9001 || pid === 9005 ? details : null), readProcessStartTimeMs: async () => 0, killProcess, forceKillProcess: vi.fn(), isProcessAlive: (pid) => alivePids.has(pid), sleepMs: async () => undefined, probeHealth: vi.fn(async () => ({ healthy: true, statusCode: 200, identity: null })), }); try { const handle = await server.ensureStarted(); await flushAsyncCleanup(); expect(killProcess).toHaveBeenNthCalledWith(1, 9005); expect(killProcess).toHaveBeenNthCalledWith(2, 9001); expect(handle.diagnostics).toContain( `opencode_app_mcp_legacy_orphan_cleaned:${orphanPort}` ); } finally { await rm(root, { recursive: true, force: true }); vi.mocked(console.warn).mockClear(); } }); it('keeps a proven legacy orphan MCP HTTP process when live consumers still reference it', async () => { const { root, statePath } = await createTempStatePath(); const child = new FakeChildProcess(43123); const orphanPort = 41033; const url = `http://127.0.0.1:${orphanPort}/mcp`; const command = `node /repo/mcp-server/src/index.ts --transport httpStream --host 127.0.0.1 --port ${orphanPort} --endpoint /mcp`; const rows = [ { pid: 9002, ppid: 1, command }, { pid: 9003, ppid: 1, command: 'consumer process' }, { pid: 43123, ppid: process.pid, command: 'current child' }, ]; const orphanDetails = `${command} AGENT_TEAMS_MCP_CLAUDE_DIR=${getClaudeBasePath()} AGENT_TEAMS_MCP_TRANSPORT=httpStream AGENT_TEAMS_MCP_HTTP_HOST=127.0.0.1 AGENT_TEAMS_MCP_HTTP_PORT=${orphanPort} AGENT_TEAMS_MCP_HTTP_ENDPOINT=/mcp`; const killProcess = vi.fn(); const server = new AgentTeamsMcpHttpServer({ statePath, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41032, spawnProcess: vi.fn(() => child as unknown as ChildProcess), waitForPort: vi.fn(async () => undefined), listProcessRows: async () => rows, readProcessDetails: async (pid) => pid === 9002 ? orphanDetails : `CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=${url}`, readProcessStartTimeMs: async () => 0, killProcess, isProcessAlive: () => false, sleepMs: async () => undefined, probeHealth: vi.fn(async () => ({ healthy: true, statusCode: 200, identity: null })), }); try { const handle = await server.ensureStarted(); await flushAsyncCleanup(); expect(killProcess).not.toHaveBeenCalled(); expect(handle.diagnostics).toContain( `opencode_app_mcp_legacy_orphan_kept_live_consumers:${orphanPort}` ); } finally { await rm(root, { recursive: true, force: true }); vi.mocked(console.warn).mockClear(); } }); it('does not clean up MCP-like processes that still have a live parent', async () => { const { root, statePath } = await createTempStatePath(); const child = new FakeChildProcess(43123); const orphanPort = 41035; const command = `node /repo/mcp-server/src/index.ts --transport httpStream --host 127.0.0.1 --port ${orphanPort} --endpoint /mcp`; const killProcess = vi.fn(); const server = new AgentTeamsMcpHttpServer({ statePath, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41034, spawnProcess: vi.fn(() => child as unknown as ChildProcess), waitForPort: vi.fn(async () => undefined), listProcessRows: async () => [ { pid: 9004, ppid: 1234, command }, { pid: 43123, ppid: process.pid, command: 'current child' }, ], readProcessDetails: vi.fn(), readProcessStartTimeMs: vi.fn(), killProcess, isProcessAlive: (pid) => pid === 1234, sleepMs: async () => undefined, }); try { await server.ensureStarted(); await flushAsyncCleanup(); expect(killProcess).not.toHaveBeenCalled(); } finally { await rm(root, { recursive: true, force: true }); } }); it('fails startup promptly when the child exits before readiness', async () => { const child = new FakeChildProcess(); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41003, spawnProcess: vi.fn(() => child as unknown as ChildProcess), waitForPort: vi.fn(() => { child.emit('exit', 1, null); return new Promise(() => { // Keep readiness pending so startup resolves only through the child exit. }); }), }); await expect(server.ensureStarted()).rejects.toThrow( 'Agent Teams MCP HTTP server exited before startup completed with code 1' ); expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(child, 'SIGKILL'); expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( 'Agent Teams MCP HTTP server exited before startup completed with code 1' ); vi.mocked(console.warn).mockClear(); }); it('does not return a handle if the child exits during readiness polling', async () => { const child = new FakeChildProcess(); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => 41004, spawnProcess: vi.fn(() => child as unknown as ChildProcess), waitForPort: vi.fn(async () => { await Promise.resolve(); child.emit('exit', 0, null); }), }); await expect(server.ensureStarted()).rejects.toThrow( 'Agent Teams MCP HTTP server exited before startup completed' ); expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(child, 'SIGKILL'); expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( 'Agent Teams MCP HTTP server exited before startup completed with code 0' ); vi.mocked(console.warn).mockClear(); }); it('waits for the HTTP health endpoint before marking the server ready', async () => { const child = new FakeChildProcess(); const port = await allocateLoopbackPort(); let healthRequests = 0; const healthServer = http.createServer((request, response) => { if (request.url === '/health') { healthRequests += 1; response.writeHead(200, { 'content-type': 'text/plain' }); response.end('ok'); return; } response.writeHead(404); response.end(); }); const spawnProcess = vi.fn((_command: string, args: string[]) => { expect(args).toContain(String(port)); healthServer.listen(port, '127.0.0.1'); return child as unknown as ChildProcess; }); const server = new AgentTeamsMcpHttpServer({ statePath: null, resolveLaunchSpec: async () => ({ command: 'node', args: ['mcp-server/dist/index.js'], }), allocatePort: async () => port, spawnProcess, }); try { const handle = await server.ensureStarted(); expect(handle.url).toBe(`http://127.0.0.1:${port}/mcp`); expect(healthRequests).toBeGreaterThan(0); } finally { await new Promise((resolve) => healthServer.close(() => resolve())); } }); });