feat(runtime): harden MCP launch orchestration

This commit is contained in:
777genius 2026-05-21 19:03:47 +03:00
parent 9b2a53863d
commit 9ad4269ebc
15 changed files with 2029 additions and 120 deletions

View file

@ -19,9 +19,23 @@ function withMockedRenameSync(mockRenameSync, callback) {
}
describe('atomic file writes', () => {
const tempDirs = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
['EPERM', 'EACCES', 'EBUSY'].forEach((code) => {
it(`retries transient ${code} rename failures before publishing JSON`, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
const dir = makeTempDir();
const filePath = path.join(dir, 'state.json');
let attempts = 0;
@ -47,7 +61,7 @@ describe('atomic file writes', () => {
});
it('does not retry ENOENT rename failures and removes the temp file', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
const dir = makeTempDir();
const filePath = path.join(dir, 'state.json');
let attempts = 0;
@ -69,7 +83,7 @@ describe('atomic file writes', () => {
});
it('removes the temp file after retryable rename failures are exhausted', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
const dir = makeTempDir();
const filePath = path.join(dir, 'state.json');
let attempts = 0;

View file

@ -6,8 +6,17 @@ const path = require('path');
const { createController } = require('../src/index.js');
describe('agent-teams-controller API', () => {
const tempDirs = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeClaudeDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
tempDirs.push(dir);
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
fs.writeFileSync(

View file

@ -6,8 +6,11 @@ const { createController } = require('../src/index.js');
const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js');
describe('crossTeam module', () => {
const tempDirs = [];
function makeClaudeDir(teams = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
tempDirs.push(dir);
for (const [teamName, config] of Object.entries(teams)) {
const teamDir = path.join(dir, 'teams', teamName);
@ -28,6 +31,9 @@ describe('crossTeam module', () => {
// Reset cascade guard between tests
const cascadeGuard = require('../src/internal/cascadeGuard.js');
cascadeGuard.reset();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('sendCrossTeamMessage', () => {

View file

@ -9,6 +9,11 @@ const HTTP_TRANSPORT = 'httpStream';
const STDIO_TRANSPORT = 'stdio';
const DEFAULT_HTTP_HOST = '127.0.0.1';
const DEFAULT_HTTP_ENDPOINT = '/mcp';
const MCP_HTTP_IDENTITY_SERVICE = 'agent-teams-mcp-http';
const MCP_HTTP_IDENTITY_SERVICE_ENV = 'AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE';
const MCP_HTTP_CLAUDE_DIR_HASH_ENV = 'AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH';
const MCP_HTTP_LAUNCH_SPEC_HASH_ENV = 'AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH';
const MCP_HTTP_OWNER_INSTANCE_ID_ENV = 'AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID';
export type AgentTeamsMcpStartOptions =
| {
@ -23,10 +28,32 @@ export type AgentTeamsMcpStartOptions =
};
};
export function createServer() {
export interface AgentTeamsMcpHttpHealthIdentity {
schemaVersion: 1;
service: typeof MCP_HTTP_IDENTITY_SERVICE;
transport: typeof HTTP_TRANSPORT;
host: string;
port: number;
endpoint: `/${string}`;
claudeDirHash: string;
launchSpecHash: string;
ownerInstanceId: string;
}
export function createServer(input: { healthIdentity?: AgentTeamsMcpHttpHealthIdentity | null } = {}) {
const server = new FastMCP({
name: 'agent-teams-mcp',
version: '1.0.0',
...(input.healthIdentity
? {
health: {
enabled: true,
path: '/health',
status: 200,
message: JSON.stringify(input.healthIdentity),
},
}
: {}),
});
registerTools(server);
@ -64,6 +91,45 @@ function parsePort(value: string | null | undefined): number {
return parsed;
}
function readIdentityValue(env: NodeJS.ProcessEnv, name: string): string | null {
const value = env[name]?.trim();
return value && value.length > 0 ? value : null;
}
export function buildHttpHealthIdentity(
options: AgentTeamsMcpStartOptions,
env: NodeJS.ProcessEnv = process.env
): AgentTeamsMcpHttpHealthIdentity | null {
if (options.transportType !== HTTP_TRANSPORT) {
return null;
}
const service = readIdentityValue(env, MCP_HTTP_IDENTITY_SERVICE_ENV);
const claudeDirHash = readIdentityValue(env, MCP_HTTP_CLAUDE_DIR_HASH_ENV);
const launchSpecHash = readIdentityValue(env, MCP_HTTP_LAUNCH_SPEC_HASH_ENV);
const ownerInstanceId = readIdentityValue(env, MCP_HTTP_OWNER_INSTANCE_ID_ENV);
if (
service !== MCP_HTTP_IDENTITY_SERVICE ||
!claudeDirHash ||
!launchSpecHash ||
!ownerInstanceId
) {
return null;
}
return {
schemaVersion: 1,
service: MCP_HTTP_IDENTITY_SERVICE,
transport: HTTP_TRANSPORT,
host: options.httpStream.host,
port: options.httpStream.port,
endpoint: options.httpStream.endpoint,
claudeDirHash,
launchSpecHash,
ownerInstanceId,
};
}
export function resolveStartOptions(
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env
@ -92,6 +158,7 @@ export function resolveStartOptions(
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = createServer();
void server.start(resolveStartOptions());
const startOptions = resolveStartOptions();
const server = createServer({ healthIdentity: buildHttpHealthIdentity(startOptions) });
void server.start(startOptions);
}

View file

@ -0,0 +1,133 @@
import { spawn, type ChildProcess } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const serverEntry = path.join(repoRoot, 'dist', 'index.js');
const children: ChildProcess[] = [];
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 HTTP e2e port')));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function readHealthBody(port: number): Promise<{ statusCode: number | null; body: string }> {
return new Promise((resolve) => {
let body = '';
const request = http.get(
{
host: '127.0.0.1',
port,
path: '/health',
timeout: 1_000,
},
(response) => {
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
body += chunk;
});
response.on('end', () => resolve({ statusCode: response.statusCode ?? null, body }));
}
);
request.once('timeout', () => {
request.destroy();
resolve({ statusCode: null, body: '' });
});
request.once('error', () => resolve({ statusCode: null, body: '' }));
});
}
async function waitForHealthBody(port: number): Promise<{ statusCode: number | null; body: string }> {
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
const result = await readHealthBody(port);
if (result.statusCode === 200) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`HTTP MCP server did not become healthy on port ${port}`);
}
afterEach(async () => {
await Promise.all(
children.splice(0).map(
(child) =>
new Promise<void>((resolve) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
child.once('exit', () => resolve());
child.kill('SIGTERM');
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
}, 500).unref();
})
)
);
});
describe('agent-teams-mcp HTTP e2e', () => {
it('returns app-managed JSON identity from /health when identity env is present', async () => {
const port = await allocateLoopbackPort();
const child = spawn(
process.execPath,
[
serverEntry,
'--transport',
'httpStream',
'--host',
'127.0.0.1',
'--port',
String(port),
'--endpoint',
'mcp',
],
{
env: {
...process.env,
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-dir-hash-e2e',
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-spec-hash-e2e',
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-e2e',
},
stdio: ['ignore', 'ignore', 'pipe'],
}
);
children.push(child);
const health = await waitForHealthBody(port);
const parsed = JSON.parse(health.body) as Record<string, unknown>;
expect(health.statusCode).toBe(200);
expect(parsed).toEqual({
schemaVersion: 1,
service: 'agent-teams-mcp-http',
transport: 'httpStream',
host: '127.0.0.1',
port,
endpoint: '/mcp',
claudeDirHash: 'claude-dir-hash-e2e',
launchSpecHash: 'launch-spec-hash-e2e',
ownerInstanceId: 'owner-e2e',
});
});
});

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveStartOptions } from '../src/index';
import { buildHttpHealthIdentity, resolveStartOptions } from '../src/index';
describe('agent-teams MCP start options', () => {
it('defaults to stdio transport', () => {
@ -51,4 +51,42 @@ describe('agent-teams MCP start options', () => {
},
});
});
it('builds HTTP health identity only when app identity env is present', () => {
const options = resolveStartOptions(
['node', 'index.js', '--transport', 'httpStream', '--port', '43125'],
{}
);
expect(buildHttpHealthIdentity(options, {})).toBeNull();
expect(
buildHttpHealthIdentity(options, {
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-hash',
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-hash',
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-id',
})
).toEqual({
schemaVersion: 1,
service: 'agent-teams-mcp-http',
transport: 'httpStream',
host: '127.0.0.1',
port: 43125,
endpoint: '/mcp',
claudeDirHash: 'claude-hash',
launchSpecHash: 'launch-hash',
ownerInstanceId: 'owner-id',
});
});
it('does not build HTTP health identity for stdio transport', () => {
const options = resolveStartOptions(['node', 'index.js'], {
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-hash',
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-hash',
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-id',
});
expect(buildHttpHealthIdentity(options)).toBeNull();
});
});

View file

@ -31,6 +31,13 @@ function parseJsonToolResult(result: unknown) {
describe('agent-teams-mcp tools', () => {
const tools = collectTools();
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function getTool(name: string) {
const tool = tools.get(name);
@ -39,7 +46,9 @@ describe('agent-teams-mcp tools', () => {
}
function makeClaudeDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
tempDirs.push(dir);
return dir;
}
function writeTeamConfig(

View file

@ -1,27 +1,27 @@
{
"version": "0.0.44",
"sourceRef": "v0.0.44",
"version": "0.0.45",
"sourceRef": "v0.0.45",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.0.0",
"releaseTag": "v2.1.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.44.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.45.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.44.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.45.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.44.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.45.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.44.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.45.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -42,6 +42,8 @@ const SNAPSHOT_CACHE_TTL_MS = 5_000;
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
const CODEX_CLI_NOT_FOUND_MESSAGE =
'Codex CLI not found. Install Codex to use native account management.';
interface CodexLastKnownAccount {
payload: CodexAppServerGetAccountResponse;
@ -487,15 +489,48 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
const now = Date.now();
if (!binaryPath) {
const freshRuntimeContext = this.getFreshLastKnownRuntimeContext(now);
if (freshRuntimeContext) {
const freshAccountPayload = this.getFreshLastKnownAccount(now);
const accountPayload = freshAccountPayload ?? null;
const managedAccount = asCodexManagedAccount(accountPayload?.account ?? null);
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode,
managedAccount,
apiKey,
appServerState: 'healthy',
appServerStatusMessage: null,
localActiveChatgptAccountPresent,
});
const snapshot = this.setSnapshot({
preferredAuthMode,
effectiveAuthMode: readiness.effectiveAuthMode,
launchAllowed: readiness.launchAllowed,
launchIssueMessage: readiness.issueMessage,
launchReadinessState: readiness.state,
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount,
apiKey,
requiresOpenaiAuth: accountPayload?.requiresOpenaiAuth ?? null,
localAccountArtifactsPresent,
localActiveChatgptAccountPresent,
runtimeContext: freshRuntimeContext,
login,
rateLimits: this.snapshotCache?.rateLimits ?? null,
updatedAt: new Date(now).toISOString(),
});
return snapshot;
}
const snapshot = this.setSnapshot({
preferredAuthMode,
effectiveAuthMode: null,
launchAllowed: false,
launchIssueMessage: 'Codex CLI not found. Install Codex to use native account management.',
launchIssueMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
launchReadinessState: 'runtime_missing',
appServerState: 'runtime-missing',
appServerStatusMessage:
'Codex CLI not found. Install Codex to use native account management.',
appServerStatusMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
managedAccount: null,
apiKey,
requiresOpenaiAuth: null,
@ -521,7 +556,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
let appServerStatusMessage: string | null = null;
let accountPayload = this.lastKnownAccount?.payload ?? null;
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
let runtimeContext = createRuntimeContext(binaryPath, null);
const previousRuntimeContext = this.getFreshLastKnownRuntimeContext(now);
let runtimeContext = createRuntimeContext(
binaryPath,
previousRuntimeContext?.binaryPath === binaryPath ? previousRuntimeContext.codexHome : null
);
this.lastKnownRuntimeContext = {
payload: runtimeContext,
observedAt: now,
};
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
const shouldRequestRateLimits =
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
@ -706,6 +749,29 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
);
}
private getFreshLastKnownRuntimeContext(now: number): CodexRuntimeContext | null {
if (
!this.lastKnownRuntimeContext ||
now - this.lastKnownRuntimeContext.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS ||
!this.lastKnownRuntimeContext.payload.binaryPath
) {
return null;
}
return this.lastKnownRuntimeContext.payload;
}
private getFreshLastKnownAccount(now: number): CodexAppServerGetAccountResponse | null {
if (
!this.lastKnownAccount ||
now - this.lastKnownAccount.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS
) {
return null;
}
return this.lastKnownAccount.payload;
}
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
if (!this.snapshotCache) {
return this.refreshSnapshot();

File diff suppressed because it is too large Load diff

View file

@ -900,11 +900,6 @@ const InstalledBanner = ({
</div>
<div className="flex shrink-0 items-center gap-8">
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Multimodel
</span>
</div>
{/* Extensions button — available whenever the runtime is installed */}
{canOpenExtensions && (
<button

View file

@ -432,6 +432,93 @@ describe('createCodexAccountFeature', () => {
}
});
it('keeps the last known Codex account when binary discovery transiently misses after a healthy snapshot', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: '/usr/bin:/bin',
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
const dateNowSpy = vi.spyOn(Date, 'now');
try {
dateNowSpy.mockReturnValue(1_776_000_000_000);
const firstSnapshot = await feature.refreshSnapshot();
binaryResolveMock.mockResolvedValue(null);
dateNowSpy.mockReturnValue(1_776_000_020_000);
const secondSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
expect(secondSnapshot.appServerState).toBe('healthy');
expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt');
expect(secondSnapshot.launchAllowed).toBe(true);
expect(secondSnapshot.launchIssueMessage).toBeNull();
expect(secondSnapshot.managedAccount).toMatchObject({
type: 'chatgpt',
email: 'user@example.com',
});
expect(secondSnapshot.runtimeContext).toEqual({
binaryPath: '/usr/local/bin/codex',
codexHome: '/Users/test/.codex',
});
expect(readAccountMock).toHaveBeenCalledTimes(1);
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
} finally {
dateNowSpy.mockRestore();
await feature.dispose();
}
});
it('reports runtime-missing once the last known Codex runtime is too old to trust', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: '/usr/bin:/bin',
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
const dateNowSpy = vi.spyOn(Date, 'now');
try {
dateNowSpy.mockReturnValue(1_776_000_000_000);
await feature.refreshSnapshot();
binaryResolveMock.mockResolvedValue(null);
dateNowSpy.mockReturnValue(1_776_000_060_001);
const snapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
expect(snapshot.appServerState).toBe('runtime-missing');
expect(snapshot.launchReadinessState).toBe('runtime_missing');
expect(snapshot.launchIssueMessage).toContain('Codex CLI not found');
expect(snapshot.managedAccount).toBeNull();
expect(readAccountMock).toHaveBeenCalledTimes(1);
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
} finally {
dateNowSpy.mockRestore();
await feature.dispose();
}
});
it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),

View file

@ -6,10 +6,9 @@ import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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');
@ -24,6 +23,23 @@ 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) {
@ -58,7 +74,7 @@ const server = http.createServer((request, response) => {
return;
}
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
response.end(healthIdentity ? JSON.stringify(healthIdentity) : 'ok');
return;
}
@ -255,11 +271,13 @@ async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise<string> {
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 () => {
@ -270,6 +288,11 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
} 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;
@ -280,8 +303,11 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
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,
@ -296,7 +322,10 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
}
it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => {
const server = new AgentTeamsMcpHttpServer();
const server = new AgentTeamsMcpHttpServer({
statePath: path.join(tempDir!, 'actual-mcp-http-state.json'),
disableOrphanCleanup: true,
});
servers.push(server);
const handle = await server.ensureStarted();
@ -321,6 +350,88 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
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');

View file

@ -1,6 +1,13 @@
import { EventEmitter } from 'events';
import http from 'http';
import net from 'net';
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(() => ({
@ -17,7 +24,10 @@ vi.mock('@main/utils/childProcess', async (importOriginal) => {
};
});
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
import {
AgentTeamsMcpHttpServer,
type AgentTeamsMcpHttpServerDeps,
} from '@main/services/team/AgentTeamsMcpHttpServer';
class FakeChildProcess extends EventEmitter {
pid: number;
@ -29,6 +39,57 @@ class FakeChildProcess extends EventEmitter {
}
}
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();
@ -44,6 +105,11 @@ async function allocateLoopbackPort(): Promise<number> {
});
}
async function flushAsyncCleanup(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
await Promise.resolve();
}
describe('AgentTeamsMcpHttpServer', () => {
beforeEach(() => {
hoisted.killProcessTreeMock.mockReset();
@ -52,8 +118,9 @@ describe('AgentTeamsMcpHttpServer', () => {
it('starts the MCP server over HTTP with hidden app-owned process env', async () => {
const child = new FakeChildProcess();
const spawnProcess = vi.fn(() => child as any);
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'],
@ -98,6 +165,7 @@ describe('AgentTeamsMcpHttpServer', () => {
],
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',
})
@ -106,8 +174,9 @@ describe('AgentTeamsMcpHttpServer', () => {
it('uses a hidden default spawn without holding stdout open', async () => {
const child = new FakeChildProcess();
hoisted.spawnCliMock.mockReturnValue(child as any);
hoisted.spawnCliMock.mockReturnValue(child as unknown as ChildProcess);
const server = new AgentTeamsMcpHttpServer({
statePath: null,
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],
@ -135,6 +204,7 @@ describe('AgentTeamsMcpHttpServer', () => {
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',
}),
@ -146,8 +216,9 @@ describe('AgentTeamsMcpHttpServer', () => {
it('coalesces concurrent starts', async () => {
const child = new FakeChildProcess();
const spawnProcess = vi.fn(() => child as any);
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'],
@ -163,11 +234,206 @@ describe('AgentTeamsMcpHttpServer', () => {
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 any);
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'],
@ -182,7 +448,7 @@ describe('AgentTeamsMcpHttpServer', () => {
expect(second).toBe(first);
expect(spawnProcess).toHaveBeenCalledTimes(1);
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 5_000);
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();
});
@ -192,8 +458,8 @@ describe('AgentTeamsMcpHttpServer', () => {
const secondChild = new FakeChildProcess(43124);
const spawnProcess = vi
.fn()
.mockReturnValueOnce(firstChild as any)
.mockReturnValueOnce(secondChild as any);
.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) {
@ -201,6 +467,7 @@ describe('AgentTeamsMcpHttpServer', () => {
}
});
const server = new AgentTeamsMcpHttpServer({
statePath: null,
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],
@ -208,6 +475,7 @@ describe('AgentTeamsMcpHttpServer', () => {
allocatePort,
spawnProcess,
waitForPort,
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
});
const first = await server.ensureStarted();
@ -230,9 +498,9 @@ describe('AgentTeamsMcpHttpServer', () => {
expect(spawnProcess).toHaveBeenCalledTimes(2);
expect(allocatePort).toHaveBeenCalledTimes(1);
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL');
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000);
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, 5_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'
@ -243,23 +511,12 @@ describe('AgentTeamsMcpHttpServer', () => {
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 blocker = net.createServer();
const blockedPort = await new Promise<number>((resolve, reject) => {
blocker.once('error', reject);
blocker.listen(0, '127.0.0.1', () => {
const address = blocker.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to allocate blocked test port'));
return;
}
resolve(address.port);
});
});
const fallbackPort = blockedPort === 65_535 ? blockedPort - 1 : blockedPort + 1;
const blockedPort = 41041;
const fallbackPort = 41042;
const spawnProcess = vi
.fn()
.mockReturnValueOnce(firstChild as any)
.mockReturnValueOnce(secondChild as any);
.mockReturnValueOnce(firstChild as unknown as ChildProcess)
.mockReturnValueOnce(secondChild as unknown as ChildProcess);
const allocatePort = vi
.fn()
.mockResolvedValueOnce(blockedPort)
@ -270,6 +527,7 @@ describe('AgentTeamsMcpHttpServer', () => {
}
});
const server = new AgentTeamsMcpHttpServer({
statePath: null,
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],
@ -277,40 +535,172 @@ describe('AgentTeamsMcpHttpServer', () => {
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 first = await server.ensureStarted();
const second = await server.ensureStarted();
const handle = await server.ensureStarted();
await flushAsyncCleanup();
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(killProcess).toHaveBeenNthCalledWith(1, 9005);
expect(killProcess).toHaveBeenNthCalledWith(2, 9001);
expect(handle.diagnostics).toContain(
`opencode_app_mcp_legacy_orphan_cleaned:${orphanPort}`
);
expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1);
expect(allocatePort).toHaveBeenCalledTimes(2);
} finally {
await new Promise<void>((resolve) => blocker.close(() => resolve()));
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 any),
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
waitForPort: vi.fn(() => {
child.emit('exit', 1, null);
return new Promise<void>(() => {
@ -332,12 +722,13 @@ describe('AgentTeamsMcpHttpServer', () => {
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 any),
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
waitForPort: vi.fn(async () => {
await Promise.resolve();
child.emit('exit', 0, null);
@ -371,9 +762,10 @@ describe('AgentTeamsMcpHttpServer', () => {
const spawnProcess = vi.fn((_command: string, args: string[]) => {
expect(args).toContain(String(port));
healthServer.listen(port, '127.0.0.1');
return child as any;
return child as unknown as ChildProcess;
});
const server = new AgentTeamsMcpHttpServer({
statePath: null,
resolveLaunchSpec: async () => ({
command: 'node',
args: ['mcp-server/dist/index.js'],

View file

@ -6,8 +6,40 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterAll, afterEach, beforeEach, expect, vi } from 'vitest';
import { afterEach, beforeEach, expect, vi } from 'vitest';
const TEST_HOME_PREFIX = 'agent-teams-vitest-home-';
const DEFAULT_STALE_TEST_HOME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function getStaleTestHomeMaxAgeMs(): number {
const value = Number(process.env.AGENT_TEAMS_VITEST_STALE_HOME_MAX_AGE_MS);
return Number.isFinite(value) && value > 0 ? value : DEFAULT_STALE_TEST_HOME_MAX_AGE_MS;
}
function cleanupStaleTestHomeDirs(): void {
const cutoff = Date.now() - getStaleTestHomeMaxAgeMs();
for (const entry of fs.readdirSync(os.tmpdir(), { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith(TEST_HOME_PREFIX)) {
continue;
}
const dir = path.join(os.tmpdir(), entry.name);
try {
const stat = fs.statSync(dir);
if (stat.mtimeMs < cutoff) {
fs.rmSync(dir, { recursive: true, force: true });
}
} catch {
// Best effort cleanup only.
}
}
}
if (process.env.AGENT_TEAMS_VITEST_TEMP_CLEANUP_DONE !== '1') {
process.env.AGENT_TEAMS_VITEST_TEMP_CLEANUP_DONE = '1';
cleanupStaleTestHomeDirs();
}
// Mock Sentry Electron SDK - it requires the real `electron` package at import
// time which is unavailable in the vitest/happy-dom environment.
@ -33,11 +65,22 @@ vi.mock('@sentry/react', () => sentryNoOp);
// Mock HOME for tests that need a predictable home path. It must be writable:
// some services persist state in best-effort background writes after a test has
// already reset path overrides.
const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-vitest-home-'));
const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), TEST_HOME_PREFIX));
vi.stubEnv('HOME', testHomeDir);
process.once('exit', () => {
fs.rmSync(testHomeDir, { recursive: true, force: true });
});
let testHomeDirRemoved = false;
function removeTestHomeDir(): void {
if (testHomeDirRemoved) {
return;
}
testHomeDirRemoved = true;
try {
fs.rmSync(testHomeDir, { recursive: true, force: true });
} catch {
// Best effort cleanup only.
}
}
afterAll(removeTestHomeDir);
process.once('exit', removeTestHomeDir);
let errorSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;