/**
 * MCP identity proxy: stdio upstream, HTTP downstream.
 *
 * Bridges stdio-only harnesses (Cursor, Claude Code, Codex) to Neotoma's
 * HTTP `/mcp` endpoint with clientInfo injection, optional AAuth signing,
 * Mcp-Session-Id relay, and SSE fan-out.
 *
 * TypeScript port of the battle-tested Python proxy from ateles. Reuses
 * Neotoma's existing deps (`@modelcontextprotocol/sdk`, `jose`,
 * `@hellocoop/httpsig`) so no new runtime dependencies are needed.
 */
import { createInterface } from "node:readline";
import { signedFetch } from "./aauth_client_signer.js";
import { runPreflight } from "./preflight.js";
export const DEFAULT_CLIENT_NAME = "neotoma-mcp-proxy";
export const DEFAULT_DOWNSTREAM_URL = "http://localhost:3080/mcp";
const SESSION_HEADER = "mcp-session-id";
function effectiveClientName(config) {
    if (config.agentLabel)
        return `${config.clientName}+${config.agentLabel}`;
    return config.clientName;
}
function log(msg) {
    const ts = new Date().toISOString();
    process.stderr.write(`${ts} [neotoma-mcp-proxy] ${msg}\n`);
}
/** Include `error.cause` (e.g. ECONNREFUSED) — `fetch failed` alone is opaque in logs. */
function describeNetworkError(err) {
    if (!(err instanceof Error))
        return String(err);
    const parts = [err.message];
    const { cause } = err;
    if (cause instanceof Error) {
        parts.push(`cause=${cause.message}`);
    }
    else if (cause && typeof cause === "object" && "code" in cause) {
        parts.push(`cause.code=${String(cause.code)}`);
    }
    else if (cause !== undefined) {
        parts.push(`cause=${String(cause)}`);
    }
    return parts.join(" ");
}
function buildBaseHeaders(config) {
    const headers = {
        "Content-Type": "application/json",
        Accept: "application/json, text/event-stream",
        "User-Agent": `${effectiveClientName(config)}/${config.clientVersion}`,
    };
    if (config.bearerToken) {
        headers["Authorization"] = `Bearer ${config.bearerToken}`;
    }
    if (config.connectionId) {
        headers["X-Connection-Id"] = config.connectionId;
    }
    if (config.aauthSigner) {
        headers["X-Agent-Label"] = config.aauthSigner.sub;
    }
    Object.assign(headers, config.extraHeaders);
    return headers;
}
function injectClientInfo(message, config) {
    if (message.method !== "initialize")
        return;
    const params = (message.params ?? {});
    message.params = params;
    const clientInfo = (params.clientInfo ?? {});
    params.clientInfo = clientInfo;
    const existingName = clientInfo.name;
    if (!existingName || typeof existingName !== "string" || !existingName.trim()) {
        clientInfo.name = effectiveClientName(config);
    }
    if (!clientInfo.version) {
        clientInfo.version = config.clientVersion;
    }
    log(`initialize clientInfo injected: name=${String(clientInfo.name)} version=${String(clientInfo.version)}`);
}
class SessionState {
    sessionId = null;
    attach(headers) {
        if (this.sessionId) {
            headers[SESSION_HEADER] = this.sessionId;
        }
    }
    capture(responseHeaders) {
        const value = responseHeaders.get(SESSION_HEADER);
        if (value)
            this.sessionId = value;
    }
    /** Drop stale session id before re-running initialize against downstream. */
    clearSession() {
        this.sessionId = null;
    }
}
/** Exported for unit tests — matches `src/actions.ts` Streamable HTTP 503 copy. */
export function isRecoverableMcpSessionLostError(status, bodyText) {
    if (status !== 503)
        return false;
    const t = bodyText.toLowerCase();
    return (t.includes("mcp session is unknown") || t.includes("session is unknown on this api instance"));
}
async function sendDownstream(config, headers, body) {
    if (config.aauthSigner) {
        try {
            return await signedFetch(config.downstreamUrl, {
                method: "POST",
                headers,
                body,
                config: config.aauthSigner,
            });
        }
        catch (signErr) {
            log(`AAuth signing failed: ${describeNetworkError(signErr)}`);
            if (config.failClosed) {
                process.stderr.write(`[neotoma-mcp-proxy] fail-closed: AAuth signing error\n`);
                process.exit(1);
            }
            return await fetch(config.downstreamUrl, {
                method: "POST",
                headers,
                body,
            });
        }
    }
    return await fetch(config.downstreamUrl, {
        method: "POST",
        headers,
        body,
    });
}
function emitJson(payload) {
    const line = JSON.stringify(payload);
    process.stdout.write(line + "\n");
}
export function formatDownstreamErrorMessage(status, detail) {
    const trimmed = detail.replace(/\s+/g, " ").trim();
    if (!trimmed)
        return `neotoma-mcp-proxy downstream error (${status})`;
    return `neotoma-mcp-proxy downstream error (${status}): ${trimmed.slice(0, 200)}`;
}
function emitErrorResponse(originalMessage, status, detail) {
    const requestId = originalMessage.id;
    if (requestId === undefined)
        return;
    emitJson({
        jsonrpc: "2.0",
        id: requestId,
        error: {
            code: status < 500 ? -32000 : -32001,
            message: formatDownstreamErrorMessage(status, detail),
            data: { detail: detail.slice(0, 500) },
        },
    });
}
async function forwardJsonResponse(response) {
    const body = await response.text();
    if (!body)
        return;
    try {
        const payload = JSON.parse(body);
        emitJson(payload);
    }
    catch (err) {
        log(`Failed to decode JSON response: ${String(err)}`);
    }
}
async function forwardSseResponse(response) {
    const reader = response.body?.getReader();
    if (!reader)
        return;
    const decoder = new TextDecoder();
    let currentEvent = "message";
    let buffer = "";
    try {
        for (;;) {
            const { done, value } = await reader.read();
            if (done)
                break;
            buffer += decoder.decode(value, { stream: true });
            const lines = buffer.split("\n");
            buffer = lines.pop() ?? "";
            for (const rawLine of lines) {
                const line = rawLine.replace(/\r$/, "");
                if (line === "") {
                    currentEvent = "message";
                    continue;
                }
                if (line.startsWith(":"))
                    continue;
                if (line.startsWith("event:")) {
                    currentEvent = line.slice(6).trim();
                    continue;
                }
                if (line.startsWith("data:")) {
                    const data = line.slice(5).trim();
                    if (currentEvent !== "message")
                        continue;
                    try {
                        const payload = JSON.parse(data);
                        emitJson(payload);
                    }
                    catch {
                        log(`SSE data frame is not JSON: ${data.slice(0, 200)}`);
                    }
                }
            }
        }
    }
    finally {
        reader.releaseLock();
    }
}
async function dispatchMessage(loopState, config, message) {
    injectClientInfo(message, config);
    const body = JSON.stringify(message);
    if (message.method === "initialize") {
        loopState.lastInitializeBody = body;
    }
    const postOriginal = async () => {
        const headers = buildBaseHeaders(config);
        loopState.session.attach(headers);
        return sendDownstream(config, headers, body);
    };
    try {
        let resp = await postOriginal();
        loopState.session.capture(resp.headers);
        if (resp.status >= 400) {
            const errText = await resp.text();
            const method = message.method;
            if (isRecoverableMcpSessionLostError(resp.status, errText) &&
                method !== "initialize" &&
                loopState.lastInitializeBody) {
                log("Downstream MCP session unknown on this instance — replaying initialize to downstream, then retrying this JSON-RPC message once (client stdout unchanged)");
                loopState.session.clearSession();
                const reinitHeaders = buildBaseHeaders(config);
                const reinitResp = await sendDownstream(config, reinitHeaders, loopState.lastInitializeBody);
                loopState.session.capture(reinitResp.headers);
                const reinitBodyText = await reinitResp.text();
                if (reinitResp.status >= 400) {
                    log(`Re-initialize after session loss failed: status=${reinitResp.status} body=${reinitBodyText.slice(0, 500)}`);
                    emitErrorResponse(message, reinitResp.status, reinitBodyText);
                    return;
                }
                resp = await postOriginal();
                loopState.session.capture(resp.headers);
                if (resp.status >= 400) {
                    const retryErr = await resp.text();
                    log(`Downstream error after session recovery: status=${resp.status} body=${retryErr.slice(0, 500)}`);
                    emitErrorResponse(message, resp.status, retryErr);
                    return;
                }
            }
            else {
                log(`Downstream error status=${resp.status} content_type=${resp.headers.get("content-type") ?? ""} body=${errText.slice(0, 500)}`);
                emitErrorResponse(message, resp.status, errText);
                return;
            }
        }
        const contentType = resp.headers.get("content-type") ?? "";
        if (contentType.includes("text/event-stream")) {
            await forwardSseResponse(resp);
        }
        else {
            await forwardJsonResponse(resp);
        }
    }
    catch (err) {
        log(`Downstream transport error: ${describeNetworkError(err)}`);
        emitErrorResponse(message, 502, describeNetworkError(err));
    }
}
export async function runProxy(config) {
    log(`Starting proxy: downstream=${config.downstreamUrl} client_name=${effectiveClientName(config)} version=${config.clientVersion} preflight=${config.sessionPreflight} fail_closed=${config.failClosed}`);
    if (config.sessionPreflight) {
        await runPreflight(config);
    }
    const loopState = {
        session: new SessionState(),
        lastInitializeBody: null,
    };
    const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
    for await (const rawLine of rl) {
        const line = rawLine.trim();
        if (!line)
            continue;
        let message;
        try {
            message = JSON.parse(line);
        }
        catch {
            log(`Dropping non-JSON stdin line: ${line.slice(0, 200)}`);
            continue;
        }
        if (typeof message !== "object" || message === null || Array.isArray(message)) {
            log(`Dropping non-object JSON-RPC message: ${line.slice(0, 200)}`);
            continue;
        }
        await dispatchMessage(loopState, config, message);
    }
    log("stdin closed; exiting proxy loop");
}
//# sourceMappingURL=mcp_stdio_proxy.js.map