"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleMcpRequest = handleMcpRequest;
/**
 * MCP Streamable HTTP transport core — framework-agnostic.
 *
 * Takes a normalized request (method, headers, body, authenticated user,
 * profile) and returns a normalized response (status, headers, optional
 * JSON body or SSE iterable). The Fastify adapter (operations port) and
 * Harper-HTTP adapter (application port) each translate to/from this shape.
 *
 * All spec MUSTs from MCP §basic/transports (rev 2025-06-18) live here:
 *   - Origin validation
 *   - Mcp-Session-Id lifecycle
 *   - MCP-Protocol-Version enforcement
 *   - HTTP status code mapping (200/202/400/403/404/405)
 *   - JSON-RPC envelope parsing + standard error codes
 */
const env = __importStar(require("../../utility/environment/environmentManager.js"));
const hdbTerms_ts_1 = require("../../utility/hdbTerms.js");
const harper_logger_ts_1 = __importDefault(require("../../utility/logging/harper_logger.js"));
const jsonrpc_ts_1 = require("./jsonrpc.js");
const lifecycle_ts_1 = require("./lifecycle.js");
const audit_ts_1 = require("./audit.js");
const listChanged_ts_1 = require("./listChanged.js");
const rateLimit_ts_1 = require("./rateLimit.js");
const session_ts_1 = require("./session.js");
const resources_ts_1 = require("./resources.js");
const sessionRegistry_ts_1 = require("./sessionRegistry.js");
const toolRegistry_ts_1 = require("./toolRegistry.js");
const SESSION_HEADER = 'mcp-session-id';
const PROTOCOL_HEADER = 'mcp-protocol-version';
const ORIGIN_HEADER = 'origin';
/**
 * Main entry. Adapters call this and map the returned shape to their
 * native wire format. Any unhandled error from a downstream call (e.g., a
 * RocksDB I/O failure on the session table) is caught and reported as a
 * spec-conformant JSON-RPC Internal Error so clients see a structured
 * error envelope instead of an opaque framework 500.
 */
async function handleMcpRequest(request) {
    try {
        if (!isOriginAllowed(request)) {
            return jsonResponse(403, { error: 'origin_not_allowed' });
        }
        if (request.method === 'POST')
            return await handlePost(request);
        if (request.method === 'GET')
            return await handleGet(request);
        if (request.method === 'DELETE')
            return await handleDelete(request);
        return { status: 405, headers: { Allow: currentlyAllowedMethods() } };
    }
    catch (err) {
        harper_logger_ts_1.default.error(`MCP transport internal error: ${err.stack ?? err.message}`);
        return jsonRpcErrorResponse(500, null, jsonrpc_ts_1.ERROR_CODES.INTERNAL_ERROR, 'internal error');
    }
}
/**
 * Per-profile Origin validation. Mirrors Harper's existing auth.ts pattern
 * (security/auth.ts:65) for parity with how the operations + HTTP ports
 * already handle CORS:
 *   - CORS disabled in config ⇒ accept any Origin (don't gate on it at all).
 *   - CORS enabled with empty/unset allow-list ⇒ accept any.
 *   - CORS enabled with allow-list ⇒ match exactly OR honor a `'*'` wildcard.
 *   - Missing Origin header ⇒ accept (curl, server-to-server, no DNS-rebinding
 *     vector exists when the request isn't browser-initiated).
 */
