887 lines
31 KiB
TypeScript
887 lines
31 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(),
|
|
untrackCliProcessMock: 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),
|
|
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<string, string>;
|
|
};
|
|
|
|
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<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();
|
|
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<void>((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<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('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<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()));
|
|
}
|
|
});
|
|
});
|