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; allocatePort?: () => Promise; spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess; waitForPort?: (host: string, port: number, timeoutMs: number) => Promise; probeHealth?: (host: string, port: number) => Promise; canListenOnPort?: (host: string, port: number) => Promise; statePath?: string | null; withStateLock?: ( filePath: string, fn: () => Promise, options?: FileLockOptions ) => Promise; disableOrphanCleanup?: boolean; listProcessRows?: () => Promise; readProcessDetails?: (pid: number) => Promise; readProcessStartTimeMs?: (pid: number) => Promise; killProcess?: (pid: number) => void; forceKillProcess?: (pid: number) => void; isProcessAlive?: (pid: number) => boolean; sleepMs?: (ms: number) => Promise; } 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 { 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 { 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 { await new Promise((resolve) => setTimeout(resolve, ms)); } function isRecord(value: unknown): value is Record { 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 { 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 { 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 { 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 { return execFileText('ps', ['eww', '-p', String(pid), '-o', 'command='], 2_000, 2 * 1024 * 1024); } async function readNativeProcessStartTimeMs(pid: number): Promise { 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 { 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 { 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 | null = null; private child: ChildProcess | null = null; private handle: AgentTeamsMcpHttpServerHandle | null = null; private generation = 0; private readonly expectedStopChildren = new WeakSet(); private readonly ownerInstanceId = randomUUID(); private readonly startedAtMs = Date.now(); private preventFutureStarts = false; constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {} async ensureStarted(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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((_, 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 { 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 ): Promise { 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, port: number, readDetails: (pid: number) => Promise ): Promise { 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; }