1408 lines
43 KiB
TypeScript
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;
|
|
}
|