agent-ecosystem/test/main/services/team/AgentTeamsMcpHttpServer.test.ts

786 lines
27 KiB
TypeScript

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(),
}));
vi.mock('@main/utils/childProcess', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
return {
...actual,
killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args),
spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...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');
}
function buildLaunchSpecHash(launchSpec: { command: string; args: string[] }): string {
return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args }));
}
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: { command: string; args: string[] };
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: { command: string; args: string[] };
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<number> {
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<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
await Promise.resolve();
}
describe('AgentTeamsMcpHttpServer', () => {
beforeEach(() => {
hoisted.killProcessTreeMock.mockReset();
hoisted.spawnCliMock.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'],
}),
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({
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,
})
);
});
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('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<AgentTeamsMcpHttpServerDeps['withStateLock']> = async (
_filePath,
fn
) => {
const previous = lockTail;
let release!: () => void;
lockTail = new Promise<void>((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<void>((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<void>((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('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,
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<void>(() => {
// 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<void>((resolve) => healthServer.close(() => resolve()));
}
});
});