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

592 lines
20 KiB
TypeScript

// @vitest-environment node
/* eslint-disable security/detect-non-literal-fs-filename, sonarjs/publicly-writable-directories */
import { chmod, 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 { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
import { OpenCodeBridgeCommandClient } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const FAKE_MCP_HTTP_SERVER_SOURCE = String.raw`
const fs = require('node:fs');
const http = require('node:http');
function readArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
const host = readArg('--host') || '127.0.0.1';
const endpoint = readArg('--endpoint') || '/mcp';
const port = Number(readArg('--port'));
const controlFile = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
const healthIdentity =
process.env.AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE === 'agent-teams-mcp-http' &&
process.env.AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH &&
process.env.AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH &&
process.env.AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID
? {
schemaVersion: 1,
service: 'agent-teams-mcp-http',
transport: 'httpStream',
host,
port,
endpoint,
claudeDirHash: process.env.AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH,
launchSpecHash: process.env.AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH,
ownerInstanceId: process.env.AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID,
}
: null;
function readControl() {
if (!controlFile) {
return 'healthy';
}
try {
return fs.readFileSync(controlFile, 'utf8').trim() || 'healthy';
} catch {
return 'healthy';
}
}
function isUnhealthy() {
const control = readControl();
if (control === 'unhealthy-once:' + port) {
try {
fs.writeFileSync(controlFile, 'healthy');
} catch {}
return true;
}
if (control === 'unhealthy-pid:' + process.pid) {
return true;
}
return control === 'unhealthy-all' || control === 'unhealthy-port:' + port;
}
const server = http.createServer((request, response) => {
if (request.url === '/health') {
if (isUnhealthy()) {
response.writeHead(503, { 'content-type': 'text/plain' });
response.end('unhealthy');
return;
}
response.writeHead(200, { 'content-type': 'text/plain' });
response.end(healthIdentity ? JSON.stringify(healthIdentity) : 'ok');
return;
}
if (request.url === endpoint) {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"jsonrpc":"2.0","result":{}}');
return;
}
response.writeHead(404, { 'content-type': 'text/plain' });
response.end('not found');
});
server.listen(port, host);
function shutdown() {
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 500).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
`;
const FAKE_OPENCODE_BRIDGE_BINARY_SOURCE = String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const http = require('node:http');
function readArg(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
function readHealthStatus(url) {
return new Promise((resolve) => {
if (!url) {
resolve(null);
return;
}
const target = new URL(url);
target.pathname = '/health';
target.search = '';
target.hash = '';
const request = http.get(
{
host: target.hostname,
port: Number(target.port),
path: target.pathname,
timeout: 750,
},
(response) => {
response.resume();
resolve(response.statusCode || null);
}
);
request.once('timeout', () => {
request.destroy();
resolve(null);
});
request.once('error', () => resolve(null));
});
}
async function main() {
const inputPath = readArg('--input');
if (!inputPath) {
console.error('missing --input');
process.exit(64);
}
const envelope = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
const mcpUrl = process.env.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL || null;
const healthStatus = await readHealthStatus(mcpUrl);
if (healthStatus !== 200) {
console.error(
JSON.stringify({
kind: 'mcp_unreachable',
mcpUrl,
healthStatus,
})
);
process.exit(7);
}
process.stdout.write(
JSON.stringify({
ok: true,
schemaVersion: envelope.schemaVersion,
requestId: envelope.requestId,
command: envelope.command,
completedAt: new Date().toISOString(),
durationMs: 1,
runtime: {
providerId: 'opencode',
binaryPath: process.argv[1],
binaryFingerprint: 'fake-runtime',
version: 'fake-opencode-bridge-e2e',
capabilitySnapshotId: 'fake-capabilities',
},
diagnostics: [],
data: {
runId: envelope.body && envelope.body.runId ? envelope.body.runId : null,
observedMcpUrl: mcpUrl,
healthStatus,
},
}) + '\n'
);
}
main().catch((error) => {
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
});
`;
const describePosix = process.platform === 'win32' ? describe.skip : describe;
async function allocateLoopbackPort(excluded: Set<number> = new Set<number>()): Promise<number> {
while (true) {
const port = await new Promise<number>((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 test port')));
return;
}
server.close(() => resolve(address.port));
});
});
if (!excluded.has(port)) {
excluded.add(port);
return port;
}
}
}
async function readHealthStatus(url: string): Promise<number | null> {
const target = new URL(url);
target.pathname = '/health';
target.search = '';
target.hash = '';
return new Promise((resolve) => {
const request = http.get(
{
host: target.hostname,
port: Number(target.port),
path: target.pathname,
timeout: 500,
},
(response) => {
response.resume();
resolve(response.statusCode ?? null);
}
);
request.once('timeout', () => {
request.destroy();
resolve(null);
});
request.once('error', () => resolve(null));
});
}
async function waitForHealthDown(url: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < 5_000) {
if ((await readHealthStatus(url)) !== 200) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Expected ${url} health endpoint to go down`);
}
async function writeFakeMcpHttpServer(tempDir: string): Promise<string> {
const scriptDir = path.join(tempDir, 'fake-mcp');
const scriptPath = path.join(scriptDir, 'server.cjs');
await mkdir(scriptDir, { recursive: true });
await writeFile(scriptPath, FAKE_MCP_HTTP_SERVER_SOURCE, 'utf8');
return scriptPath;
}
async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise<string> {
const scriptDir = path.join(tempDir, 'fake-runtime');
const scriptPath = path.join(scriptDir, 'claude-multimodel-fake');
await mkdir(scriptDir, { recursive: true });
await writeFile(scriptPath, FAKE_OPENCODE_BRIDGE_BINARY_SOURCE, 'utf8');
await chmod(scriptPath, 0o755);
return scriptPath;
}
describePosix('AgentTeamsMcpHttpServer integration', () => {
let tempDir: string | null = null;
let originalControlFileEnv: string | undefined;
let originalMcpHttpPortEnv: string | undefined;
const servers: AgentTeamsMcpHttpServer[] = [];
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-'));
originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
originalMcpHttpPortEnv = process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
});
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.stop()));
vi.mocked(console.warn).mockClear();
if (originalControlFileEnv === undefined) {
delete process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
} else {
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = originalControlFileEnv;
}
if (originalMcpHttpPortEnv === undefined) {
delete process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
} else {
process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = originalMcpHttpPortEnv;
}
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
});
function createControlledServer(input: {
scriptPath: string;
controlFile: string;
allocatePort?: () => Promise<number>;
statePath?: string;
}): AgentTeamsMcpHttpServer {
const server = new AgentTeamsMcpHttpServer({
statePath: input.statePath ?? path.join(tempDir!, `mcp-http-state-${servers.length}.json`),
disableOrphanCleanup: true,
resolveLaunchSpec: () =>
Promise.resolve({
command: process.execPath,
args: [input.scriptPath],
}),
allocatePort: input.allocatePort,
});
servers.push(server);
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = input.controlFile;
return server;
}
it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => {
const server = new AgentTeamsMcpHttpServer({
statePath: path.join(tempDir!, 'actual-mcp-http-state.json'),
disableOrphanCleanup: true,
});
servers.push(server);
const handle = await server.ensureStarted();
expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp$/);
expect(handle.pid).toEqual(expect.any(Number));
expect(await readHealthStatus(handle.url)).toBe(200);
});
it('reuses a healthy cached bridge URL after a real loopback health recheck', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({ scriptPath, controlFile });
const first = await server.ensureStarted();
const warnCountAfterFirstStart = vi.mocked(console.warn).mock.calls.length;
const second = await server.ensureStarted();
expect(second).toEqual(first);
expect(await readHealthStatus(first.url)).toBe(200);
expect(vi.mocked(console.warn).mock.calls.slice(warnCountAfterFirstStart)).toEqual([]);
});
it('adopts a healthy MCP HTTP child from persistent state across server managers', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const statePath = path.join(tempDir!, 'shared-mcp-http-state.json');
await writeFile(controlFile, 'healthy', 'utf8');
const firstServer = createControlledServer({ scriptPath, controlFile, statePath });
const first = await firstServer.ensureStarted();
const secondServer = createControlledServer({ scriptPath, controlFile, statePath });
const second = await secondServer.ensureStarted();
expect(second.port).toBe(first.port);
expect(second.pid).toBe(first.pid);
expect(second.diagnostics).toContain(`opencode_app_mcp_adopted_state_server:${first.port}`);
expect(await readHealthStatus(second.url)).toBe(200);
});
it('falls back from an unknown healthy stable-port server and leaves it alive', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
let unknownServer: http.Server | null = null;
let stablePort = 0;
let fallbackPort = 0;
for (let candidate = 43_100; candidate < 65_534; candidate += 1) {
const stableCandidate = http.createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('unknown-ok');
return;
}
response.writeHead(404);
response.end('not found');
});
const fallbackProbe = net.createServer();
try {
await new Promise<void>((resolve, reject) => {
stableCandidate.once('error', reject);
stableCandidate.listen(candidate, '127.0.0.1', () => resolve());
});
await new Promise<void>((resolve, reject) => {
fallbackProbe.once('error', reject);
fallbackProbe.listen(candidate + 1, '127.0.0.1', () => resolve());
});
await new Promise<void>((resolve) => fallbackProbe.close(() => resolve()));
unknownServer = stableCandidate;
stablePort = candidate;
fallbackPort = candidate + 1;
break;
} catch {
await new Promise<void>((resolve) => fallbackProbe.close(() => resolve())).catch(
() => undefined
);
await new Promise<void>((resolve) => stableCandidate.close(() => resolve())).catch(
() => undefined
);
}
}
if (!unknownServer) {
throw new Error('Failed to reserve contiguous ports for unknown stable-port test');
}
process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = String(stablePort);
const server = createControlledServer({ scriptPath, controlFile });
try {
const handle = await server.ensureStarted();
expect(handle.port).toBe(fallbackPort);
expect(handle.diagnostics).toContain(`opencode_app_mcp_port_occupied_unknown:${stablePort}`);
expect(handle.diagnostics).toContain(
`opencode_app_mcp_preferred_port_unavailable:${stablePort}`
);
expect(await readHealthStatus(`http://127.0.0.1:${stablePort}/mcp`)).toBe(200);
expect(await readHealthStatus(handle.url)).toBe(200);
} finally {
await new Promise<void>((resolve) => unknownServer?.close(() => resolve()));
vi.mocked(console.warn).mockClear();
}
});
it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const first = await server.ensureStarted();
expect(first.pid).toEqual(expect.any(Number));
await writeFile(controlFile, `unhealthy-pid:${first.pid}`, 'utf8');
const second = await server.ensureStarted();
expect(second.port).toBe(first.port);
expect(second.url).toBe(first.url);
expect(second.pid).not.toBe(first.pid);
expect(await readHealthStatus(second.url)).toBe(200);
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
vi.mocked(console.warn).mockClear();
});
it('recovers when the cached MCP HTTP child dies and the old URL refuses connections', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const first = await server.ensureStarted();
expect(first.pid).toEqual(expect.any(Number));
process.kill(first.pid!, 'SIGTERM');
await waitForHealthDown(first.url);
const second = await server.ensureStarted();
expect(second.port).not.toBe(first.port);
expect(second.pid).not.toBe(first.pid);
expect(await readHealthStatus(second.url)).toBe(200);
});
it('passes a refreshed healthy MCP URL into a real bridge child process after cached health goes stale', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
const usedPorts = new Set<number>();
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({
scriptPath,
controlFile,
allocatePort: () => allocateLoopbackPort(usedPorts),
});
const bridgeEnv: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
};
let requestIdCounter = 0;
const client = new OpenCodeBridgeCommandClient({
binaryPath: bridgeBinaryPath,
tempDirectory: path.join(tempDir!, 'bridge-input'),
env: bridgeEnv,
envProvider: async () => {
const mcpHttpServer = await server.ensureStarted();
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
return {
...bridgeEnv,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: mcpHttpServer.url,
};
},
requestIdFactory: () => {
requestIdCounter += 1;
return `req-refresh-${requestIdCounter}`;
},
});
const firstResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-1' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(firstResult.ok).toBe(true);
if (!firstResult.ok) {
throw new Error(firstResult.error.message);
}
const firstHandle = server.getCurrentHandle();
expect(firstHandle?.pid).toEqual(expect.any(Number));
await writeFile(
controlFile,
`unhealthy-pid:${firstHandle?.pid}`,
'utf8'
);
const secondResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-2' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(secondResult.ok).toBe(true);
if (!secondResult.ok) {
throw new Error(secondResult.error.message);
}
expect(secondResult.data.observedMcpUrl).toBe(firstResult.data.observedMcpUrl);
expect(await readHealthStatus(secondResult.data.observedMcpUrl)).toBe(200);
});
it('fails closed when a bridge child receives an unreachable MCP URL without env refresh', async () => {
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
const controlFile = path.join(tempDir!, 'health-control.txt');
await writeFile(controlFile, 'healthy', 'utf8');
const server = createControlledServer({ scriptPath, controlFile });
const first = await server.ensureStarted();
await server.stop();
await waitForHealthDown(first.url);
const client = new OpenCodeBridgeCommandClient({
binaryPath: bridgeBinaryPath,
tempDirectory: path.join(tempDir!, 'bridge-input-stale'),
env: {
PATH: process.env.PATH,
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL: first.url,
},
requestIdFactory: () => 'req-stale-mcp',
});
const result = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
'opencode.launchTeam',
{ runId: 'run-stale' },
{
cwd: tempDir!,
timeoutMs: 5_000,
}
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected stale MCP URL to fail');
}
expect(result.error.kind).toBe('provider_error');
expect(result.error.details?.stderr).toContain('mcp_unreachable');
expect(result.error.details?.stderr).toContain(first.url);
});
});