// Task 178 — shadow-probe each MCP server in a spawn's mcp.json to learn
// what tools the server exposes at runtime. Used by pty-spawner.ts to emit
// the [pty-spawn-tool-inventory] line that closes the observability gap
// Task 174 §3 left open: the framework says N tools were provisioned but
// nothing in server.log records what each MCP server actually offered
// (and therefore which subset claude code could possibly load into the
// model's runtime inventory).
//
// The probe is independent of the real claude child. It re-spawns each
// MCP server in parallel using the same command+args+env the per-spawn
// mcp.json declares, drives the standard MCP initialize → tools/list
// JSON-RPC sequence over stdio (newline-delimited), captures the tool
// names, then SIGKILLs the child. The probe's tool-name list is what the
// real claude child sees from its own tools/list reply — modulo CLI-side
// filtering which is a separate observability concern (Task 178 known
// limitation; a follow-up task covers it).
//
// Per-server timeout. On timeout the server's tool list is empty; the
// aggregator in pty-spawner.ts emits the empty list and the operator can
// grep for the empty-inventory regression signature.
import { spawn as nodeSpawn } from 'node:child_process';
const PROBE_CLIENT_INFO = { name: 'maxy-pty-spawner-probe', version: '0.1.0' };
const PROBE_PROTOCOL_VERSION = '2024-11-05';
async function probeOne(name, desc, baseEnv, timeoutMs, spawnImpl, logger) {
    return new Promise((resolve) => {
        let child;
        try {
            child = spawnImpl(desc.command, desc.args, {
                env: { ...baseEnv, ...(desc.env ?? {}) },
                stdio: ['pipe', 'pipe', 'pipe'],
            });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            logger?.(`[mcp-tools-probe] spawn-failed server=${name} err=${JSON.stringify(msg)}`);
            resolve([]);
            return;
        }
        let stdoutBuf = '';
        let done = false;
        const finish = (tools, reason) => {
            if (done)
                return;
            done = true;
            clearTimeout(timer);
            try {
                child.kill('SIGKILL');
            }
            catch { /* child may already be gone */ }
            if (reason)
                logger?.(`[mcp-tools-probe] finish server=${name} tools=${tools.length} reason=${reason}`);
            resolve(tools);
        };
        const timer = setTimeout(() => finish([], 'timeout'), timeoutMs);
        child.on('error', (err) => {
            const msg = err instanceof Error ? err.message : String(err);
            finish([], `spawn-error err=${JSON.stringify(msg)}`);
        });
        // The child exiting before we receive tools/list is a clean "no tools"
        // signal — server crashed or refused the handshake.
        child.on('exit', () => finish([], 'child-exit-before-reply'));
        child.stdout.setEncoding('utf8');
        child.stdout.on('data', (chunk) => {
            stdoutBuf += chunk;
            // Newline-delimited JSON-RPC frames. Partial line stays in stdoutBuf
            // for the next chunk.
            let nl = stdoutBuf.indexOf('\n');
            while (nl >= 0) {
                const line = stdoutBuf.slice(0, nl).trim();
                stdoutBuf = stdoutBuf.slice(nl + 1);
                if (line.length > 0) {
                    let frame = null;
                    try {
                        frame = JSON.parse(line);
                    }
                    catch {
                        frame = null;
                    }
                    if (frame && frame.id === 2 && frame.result?.tools) {
                        const tools = frame.result.tools
                            .map((t) => (typeof t?.name === 'string' ? t.name : null))
                            .filter((n) => n !== null && n.length > 0);
                        finish(tools);
                        return;
                    }
                }
                nl = stdoutBuf.indexOf('\n');
            }
        });
        // Best-effort write — if the child died before we wrote, the error
        // handler above will resolve with []. We don't await drain; MCP
        // initialize + tools/list payloads are small.
        const safeWrite = (frame) => {
            try {
                child.stdin.write(JSON.stringify(frame) + '\n');
            }
            catch (err) {
                const msg = err instanceof Error ? err.message : String(err);
                logger?.(`[mcp-tools-probe] stdin-write-failed server=${name} err=${JSON.stringify(msg)}`);
            }
        };
        safeWrite({
            jsonrpc: '2.0',
            id: 1,
            method: 'initialize',
            params: {
                protocolVersion: PROBE_PROTOCOL_VERSION,
                capabilities: {},
                clientInfo: PROBE_CLIENT_INFO,
            },
        });
        safeWrite({ jsonrpc: '2.0', method: 'notifications/initialized' });
        safeWrite({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
    });
}
/** Probe every server in parallel. Returns a Map keyed by server name with
 *  insertion order matching the input. Missing/errored servers map to [].
 *  Never throws — probe failures are surfaced through empty arrays. */
export async function probeMcpServerTools(req, deps = {}) {
    const spawnImpl = deps.spawn ?? nodeSpawn;
    const entries = Array.from(req.servers.entries());
    const pairs = await Promise.all(entries.map(async ([name, desc]) => {
        const tools = await probeOne(name, desc, req.baseEnv, req.timeoutMs, spawnImpl, deps.logger);
        return [name, tools];
    }));
    const out = new Map();
    for (const [name, tools] of pairs)
        out.set(name, tools);
    return out;
}
//# sourceMappingURL=mcp-tools-probe.js.map