agent-ecosystem/src/main/services/team/AgentTeamsMcpHttpServer.ts
2026-05-22 16:49:02 +03:00

1408 lines
43 KiB
TypeScript

import { type ChildProcess, execFile, type ExecFileException } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import * as path from 'node:path';
import { type RuntimeProcessTableRow } from '@features/tmux-installer/main';
import { applyAgentTeamsIdentityEnv } from '@main/services/identity/AgentTeamsIdentityStore';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { killProcessTree, spawnCli, untrackCliProcess } from '@main/utils/childProcess';
import { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { killProcessByPid } from '@main/utils/processKill';
import { createLogger } from '@shared/utils/logger';
import { type FileLockOptions, withFileLock } from './fileLock';
import { type McpLaunchSpec, resolveAgentTeamsMcpLaunchSpec } from './TeamMcpConfigBuilder';
const logger = createLogger('Service:AgentTeamsMcpHttpServer');
const MCP_HTTP_HOST = '127.0.0.1';
const MCP_HTTP_ENDPOINT = '/mcp';
const MCP_HTTP_READY_TIMEOUT_MS = 10_000;
const MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS = 3_000;
const MCP_HTTP_READY_POLL_MS = 100;
const MCP_HTTP_PORT_RELEASE_TIMEOUT_MS = 3_000;
const MCP_HTTP_STABLE_PORT_BASE = 43_100;
const MCP_HTTP_STABLE_PORT_SPAN = 700;
const MCP_HTTP_STABLE_PORT_SCAN_LIMIT = 20;
const MCP_HTTP_PORT_ENV = 'CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT';
const MCP_HTTP_HEALTH_BODY_MAX_BYTES = 8 * 1024;
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';
const MCP_HTTP_STATE_DIR = 'mcp-http-server';
const MCP_HTTP_STATE_FILE = 'state.json';
const MCP_HTTP_STATE_LOCK_OPTIONS: FileLockOptions = {
acquireTimeoutMs: 5_000,
staleTimeoutMs: 30_000,
retryIntervalMs: 25,
};
const MCP_HTTP_CLEANUP_DISABLED_ENV = 'CLAUDE_TEAM_DISABLE_MCP_ORPHAN_CLEANUP';
const MCP_HTTP_ORPHAN_TERMINATE_GRACE_MS = 250;
export interface AgentTeamsMcpHttpTransportEvidence {
schemaVersion: 1;
transport: 'httpStream';
host: string;
port: number;
endpoint: string;
url: string;
urlHash: string;
generation: number;
observedAt: string;
}
export interface AgentTeamsMcpHttpIdentity {
schemaVersion: 1;
service: typeof MCP_HTTP_IDENTITY_SERVICE;
transport: 'httpStream';
host: string;
port: number;
endpoint: string;
claudeDirHash: string;
launchSpecHash: string;
ownerInstanceId: string;
}
export interface AgentTeamsMcpHttpServerHandle {
url: string;
port: number;
pid: number | null;
generation: number;
urlHash: string;
transportEvidence: AgentTeamsMcpHttpTransportEvidence;
diagnostics: string[];
}
export interface AgentTeamsMcpHttpHealthProbe {
healthy: boolean;
statusCode: number | null;
identity: AgentTeamsMcpHttpIdentity | null;
}
export interface AgentTeamsMcpHttpServerDeps {
resolveLaunchSpec?: () => Promise<McpLaunchSpec>;
allocatePort?: () => Promise<number>;
spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess;
waitForPort?: (host: string, port: number, timeoutMs: number) => Promise<void>;
probeHealth?: (host: string, port: number) => Promise<AgentTeamsMcpHttpHealthProbe>;
canListenOnPort?: (host: string, port: number) => Promise<boolean>;
statePath?: string | null;
withStateLock?: <T>(
filePath: string,
fn: () => Promise<T>,
options?: FileLockOptions
) => Promise<T>;
disableOrphanCleanup?: boolean;
listProcessRows?: () => Promise<RuntimeProcessTableRow[]>;
readProcessDetails?: (pid: number) => Promise<string | null>;
readProcessStartTimeMs?: (pid: number) => Promise<number | null>;
killProcess?: (pid: number) => void;
forceKillProcess?: (pid: number) => void;
isProcessAlive?: (pid: number) => boolean;
sleepMs?: (ms: number) => Promise<void>;
}
interface AgentTeamsMcpExpectedHttpIdentity {
service: typeof MCP_HTTP_IDENTITY_SERVICE;
transport: 'httpStream';
host: string;
endpoint: string;
claudeDirHash: string;
launchSpecHash: string;
ownerInstanceId: string;
}
interface AgentTeamsMcpHttpState {
schemaVersion: 1;
service: typeof MCP_HTTP_IDENTITY_SERVICE;
transport: 'httpStream';
host: string;
port: number;
endpoint: string;
url: string;
urlHash: string;
pid: number | null;
claudeDirHash: string;
launchSpecHash: string;
ownerInstanceId: string;
startedAt: string;
updatedAt: string;
}
type PortClassification =
| { kind: 'available' }
| { kind: 'owned'; identity: AgentTeamsMcpHttpIdentity }
| { kind: 'occupied_unknown'; healthy: boolean };
async function allocateLoopbackPort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, MCP_HTTP_HOST, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate Agent Teams MCP HTTP port')));
return;
}
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(address.port);
});
});
});
}
async function canListenOnLoopbackPort(host: string, port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
try {
server.close(() => resolve(false));
} catch {
resolve(false);
}
});
server.listen(port, host, () => {
server.close(() => resolve(true));
});
});
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function asPort(value: unknown): number | null {
return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 65_535
? Number(value)
: null;
}
function asPositiveInteger(value: unknown): number | null {
return Number.isInteger(value) && Number(value) > 0 ? Number(value) : null;
}
function parseHealthIdentity(raw: string): AgentTeamsMcpHttpIdentity | null {
const trimmed = raw.trim();
if (!trimmed.startsWith('{')) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!isRecord(parsed)) {
return null;
}
const service = parsed.service;
const transport = parsed.transport;
const host = asString(parsed.host);
const port = asPort(parsed.port);
const endpoint = asString(parsed.endpoint);
const claudeDirHash = asString(parsed.claudeDirHash);
const launchSpecHash = asString(parsed.launchSpecHash);
const ownerInstanceId = asString(parsed.ownerInstanceId);
if (
parsed.schemaVersion !== 1 ||
service !== MCP_HTTP_IDENTITY_SERVICE ||
transport !== 'httpStream' ||
!host ||
port === null ||
!endpoint ||
!claudeDirHash ||
!launchSpecHash ||
!ownerInstanceId
) {
return null;
}
return {
schemaVersion: 1,
service: MCP_HTTP_IDENTITY_SERVICE,
transport: 'httpStream',
host,
port,
endpoint,
claudeDirHash,
launchSpecHash,
ownerInstanceId,
};
} catch {
return null;
}
}
async function probeLoopbackHealth(
host: string,
port: number
): Promise<AgentTeamsMcpHttpHealthProbe> {
return new Promise((resolve) => {
let settled = false;
let body = '';
const finish = (probe: AgentTeamsMcpHttpHealthProbe): void => {
if (settled) {
return;
}
settled = true;
resolve(probe);
};
const request = http.get(
{
host,
port,
path: '/health',
timeout: MCP_HTTP_READY_POLL_MS,
},
(response) => {
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
if (body.length >= MCP_HTTP_HEALTH_BODY_MAX_BYTES) {
return;
}
body += chunk.slice(0, MCP_HTTP_HEALTH_BODY_MAX_BYTES - body.length);
});
response.on('end', () => {
const statusCode = response.statusCode ?? null;
const healthy = statusCode !== null && statusCode >= 200 && statusCode < 300;
finish({
healthy,
statusCode,
identity: healthy ? parseHealthIdentity(body) : null,
});
});
}
);
request.once('timeout', () => {
request.destroy();
finish({ healthy: false, statusCode: null, identity: null });
});
request.once('error', () => {
finish({ healthy: false, statusCode: null, identity: null });
});
});
}
async function waitForLoopbackPort(host: string, port: number, timeoutMs: number): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if ((await probeLoopbackHealth(host, port)).healthy) {
return;
}
await sleep(MCP_HTTP_READY_POLL_MS);
}
throw new Error(
`Agent Teams MCP HTTP server did not become healthy at ${host}:${port} in ${timeoutMs}ms`
);
}
async function waitForLoopbackPortAvailable(
host: string,
port: number,
timeoutMs: number
): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await canListenOnLoopbackPort(host, port)) {
return true;
}
await sleep(MCP_HTTP_READY_POLL_MS);
}
return await canListenOnLoopbackPort(host, port);
}
function defaultSpawnProcess(
command: string,
args: string[],
env: NodeJS.ProcessEnv
): ChildProcess {
const child = spawnCli(command, args, {
env,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
untrackCliProcess(child);
return child;
}
function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] {
return [
...launchSpec.args,
'--transport',
'httpStream',
'--host',
MCP_HTTP_HOST,
'--port',
String(port),
'--endpoint',
MCP_HTTP_ENDPOINT,
];
}
function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
function parseConfiguredStablePort(value: string | undefined): number | null {
if (!value?.trim()) {
return null;
}
const parsed = Number(value.trim());
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
logger.warn(`Ignoring invalid ${MCP_HTTP_PORT_ENV} value: ${value}`);
return null;
}
return parsed;
}
function resolveDefaultStablePort(): number {
const configured = parseConfiguredStablePort(process.env[MCP_HTTP_PORT_ENV]);
if (configured) {
return configured;
}
const basis = `${getClaudeBasePath()}|agent-teams-opencode-mcp-http`;
const hashPrefix = sha256Hex(basis).slice(0, 8);
const offset = Number.parseInt(hashPrefix, 16) % MCP_HTTP_STABLE_PORT_SPAN;
return MCP_HTTP_STABLE_PORT_BASE + offset;
}
function buildTransportEvidence(
port: number,
generation: number
): AgentTeamsMcpHttpTransportEvidence {
const url = `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`;
return {
schemaVersion: 1,
transport: 'httpStream',
host: MCP_HTTP_HOST,
port,
endpoint: MCP_HTTP_ENDPOINT,
url,
urlHash: sha256Hex(url),
generation,
observedAt: new Date().toISOString(),
};
}
function buildStatePath(): string {
return path.join(getAppDataPath(), MCP_HTTP_STATE_DIR, MCP_HTTP_STATE_FILE);
}
function buildLaunchSpecHash(launchSpec: McpLaunchSpec): 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 }));
}
function buildExpectedIdentity(
launchSpec: McpLaunchSpec,
ownerInstanceId: string
): AgentTeamsMcpExpectedHttpIdentity {
return {
service: MCP_HTTP_IDENTITY_SERVICE,
transport: 'httpStream',
host: MCP_HTTP_HOST,
endpoint: MCP_HTTP_ENDPOINT,
claudeDirHash: sha256Hex(getClaudeBasePath()),
launchSpecHash: buildLaunchSpecHash(launchSpec),
ownerInstanceId,
};
}
function identityMatchesExpected(
identity: AgentTeamsMcpHttpIdentity,
expected: AgentTeamsMcpExpectedHttpIdentity,
port?: number
): boolean {
return (
identity.service === expected.service &&
identity.transport === expected.transport &&
identity.host === expected.host &&
identity.endpoint === expected.endpoint &&
identity.claudeDirHash === expected.claudeDirHash &&
identity.launchSpecHash === expected.launchSpecHash &&
(port === undefined || identity.port === port)
);
}
function buildState(
handle: AgentTeamsMcpHttpServerHandle,
identity: AgentTeamsMcpHttpIdentity,
pid: number | null,
startedAt: string
): AgentTeamsMcpHttpState {
return {
schemaVersion: 1,
service: MCP_HTTP_IDENTITY_SERVICE,
transport: 'httpStream',
host: MCP_HTTP_HOST,
port: handle.port,
endpoint: MCP_HTTP_ENDPOINT,
url: handle.url,
urlHash: handle.urlHash,
pid,
claudeDirHash: identity.claudeDirHash,
launchSpecHash: identity.launchSpecHash,
ownerInstanceId: identity.ownerInstanceId,
startedAt,
updatedAt: new Date().toISOString(),
};
}
function parseState(raw: string): AgentTeamsMcpHttpState | null {
try {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return null;
}
const host = asString(parsed.host);
const port = asPort(parsed.port);
const endpoint = asString(parsed.endpoint);
const url = asString(parsed.url);
const urlHash = asString(parsed.urlHash);
const pid = parsed.pid === null ? null : asPositiveInteger(parsed.pid);
const claudeDirHash = asString(parsed.claudeDirHash);
const launchSpecHash = asString(parsed.launchSpecHash);
const ownerInstanceId = asString(parsed.ownerInstanceId);
const startedAt = asString(parsed.startedAt);
const updatedAt = asString(parsed.updatedAt);
if (
parsed.schemaVersion !== 1 ||
parsed.service !== MCP_HTTP_IDENTITY_SERVICE ||
parsed.transport !== 'httpStream' ||
!host ||
port === null ||
!endpoint ||
!url ||
!urlHash ||
(pid === null && parsed.pid !== null) ||
!claudeDirHash ||
!launchSpecHash ||
!ownerInstanceId ||
!startedAt ||
!updatedAt
) {
return null;
}
return {
schemaVersion: 1,
service: MCP_HTTP_IDENTITY_SERVICE,
transport: 'httpStream',
host,
port,
endpoint,
url,
urlHash,
pid,
claudeDirHash,
launchSpecHash,
ownerInstanceId,
startedAt,
updatedAt,
};
} catch {
return null;
}
}
function stateMatchesExpected(
state: AgentTeamsMcpHttpState,
expected: AgentTeamsMcpExpectedHttpIdentity
): boolean {
return (
state.service === expected.service &&
state.transport === expected.transport &&
state.host === expected.host &&
state.endpoint === expected.endpoint &&
state.url === `http://${MCP_HTTP_HOST}:${state.port}${MCP_HTTP_ENDPOINT}` &&
state.urlHash === sha256Hex(state.url) &&
state.claudeDirHash === expected.claudeDirHash &&
state.launchSpecHash === expected.launchSpecHash
);
}
function isFileLockTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message.startsWith('File lock timeout:');
}
function diagnostic(message: string, diagnostics: string[]): void {
diagnostics.push(message);
}
function emitDiagnostics(diagnostics: readonly string[]): void {
for (const item of diagnostics) {
logger.warn(`Agent Teams MCP HTTP diagnostic: ${item}`);
}
}
function parseCommandArg(command: string, flag: string): string | null {
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`(?:^|\\s)${escaped}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|(\\S+))`).exec(
command
);
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
}
function parseCommandPort(command: string): number | null {
const raw = parseCommandArg(command, '--port');
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535 ? parsed : null;
}
function commandArgEquals(command: string, flag: string, expected: string): boolean {
return parseCommandArg(command, flag) === expected;
}
function isMcpHttpServerCommand(command: string): boolean {
const normalized = command.trim();
return (
/mcp-server[/\\](?:src[/\\]index\.ts|dist[/\\]index\.js|index\.js)(?=\s|$)/.test(normalized) &&
commandArgEquals(normalized, '--transport', 'httpStream') &&
commandArgEquals(normalized, '--host', MCP_HTTP_HOST) &&
commandArgEquals(normalized, '--endpoint', MCP_HTTP_ENDPOINT) &&
parseCommandPort(normalized) !== null
);
}
function processDetailsIncludeMarker(details: string, marker: string): boolean {
return new RegExp(`(^|\\s)${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`).test(
details
);
}
function hasManagedMcpDetails(details: string, port: number): boolean {
return (
processDetailsIncludeMarker(details, 'AGENT_TEAMS_MCP_TRANSPORT=httpStream') &&
processDetailsIncludeMarker(details, `AGENT_TEAMS_MCP_HTTP_HOST=${MCP_HTTP_HOST}`) &&
processDetailsIncludeMarker(details, `AGENT_TEAMS_MCP_HTTP_PORT=${port}`) &&
processDetailsIncludeMarker(details, `AGENT_TEAMS_MCP_HTTP_ENDPOINT=${MCP_HTTP_ENDPOINT}`) &&
processDetailsIncludeMarker(details, `AGENT_TEAMS_MCP_CLAUDE_DIR=${getClaudeBasePath()}`)
);
}
function isNativeProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
async function readNativeProcessCommandWithEnv(pid: number): Promise<string | null> {
return execFileText('ps', ['eww', '-p', String(pid), '-o', 'command='], 2_000, 2 * 1024 * 1024);
}
async function readNativeProcessStartTimeMs(pid: number): Promise<number | null> {
const output = await execFileText('ps', ['-p', String(pid), '-o', 'lstart='], 2_000, 64 * 1024);
if (!output) {
return null;
}
const parsed = Date.parse(output.trim());
return Number.isFinite(parsed) ? parsed : null;
}
function parseNativeProcessRows(output: string): RuntimeProcessTableRow[] {
const rows: RuntimeProcessTableRow[] = [];
for (const line of output.split('\n')) {
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
if (!match) {
continue;
}
const pid = Number.parseInt(match[1], 10);
const ppid = Number.parseInt(match[2], 10);
const command = match[3]?.trim() ?? '';
if (pid > 0 && ppid >= 0 && command.length > 0) {
rows.push({ pid, ppid, command });
}
}
return rows;
}
async function listNativeProcessRows(): Promise<RuntimeProcessTableRow[]> {
if (process.platform === 'win32') {
return [];
}
const output = await execFileText(
'ps',
['-ax', '-o', 'pid=,ppid=,command='],
2_000,
4 * 1024 * 1024
);
return output ? parseNativeProcessRows(output) : [];
}
function execFileText(
command: string,
args: string[],
timeout: number,
maxBuffer: number
): Promise<string | null> {
return new Promise((resolve) => {
execFile(
command,
args,
{
encoding: 'utf8',
timeout,
maxBuffer,
windowsHide: true,
},
(error: ExecFileException | null, stdout: string | Buffer) => {
if (error) {
resolve(null);
return;
}
resolve(String(stdout));
}
);
});
}
export class AgentTeamsMcpHttpServer {
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
private child: ChildProcess | null = null;
private handle: AgentTeamsMcpHttpServerHandle | null = null;
private generation = 0;
private readonly expectedStopChildren = new WeakSet<ChildProcess>();
private readonly ownerInstanceId = randomUUID();
private readonly startedAtMs = Date.now();
private preventFutureStarts = false;
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
this.throwIfStartsPrevented();
if (this.startPromise) {
return this.startPromise;
}
this.startPromise = (
this.handle ? this.reuseOrRestartExistingHandle(this.handle) : this.startOnce()
).finally(() => {
this.startPromise = null;
});
return this.startPromise;
}
async stop(input: { preventRestart?: boolean } = {}): Promise<void> {
if (input.preventRestart) {
this.preventFutureStarts = true;
}
const child = this.child;
const handle = this.handle;
const releasePort = child ? (handle?.port ?? null) : null;
this.child = null;
this.handle = null;
if (child) {
this.expectedStopChildren.add(child);
killProcessTree(child, 'SIGKILL');
if (handle) {
await this.clearStateForOwnedHandle(handle);
}
}
if (releasePort) {
await waitForLoopbackPortAvailable(
MCP_HTTP_HOST,
releasePort,
MCP_HTTP_PORT_RELEASE_TIMEOUT_MS
);
}
}
getCurrentHandle(): AgentTeamsMcpHttpServerHandle | null {
return this.handle;
}
private resolveStatePath(): string | null {
if (this.deps.statePath === null) {
return null;
}
return this.deps.statePath ?? buildStatePath();
}
private throwIfStartsPrevented(): void {
if (this.preventFutureStarts) {
throw new Error('Agent Teams MCP HTTP server startup is disabled during shutdown');
}
}
private async reuseOrRestartExistingHandle(
handle: AgentTeamsMcpHttpServerHandle
): Promise<AgentTeamsMcpHttpServerHandle> {
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
try {
await waitForPort(MCP_HTTP_HOST, handle.port, MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS);
if (this.handle === handle) {
return handle;
}
} catch (error) {
if (this.handle === handle) {
logger.warn(
`Agent Teams MCP HTTP server at ${handle.url} failed health reuse check, restarting: ${
error instanceof Error ? error.message : String(error)
}`
);
const restartPort = handle.port;
const previousUrlHash = handle.urlHash;
await this.stop();
return this.startOnce({
preferredPort: restartPort,
previousUrlHash,
reason: 'health_reuse_failed',
});
}
}
return this.startOnce();
}
private async readStateSafe(
statePath: string,
diagnostics: string[]
): Promise<AgentTeamsMcpHttpState | null> {
try {
const raw = await fs.promises.readFile(statePath, 'utf8');
const parsed = parseState(raw);
if (!parsed) {
diagnostic('opencode_app_mcp_state_ignored:parse_failed', diagnostics);
}
return parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
diagnostic('opencode_app_mcp_state_ignored:read_failed', diagnostics);
}
return null;
}
}
private async writeStateSafe(
statePath: string | null,
state: AgentTeamsMcpHttpState,
diagnostics: string[]
): Promise<void> {
if (!statePath) {
return;
}
try {
await atomicWriteAsync(statePath, `${JSON.stringify(state, null, 2)}\n`);
} catch {
diagnostic('opencode_app_mcp_state_ignored:write_failed', diagnostics);
}
}
private async clearStateForOwnedHandle(handle: AgentTeamsMcpHttpServerHandle): Promise<void> {
const statePath = this.resolveStatePath();
if (!statePath) {
return;
}
const lock = this.deps.withStateLock ?? withFileLock;
try {
await lock(
statePath,
async () => {
const diagnostics: string[] = [];
const state = await this.readStateSafe(statePath, diagnostics);
if (
state &&
state.port === handle.port &&
state.urlHash === handle.urlHash &&
state.ownerInstanceId === this.ownerInstanceId
) {
await fs.promises.rm(statePath, { force: true });
}
},
MCP_HTTP_STATE_LOCK_OPTIONS
);
} catch {
logger.warn('Agent Teams MCP HTTP diagnostic: opencode_app_mcp_state_ignored:clear_failed');
}
}
private async classifyPort(
port: number,
expectedIdentity: AgentTeamsMcpExpectedHttpIdentity
): Promise<PortClassification> {
const canListen = this.deps.canListenOnPort ?? canListenOnLoopbackPort;
if (await canListen(MCP_HTTP_HOST, port)) {
return { kind: 'available' };
}
const probeHealth = this.deps.probeHealth ?? probeLoopbackHealth;
const probe = await probeHealth(MCP_HTTP_HOST, port);
if (
probe.healthy &&
probe.identity &&
identityMatchesExpected(probe.identity, expectedIdentity, port)
) {
return { kind: 'owned', identity: probe.identity };
}
return { kind: 'occupied_unknown', healthy: probe.healthy };
}
private async tryAdoptStateHandle(
statePath: string,
expectedIdentity: AgentTeamsMcpExpectedHttpIdentity,
diagnostics: string[]
): Promise<AgentTeamsMcpHttpServerHandle | null> {
const state = await this.readStateSafe(statePath, diagnostics);
if (!state) {
return null;
}
if (!stateMatchesExpected(state, expectedIdentity)) {
diagnostic('opencode_app_mcp_state_ignored:identity_mismatch', diagnostics);
return null;
}
const probeHealth = this.deps.probeHealth ?? probeLoopbackHealth;
const probe = await probeHealth(MCP_HTTP_HOST, state.port);
if (!probe.healthy) {
diagnostic('opencode_app_mcp_state_ignored:unhealthy', diagnostics);
return null;
}
if (!probe.identity || !identityMatchesExpected(probe.identity, expectedIdentity, state.port)) {
diagnostic('opencode_app_mcp_state_ignored:identity_mismatch', diagnostics);
return null;
}
return this.adoptHandle({
identity: probe.identity,
pid: state.pid,
diagnostics,
diagnosticMessage: `opencode_app_mcp_adopted_state_server:${state.port}`,
statePath,
});
}
private async tryAdoptPortHandle(
port: number,
expectedIdentity: AgentTeamsMcpExpectedHttpIdentity,
statePath: string | null,
diagnostics: string[]
): Promise<AgentTeamsMcpHttpServerHandle | null> {
const classification = await this.classifyPort(port, expectedIdentity);
if (classification.kind === 'available') {
return null;
}
if (classification.kind === 'owned') {
return this.adoptHandle({
identity: classification.identity,
pid: null,
diagnostics,
diagnosticMessage: `opencode_app_mcp_adopted_port_server:${port}`,
statePath,
});
}
diagnostic(`opencode_app_mcp_port_occupied_unknown:${port}`, diagnostics);
return null;
}
private async adoptHandle(input: {
identity: AgentTeamsMcpHttpIdentity;
pid: number | null;
diagnostics: string[];
diagnosticMessage: string;
statePath: string | null;
}): Promise<AgentTeamsMcpHttpServerHandle> {
diagnostic(input.diagnosticMessage, input.diagnostics);
const generation = this.generation + 1;
const transportEvidence = buildTransportEvidence(input.identity.port, generation);
this.generation = generation;
this.child = null;
this.handle = {
url: transportEvidence.url,
port: input.identity.port,
pid: input.pid,
generation,
urlHash: transportEvidence.urlHash,
transportEvidence,
diagnostics: input.diagnostics,
};
await this.writeStateSafe(
input.statePath,
buildState(this.handle, input.identity, input.pid, new Date().toISOString()),
input.diagnostics
);
logger.info(`Agent Teams MCP HTTP server adopted at ${this.handle.url}`);
emitDiagnostics(input.diagnostics);
this.scheduleOrphanCleanup(input.identity, this.handle);
return this.handle;
}
private async resolveStartTarget(
preferredPort: number | null | undefined,
expectedIdentity: AgentTeamsMcpExpectedHttpIdentity,
statePath: string | null,
diagnostics: string[]
): Promise<
{ kind: 'port'; port: number } | { kind: 'handle'; handle: AgentTeamsMcpHttpServerHandle }
> {
const canListen = this.deps.canListenOnPort ?? canListenOnLoopbackPort;
if (preferredPort) {
if (await canListen(MCP_HTTP_HOST, preferredPort)) {
return { kind: 'port', port: preferredPort };
}
const adopted = await this.tryAdoptPortHandle(
preferredPort,
expectedIdentity,
statePath,
diagnostics
);
if (adopted) {
return { kind: 'handle', handle: adopted };
}
diagnostic(`opencode_app_mcp_preferred_port_unavailable:${preferredPort}`, diagnostics);
}
if (this.deps.allocatePort && (!preferredPort || diagnostics.length > 0)) {
return { kind: 'port', port: await this.deps.allocatePort() };
}
const stablePort = resolveDefaultStablePort();
let stablePortUnavailable = false;
for (let offset = 0; offset < MCP_HTTP_STABLE_PORT_SCAN_LIMIT; offset += 1) {
const candidate = stablePort + offset;
if (candidate > 65_535) {
break;
}
if (preferredPort === candidate) {
continue;
}
const classification = await this.classifyPort(candidate, expectedIdentity);
if (classification.kind === 'available') {
if (candidate !== stablePort || stablePortUnavailable) {
diagnostic(`opencode_app_mcp_preferred_port_unavailable:${stablePort}`, diagnostics);
}
return { kind: 'port', port: candidate };
}
if (classification.kind === 'owned') {
return {
kind: 'handle',
handle: await this.adoptHandle({
identity: classification.identity,
pid: null,
diagnostics,
diagnosticMessage: `opencode_app_mcp_adopted_port_server:${candidate}`,
statePath,
}),
};
}
stablePortUnavailable = stablePortUnavailable || candidate === stablePort;
diagnostic(`opencode_app_mcp_port_occupied_unknown:${candidate}`, diagnostics);
}
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
const port = await allocatePort();
diagnostic('opencode_app_mcp_stable_port_range_unavailable', diagnostics);
return { kind: 'port', port };
}
private async startOnce(
input: {
preferredPort?: number | null;
previousUrlHash?: string | null;
reason?: string;
} = {}
): Promise<AgentTeamsMcpHttpServerHandle> {
this.throwIfStartsPrevented();
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
const launchSpec = await resolveLaunchSpec();
this.throwIfStartsPrevented();
const expectedIdentity = buildExpectedIdentity(launchSpec, this.ownerInstanceId);
const statePath = this.resolveStatePath();
const startUnlocked = async (effectiveStatePath: string | null, diagnostics: string[]) =>
this.startOnceUnlocked(input, launchSpec, expectedIdentity, effectiveStatePath, diagnostics);
if (!statePath) {
return startUnlocked(null, []);
}
const lock = this.deps.withStateLock ?? withFileLock;
try {
return await lock(statePath, () => startUnlocked(statePath, []), MCP_HTTP_STATE_LOCK_OPTIONS);
} catch (error) {
if (!isFileLockTimeoutError(error)) {
throw error;
}
const diagnostics = ['opencode_app_mcp_state_ignored:lock_failed'];
return startUnlocked(null, diagnostics);
}
}
private async startOnceUnlocked(
input: {
preferredPort?: number | null;
previousUrlHash?: string | null;
reason?: string;
},
launchSpec: McpLaunchSpec,
expectedIdentity: AgentTeamsMcpExpectedHttpIdentity,
statePath: string | null,
initialDiagnostics: string[]
): Promise<AgentTeamsMcpHttpServerHandle> {
const diagnostics = [...initialDiagnostics];
const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess;
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
this.throwIfStartsPrevented();
if (statePath) {
const adopted = await this.tryAdoptStateHandle(statePath, expectedIdentity, diagnostics);
if (adopted) {
return adopted;
}
}
const selectedTarget = await this.resolveStartTarget(
input.preferredPort ?? null,
expectedIdentity,
statePath,
diagnostics
);
this.throwIfStartsPrevented();
if (selectedTarget.kind === 'handle') {
return selectedTarget.handle;
}
const port = selectedTarget.port;
const args = buildHttpServerArgs(launchSpec, port);
const childIdentity: AgentTeamsMcpHttpIdentity = {
schemaVersion: 1,
service: MCP_HTTP_IDENTITY_SERVICE,
transport: 'httpStream',
host: MCP_HTTP_HOST,
port,
endpoint: MCP_HTTP_ENDPOINT,
claudeDirHash: expectedIdentity.claudeDirHash,
launchSpecHash: expectedIdentity.launchSpecHash,
ownerInstanceId: expectedIdentity.ownerInstanceId,
};
const childEnv = applyAgentTeamsIdentityEnv({
...process.env,
...launchSpec.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,
AGENT_TEAMS_MCP_HTTP_PORT: String(port),
AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT,
[MCP_HTTP_IDENTITY_SERVICE_ENV]: MCP_HTTP_IDENTITY_SERVICE,
[MCP_HTTP_CLAUDE_DIR_HASH_ENV]: expectedIdentity.claudeDirHash,
[MCP_HTTP_LAUNCH_SPEC_HASH_ENV]: expectedIdentity.launchSpecHash,
[MCP_HTTP_OWNER_INSTANCE_ID_ENV]: expectedIdentity.ownerInstanceId,
});
const child = spawnProcess(launchSpec.command, args, childEnv);
const clearIfCurrent = (): void => {
if (this.child === child) {
this.child = null;
this.handle = null;
}
};
child.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8').trim();
if (text) {
logger.debug(`Agent Teams MCP HTTP stderr: ${text.slice(0, 1000)}`);
}
});
this.child = child;
let startupSettled = false;
const startupFailure = new Promise<never>((_, reject) => {
child.once('exit', (code, signal) => {
const expectedStop = this.expectedStopChildren.delete(child);
clearIfCurrent();
const codeSuffix = typeof code === 'number' ? ` with code ${code}` : '';
const signalSuffix = signal ? ` (${signal})` : '';
const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`;
if (!startupSettled && !expectedStop) {
reject(new Error(message));
logger.warn(message);
return;
}
if (startupSettled && !expectedStop) {
logger.warn(
`Agent Teams MCP HTTP server exited after startup${codeSuffix}${signalSuffix}`
);
}
});
child.once('error', (error) => {
clearIfCurrent();
const message = `Agent Teams MCP HTTP server process error: ${
error instanceof Error ? error.message : String(error)
}`;
if (!startupSettled) {
reject(error instanceof Error ? error : new Error(message));
}
logger.warn(message);
});
});
try {
await Promise.race([
waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS),
startupFailure,
]);
if (this.child !== child) {
throw new Error('Agent Teams MCP HTTP server exited before startup completed');
}
} catch (error) {
startupSettled = true;
if (this.child === child) {
this.child = null;
this.handle = null;
}
this.expectedStopChildren.add(child);
killProcessTree(child, 'SIGKILL');
throw error;
}
startupSettled = true;
const generation = this.generation + 1;
const transportEvidence = buildTransportEvidence(port, generation);
this.generation = generation;
if (input.previousUrlHash && input.previousUrlHash !== transportEvidence.urlHash) {
diagnostic('opencode_app_mcp_public_url_changed', diagnostics);
}
if (input.reason) {
diagnostic(`opencode_app_mcp_restart_reason:${input.reason}`, diagnostics);
}
this.handle = {
url: transportEvidence.url,
port,
pid: child.pid ?? null,
generation,
urlHash: transportEvidence.urlHash,
transportEvidence,
diagnostics,
};
await this.writeStateSafe(
statePath,
buildState(this.handle, childIdentity, child.pid ?? null, new Date().toISOString()),
diagnostics
);
logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`);
emitDiagnostics(diagnostics);
this.scheduleOrphanCleanup(childIdentity, this.handle);
return this.handle;
}
private scheduleOrphanCleanup(
expectedIdentity: AgentTeamsMcpHttpIdentity,
currentHandle: AgentTeamsMcpHttpServerHandle
): void {
if (
this.deps.disableOrphanCleanup ||
this.resolveStatePath() === null ||
process.env[MCP_HTTP_CLEANUP_DISABLED_ENV] === '1'
) {
return;
}
void this.tryCleanupOwnedOrphans(expectedIdentity, currentHandle).catch(() => {
logger.warn('Agent Teams MCP HTTP diagnostic: opencode_app_mcp_orphan_cleanup_failed');
});
}
private async tryCleanupOwnedOrphans(
expectedIdentity: AgentTeamsMcpHttpIdentity,
currentHandle: AgentTeamsMcpHttpServerHandle
): Promise<void> {
const listRows = this.deps.listProcessRows ?? listNativeProcessRows;
const readDetails =
this.deps.readProcessDetails ??
(process.platform === 'win32' ? async () => null : readNativeProcessCommandWithEnv);
const readStartTimeMs =
this.deps.readProcessStartTimeMs ??
(process.platform === 'win32' ? async () => null : readNativeProcessStartTimeMs);
const killProcess = this.deps.killProcess ?? killProcessByPid;
const forceKillProcess =
this.deps.forceKillProcess ?? ((pid: number) => process.kill(pid, 'SIGKILL'));
const isProcessAlive = this.deps.isProcessAlive ?? isNativeProcessAlive;
const sleepMs = this.deps.sleepMs ?? sleep;
const probeHealth = this.deps.probeHealth ?? probeLoopbackHealth;
const rows = await listRows();
for (const row of rows) {
if (row.pid === currentHandle.pid || row.pid === process.pid) {
continue;
}
if (!isMcpHttpServerCommand(row.command)) {
continue;
}
const port = parseCommandPort(row.command);
if (!port || port === currentHandle.port) {
continue;
}
const parentMayStillOwnProcess =
process.platform === 'win32' ? row.ppid > 0 && isProcessAlive(row.ppid) : row.ppid !== 1;
if (parentMayStillOwnProcess) {
continue;
}
const startedAtMs = await readStartTimeMs(row.pid);
if (
!Number.isFinite(startedAtMs) ||
startedAtMs === null ||
startedAtMs >= this.startedAtMs
) {
continue;
}
const details = await readDetails(row.pid);
if (!details || !hasManagedMcpDetails(details, port)) {
continue;
}
const probe = await probeHealth(MCP_HTTP_HOST, port);
const hasMatchingIdentity =
probe.identity !== null && identityMatchesExpected(probe.identity, expectedIdentity, port);
if (probe.identity && !hasMatchingIdentity) {
continue;
}
const ownedPids = await this.collectOwnedMcpProcessTreePids(rows, row.pid, port, readDetails);
const ownedPidSet = new Set(ownedPids);
if (await this.hasLiveMcpConsumers(rows, ownedPidSet, port, readDetails)) {
this.recordCleanupDiagnostic(
currentHandle,
`opencode_app_mcp_legacy_orphan_kept_live_consumers:${port}`
);
continue;
}
try {
let cleanupFailed = false;
for (const pid of [...ownedPids].reverse()) {
if (!isProcessAlive(pid)) {
continue;
}
try {
killProcess(pid);
} catch {
cleanupFailed = cleanupFailed || isProcessAlive(pid);
}
}
await sleepMs(MCP_HTTP_ORPHAN_TERMINATE_GRACE_MS);
for (const pid of [...ownedPids].reverse()) {
if (!isProcessAlive(pid)) {
continue;
}
try {
forceKillProcess(pid);
} catch {
cleanupFailed = true;
}
}
if (ownedPids.some((pid) => isProcessAlive(pid))) {
cleanupFailed = true;
}
if (cleanupFailed) {
this.recordCleanupDiagnostic(
currentHandle,
`opencode_app_mcp_state_ignored:cleanup_failed`
);
continue;
}
this.recordCleanupDiagnostic(
currentHandle,
`opencode_app_mcp_legacy_orphan_cleaned:${port}`
);
} catch {
this.recordCleanupDiagnostic(
currentHandle,
`opencode_app_mcp_state_ignored:cleanup_failed`
);
}
}
}
private async collectOwnedMcpProcessTreePids(
rows: readonly RuntimeProcessTableRow[],
rootPid: number,
port: number,
readDetails: (pid: number) => Promise<string | null>
): Promise<number[]> {
const ownedPids = [rootPid];
const visited = new Set(ownedPids);
for (const parentPid of ownedPids) {
for (const row of rows) {
if (row.ppid !== parentPid || visited.has(row.pid)) {
continue;
}
if (!isMcpHttpServerCommand(row.command) || parseCommandPort(row.command) !== port) {
continue;
}
const details = await readDetails(row.pid);
if (!details || !hasManagedMcpDetails(details, port)) {
continue;
}
visited.add(row.pid);
ownedPids.push(row.pid);
}
}
return ownedPids;
}
private async hasLiveMcpConsumers(
rows: readonly RuntimeProcessTableRow[],
candidatePids: ReadonlySet<number>,
port: number,
readDetails: (pid: number) => Promise<string | null>
): Promise<boolean> {
const url = `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`;
const urlHash = sha256Hex(url);
for (const row of rows) {
if (candidatePids.has(row.pid)) {
continue;
}
const details = (await readDetails(row.pid)) ?? row.command;
if (
processDetailsIncludeMarker(details, `CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=${url}`) ||
processDetailsIncludeMarker(
details,
`CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH=${urlHash}`
)
) {
return true;
}
}
return false;
}
private recordCleanupDiagnostic(
handle: AgentTeamsMcpHttpServerHandle,
diagnosticMessage: string
): void {
handle.diagnostics.push(diagnosticMessage);
logger.warn(`Agent Teams MCP HTTP diagnostic: ${diagnosticMessage}`);
}
}
export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer();
export function getCurrentAgentTeamsMcpHttpTransportEvidence(): AgentTeamsMcpHttpTransportEvidence | null {
return agentTeamsMcpHttpServer.getCurrentHandle()?.transportEvidence ?? null;
}