function isOriginAllowed(request) {
    const origin = request.headers[ORIGIN_HEADER];
    if (!origin)
        return true;
    const corsEnabled = request.profile === 'operations'
        ? env.get(hdbTerms_ts_1.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_CORS)
        : env.get(hdbTerms_ts_1.CONFIG_PARAMS.HTTP_CORS);
    if (!corsEnabled)
        return true;
    const list = request.profile === 'operations'
        ? env.get(hdbTerms_ts_1.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_CORSACCESSLIST)
        : env.get(hdbTerms_ts_1.CONFIG_PARAMS.HTTP_CORSACCESSLIST);
    if (!Array.isArray(list) || list.length === 0)
        return true;
    return list.includes(origin) || list.includes('*');
}
async function handlePost(request) {
    const parsed = (0, jsonrpc_ts_1.parseMessage)(request.body);
    if (parsed.ok !== true) {
        // Explicit cast: tsconfig has `strict: false`, so TS can't narrow
        // discriminated unions from `parsed.ok !== true`. Without the cast it
        // errors TS2339 on `parsed.code` / `parsed.reason`. (Verified during
        // review; the alternative — enabling strict mode — is repo-wide.)
        const fail = parsed;
        return jsonRpcErrorResponse(400, null, fail.code, fail.reason);
    }
    const message = parsed.message;
    const messageId = 'id' in message ? message.id : null;
    const method = 'method' in message ? message.method : undefined;
    if (method === 'initialize')
        return dispatchInitialize(request, message, messageId);
    const sessionId = request.headers[SESSION_HEADER];
    if (!sessionId) {
        return jsonRpcErrorResponse(400, messageId, jsonrpc_ts_1.ERROR_CODES.INVALID_REQUEST, 'missing Mcp-Session-Id header');
    }
    const session = await (0, session_ts_1.loadSession)(sessionId);
    if (!session) {
        // Terminated, expired, or never existed. Spec mandates 404 so the
        // client knows to drop the id and re-initialize.
        return { status: 404, headers: {} };
    }
    const protocolCheck = validateProtocolHeader(request.headers[PROTOCOL_HEADER], session.protocolVersion);
    if (protocolCheck.ok !== true) {
        const fail = protocolCheck;
        return jsonRpcErrorResponse(400, messageId, jsonrpc_ts_1.ERROR_CODES.INVALID_REQUEST, fail.reason);
    }
    if (session.user !== request.user) {
        // Defense-in-depth against session-id leaks: id was created for a
        // different user. Mask the session id in logs — even though it's not
        // a credential in MCP, leaking it to an attacker with log access would
        // enable session-jacking once paired with a valid token.
        harper_logger_ts_1.default.warn(`MCP session ${maskSessionId(sessionId)} presented by user ${request.user} but bound to ${session.user}`);
        return { status: 403, headers: {} };
    }
    // Sliding-window idle reset. Awaited (not fire-and-forget) so a concurrent
    // DELETE that arrives mid-request can't be resurrected by a late put.
    await (0, session_ts_1.touchSession)(session);
    // Fire-and-forget frames (notifications + client responses) always 202.
    if ((0, jsonrpc_ts_1.isClientFireAndForget)(message)) {
        if (method === 'notifications/initialized') {
            await (0, lifecycle_ts_1.handleInitialized)(session);
        }
        return { status: 202, headers: {} };
    }
    // Request with id + method (and not 'initialize'): route to the known
    // method handlers below. Anything we don't handle yet returns a
    // spec-conformant Method-not-found error.
    if (method === 'tools/list')
        return dispatchToolsList(request, session, message, messageId);
    if (method === 'tools/call')
        return dispatchToolsCall(request, session, message, messageId);
    if (method === 'resources/list')
        return dispatchResourcesList(request, message, messageId);
    if (method === 'resources/templates/list')
        return dispatchResourceTemplatesList(request, messageId);
    if (method === 'resources/read')
        return dispatchResourcesRead(request, message, messageId);
    return jsonResponse(200, (0, jsonrpc_ts_1.buildError)(messageId, jsonrpc_ts_1.ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${method ?? '<missing>'}`));
}
async function dispatchInitialize(request, message, messageId) {
    const params = 'params' in message ? message.params : undefined;
    const outcome = await (0, lifecycle_ts_1.handleInitialize)(params, request.user);
    if (outcome.ok !== true) {
        const fail = outcome;
        return jsonRpcErrorResponse(400, messageId, jsonrpc_ts_1.ERROR_CODES.INVALID_PARAMS, fail.reason, {
            supportedVersions: fail.supportedVersions,
        });
    }
    return {
        status: 200,
        headers: { 'Mcp-Session-Id': outcome.session.id },
        jsonBody: (0, jsonrpc_ts_1.buildSuccess)(messageId, outcome.result),
    };
}
async function handleGet(request) {
    // Server-push channel for `notifications/{tools,resources}/list_changed`
    // (#619). The client opens GET /mcp after `initialize`; we keep the SSE
    // connection alive for the lifetime of the session and push frames as
    // role/schema events fire. Per the spec, idle sessions get HTTP 404 on
    // the next POST after eviction — the GET stream itself just closes when
    // the session record is deleted (via unregisterSession).
    const sessionId = request.headers[SESSION_HEADER];
    if (!sessionId) {
        return { status: 400, headers: {} };
    }
    const session = await (0, session_ts_1.loadSession)(sessionId);
    if (!session) {
        return { status: 404, headers: {} };
    }
    if (session.user !== request.user) {
        return { status: 403, headers: {} };
    }
    const protocolCheck = validateProtocolHeader(request.headers[PROTOCOL_HEADER], session.protocolVersion);
    if (protocolCheck.ok !== true) {
        const fail = protocolCheck;
        return {
            status: 400,
            headers: {},
            jsonBody: { error: { message: fail.reason } },
        };
    }
    const record = (0, sessionRegistry_ts_1.registerSession)(sessionId, request.profile, effectiveUser(request));
    (0, listChanged_ts_1.seedSessionSnapshot)(sessionId);
    return {
        status: 200,
        headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' },
        sseIterable: record.queue,
    };
}
const DEFAULT_MAX_TOOLS = 200;
const DEFAULT_RESOURCE_PAGE_LIMIT = 200;
function profileMaxTools(profile) {
    const key = profile === 'operations' ? hdbTerms_ts_1.CONFIG_PARAMS.MCP_OPERATIONS_MAXTOOLS : hdbTerms_ts_1.CONFIG_PARAMS.MCP_APPLICATION_MAXTOOLS;
    const value = env.get(key);
    return typeof value === 'number' && value > 0 ? value : DEFAULT_MAX_TOOLS;
}
function effectiveUser(request) {
    return request.userObject ?? { username: request.user };
}
function dispatchToolsList(request, session, message, messageId) {
    const params = 'params' in message ? message.params : undefined;
    const cursor = typeof params?.cursor === 'string' ? params.cursor : undefined;
    const limit = profileMaxTools(request.profile);
    const { tools, nextCursor } = (0, toolRegistry_ts_1.listTools)({
        user: effectiveUser(request),
        profile: request.profile,
        sessionId: session.id,
        cursor,
        limit,
    });
    const result = { tools };
    if (nextCursor)
        result.nextCursor = nextCursor;
    return jsonResponse(200, (0, jsonrpc_ts_1.buildSuccess)(messageId, result));
}
async function dispatchToolsCall(request, session, message, messageId) {
    const params = 'params' in message ? message.params : undefined;
    const name = typeof params?.name === 'string' ? params.name : undefined;
    if (!name) {
        return jsonResponse(200, (0, jsonrpc_ts_1.buildError)(messageId, jsonrpc_ts_1.ERROR_CODES.INVALID_PARAMS, 'tools/call requires params.name'));
    }
    const tool = (0, toolRegistry_ts_1.getTool)(name);
    if (!tool || tool.profile !== request.profile) {
        return jsonResponse(200, (0, jsonrpc_ts_1.buildError)(messageId, jsonrpc_ts_1.ERROR_CODES.METHOD_NOT_FOUND, `Unknown tool: ${name}`));
    }
    const args = params?.arguments ?? {};
    const callStartedAt = Date.now();
    const user = effectiveUser(request);
    // Keep the SSE registry's idle-prune from sweeping this session: tools/call
    // activity counts as "alive" even if the GET stream is dormant between
    // listChanged events.
    (0, sessionRegistry_ts_1.touchRegisteredSession)(session.id);
    // Rate limit check — admit-or-deny BEFORE invoking the handler. Failures
    // surface as `isError: true` with `kind: 'rate_limited'` (NOT a JSON-RPC
    // error) so the LLM sees and can back off / try later.
    const decision = (0, rateLimit_ts_1.tryAdmit)(session.id, name, request.profile);
    if (!decision.allowed) {
        // Non-strict tsconfig doesn't narrow the discriminated union here.
        const denied = decision;
        const toolResult = {
            isError: true,
            content: [
                {
                    type: 'text',
                    text: JSON.stringify({
                        kind: 'rate_limited',
                        scope: denied.reason,
                        tool: name,
                        message: `MCP ${denied.reason} rate limit reached; back off and try again`,
                    }),
                },
            ],
        };
        (0, audit_ts_1.emitAuditEntry)({
            timestamp: new Date(callStartedAt).toISOString(),
            profile: request.profile,
            sessionId: session.id,
            tool: name,
            user: user.username ?? request.user,
            args: args,
            status: 'rate_limited',
            durationMs: 0,
        });
        return jsonResponse(200, (0, jsonrpc_ts_1.buildSuccess)(messageId, toolResult));
    }
    let toolResult;
    let status = 'ok';
    let errorMessage;
    try {
        toolResult = await tool.handler(args, {
            user,
            profile: request.profile,
            sessionId: session.id,
        });
        if (toolResult?.isError)
            status = 'isError';
    }
    catch (err) {
        // Per MCP §server/tools → Error Handling: tool-execution errors come
        // back as a successful JSON-RPC result with isError:true so the LLM
        // can see and adapt. Stack traces stay in the server log; only the
        // message goes to the wire (Harper-style hygiene).
        const errMsg = err.message ?? 'tool execution failed';
        harper_logger_ts_1.default.warn(`MCP tools/call ${name} threw: ${err.stack ?? errMsg}`);
        errorMessage = errMsg;
        status = 'isError';
        toolResult = {
            isError: true,
            content: [
                {
                    type: 'text',
                    text: JSON.stringify({ kind: 'harper_error', message: errMsg }),
                },
            ],
        };
    }
    finally {
        decision.release();
    }
    (0, audit_ts_1.emitAuditEntry)({
        timestamp: new Date(callStartedAt).toISOString(),
        profile: request.profile,
        sessionId: session.id,
        tool: name,
        user: user.username ?? request.user,
        args: args,
        status,
        durationMs: Date.now() - callStartedAt,
        ...(errorMessage ? { errorMessage } : {}),
    });
    return jsonResponse(200, (0, jsonrpc_ts_1.buildSuccess)(messageId, toolResult));
}
function dispatchResourcesList(request, message, messageId) {
    const params = 'params' in message ? message.params : undefined;
    const cursor = typeof params?.cursor === 'string' ? params.cursor : undefined;
    const result = (0, resources_ts_1.listResources)({
        user: effectiveUser(request),
        profile: request.profile,
        cursor,
        limit: DEFAULT_RESOURCE_PAGE_LIMIT,
    });
    const body = { resources: result.resources };
    if (result.nextCursor)
        body.nextCursor = result.nextCursor;
    return jsonResponse(200, (0, jsonrpc_ts_1.buildSuccess)(messageId, body));
}
function dispatchResourceTemplatesList(request, messageId) {
    const templates = (0, resources_ts_1.listResourceTemplates)(request.profile);
    return jsonResponse(200, (0, jsonrpc_ts_1.buildSuccess)(messageId, { resourceTemplates: templates }));
}
async function dispatchResourcesRead(request, message, messageId) {
    const params = 'params' in message ? message.params : undefined;
    const uri = typeof params?.uri === 'string' ? params.uri : undefined;
    if (!uri) {
        return jsonResponse(200, (0, jsonrpc_ts_1.buildError)(messageId, jsonrpc_ts_1.ERROR_CODES.INVALID_PARAMS, 'resources/read requires params.uri'));
    }
    const outcome = await (0, resources_ts_1.readResource)({ uri, user: effectiveUser(request), profile: request.profile });
    if (outcome.ok !== true) {
        const fail = outcome;
        // Per MCP §server/resources, "resource not found" and access-control
        // failures are protocol errors, not isError tool results — resources/*
        // returns JSON-RPC errors rather than success-with-isError. We pick
        // -32602 (Invalid params) for bad inputs and -32601 (Method not found)
        // for unknown resources, matching the spec's mapping for analogous
        // situations and the precedent already in place for tools/call.
        const code = /not found|no resource matches/.test(fail.reason)
            ? jsonrpc_ts_1.ERROR_CODES.METHOD_NOT_FOUND
            : jsonrpc_ts_1.ERROR_CODES.INVALID_PARAMS;
        return jsonResponse(200, (0, jsonrpc_ts_1.buildError)(messageId, code, fail.reason));
    }
    return jsonResponse(200, (0, jsonrpc_ts_1.buildSuccess)(messageId, { contents: outcome.contents }));
}
async function handleDelete(request) {
    const allow = env.get(hdbTerms_ts_1.CONFIG_PARAMS.MCP_SESSION_ALLOWCLIENTDELETE);
    if (allow !== true) {
        return { status: 405, headers: { Allow: currentlyAllowedMethods() } };
    }
    const sessionId = request.headers[SESSION_HEADER];
    if (!sessionId) {
        return { status: 400, headers: {} };
    }
    const session = await (0, session_ts_1.loadSession)(sessionId);
    if (!session) {
        return { status: 404, headers: {} };
    }
    if (session.user !== request.user) {
        return { status: 403, headers: {} };
    }
    await (0, session_ts_1.deleteSession)(sessionId);
    return { status: 204, headers: {} };
}
function validateProtocolHeader(headerValue, sessionVersion) {
    // Per spec compatibility rule: missing header is treated as 2025-03-26.
    const effective = headerValue ?? lifecycle_ts_1.PROTOCOL_VERSION_BACKCOMPAT;
    if (!lifecycle_ts_1.SUPPORTED_PROTOCOL_VERSIONS.includes(effective)) {
        return { ok: false, reason: `unsupported MCP-Protocol-Version: ${effective}` };
    }
    if (effective !== sessionVersion) {
        return {
            ok: false,
            reason: `MCP-Protocol-Version mismatch: session negotiated ${sessionVersion}, request sent ${effective}`,
        };
    }
    return { ok: true };
}
function jsonResponse(status, body) {
    return {
        status,
        headers: { 'Content-Type': 'application/json' },
        jsonBody: body,
    };
}
function jsonRpcErrorResponse(status, id, code, message, data) {
    return {
        status,
        headers: { 'Content-Type': 'application/json' },
        jsonBody: (0, jsonrpc_ts_1.buildError)(id, code, message, data),
    };
}
/**
 * Mask an MCP session id for log output. Sessions aren't credentials in the
 * strict sense, but a leaked id paired with a stolen token enables session
 * jacking, so we don't print the full value in routine logs.
 */
function maskSessionId(id) {
    if (id.length <= 8)
        return '***';
    return `${id.slice(0, 8)}…`;
}
/**
 * Build the `Allow` header value for a 405 response. Per RFC 9110 §9.1 the
 * server MUST list only currently-supported methods. In v1, POST is always
 * supported; GET opens the server-push SSE channel for
 * `notifications/{tools,resources}/list_changed`; DELETE is conditional on
 * `mcp.session.allowClientDelete`.
 */
function currentlyAllowedMethods() {
    const allow = ['POST', 'GET'];
    if (env.get(hdbTerms_ts_1.CONFIG_PARAMS.MCP_SESSION_ALLOWCLIENTDELETE) === true)
        allow.push('DELETE');
    return allow.join(', ');
}
//# sourceMappingURL=transport.js.map