/**
 * # mcp/server — the MCP face as a LOSSLESS projection of the djm.5 SDK (kestrel-djm.7)
 *
 * `createKestrelMcpServer({ transport })` builds `createSdk(transport)` INTERNALLY (pass `localTransport()`
 * for in-process execution or `remoteTransport({ baseUrl, fetch })` for the managed backend) and exposes it
 * as an MCP server: a JSON-RPC 2.0 dispatcher over RESOURCES (the static djm.8 catalog + the immutable,
 * content-addressed artifacts) and TOOLS (validate, the incremental Session lifecycle, Grade, and Operation
 * continuation). It holds NO runtime semantics of its own — every method marshals an MCP request into an SDK
 * call and marshals the SDK's djm.2 protocol object back out VERBATIM. It is one of ADR-0004's four equal
 * faces (http / sdk / cli / mcp); it adds no Kestrel statement kind.
 *
 * PROJECTION-ONLY: for every RUNTIME semantic the only Kestrel import is the ONE SDK barrel (`../sdk/index.ts`
 * — `createSdk` + `SdkLocalError` + the transport-neutral types). The face reaches NO runtime-semantics module
 * (session/engine/fill/blotter/bus/lang, or the djm.8 loader) directly — those live behind the SDK's
 * transport. Even catalog discovery goes through `sdk.catalog()`, not the loader (grep-provable, pinned by
 * tests/mcp.projection.test.ts).
 *
 * ONE DOCUMENTED EXCEPTION — the LOCAL operator-secret store (kestrel-jh9w.3): `kestrel.secrets.set/unset/list`
 * manage `~/.kestrel/.env` directly through `../cli/credentials.ts`, NOT through the SDK transport, because the
 * secret store is a local-process operator concern (the same one the `kestrel secrets` CLI verb owns), not a
 * runtime statement kind and not something that crosses a transport. It is NOT a runtime-semantics module (it is
 * `/cli/`, outside the projection test's forbidden set) and adds no fifth Kestrel statement kind. The invariant
 * these tools uphold is the store's own: NO face ever returns a secret VALUE — `list` is names-only, there is no
 * `get`, and the `set` result names only the key + path. The jh9w.5.1 refusals (the *LIVE* paper-only refusal +
 * the [A-Z_][A-Z0-9_]* key-name format) are single-sourced in `checkOperatorSecretKey`, so they fire IDENTICALLY
 * on this MCP path and the CLI path — a guard wired to only one face is the wiring-evasion shape this forbids.
 *
 * LOSSLESS/CANONICAL: a `tools/call` returns the SDK's protocol object VERBATIM in `structuredContent`
 * (exact Kestrel bytes in the pentad `body.bytes`, the TYPED SessionFrame struct in a `Delivery.frame` —
 * `frameRoot` binds its canonical JSON; a Rendering of it is the caller's, via `src/frame` — the protocol
 * Blotter, the portable GradeResult, the conformance root, the artifact roots, the OperationResumption). A
 * resource READ returns the djm.2 object VERBATIM as JSON. Nothing is summarized / repaired / rewritten.
 *
 * CONTINUATION AS DATA: a live `KestrelSession` cannot cross the wire, so `kestrel.session.open` projects the
 * SDK's `Gated<KestrelSession>` to `{ gated:false, sessionId }` — the deterministic SessionId is the durable
 * handle every later verb carries; the server holds the live session keyed by it — or, when gated, to
 * `{ gated:true, payment }`: the 402/Offer as BYTE-IDENTICAL structured continuation DATA (never a rewrite,
 * never a JSON-RPC error).
 *
 * FAILURE TAXONOMY (fail-closed, distinguishable): a TRANSPORT failure (malformed frame / bad request /
 * invalid params) is a JSON-RPC `error`; a typed KESTREL REFUSAL (an `SdkLocalError` / `KestrelClientError`,
 * detected by its string `code`) is a tool RESULT with `isError:true` + `structuredContent.refusal.code`; a
 * STAND_DOWN is a normal `SessionResponse` tool RESULT (`ok:true`, disposition `stood-down`). Three distinct
 * shapes the caller can switch on — never conflated. An unexpected fault is a JSON-RPC internal error, kept
 * distinct from a domain refusal.
 */

import {
  AUTHORED_RESPONSE_SHAPE,
  BOUND_RESPONSE_SHAPE,
  createSdk,
  EVENT_CURSOR_SHAPE,
  SdkLocalError,
  SDK_VERSION,
  SUBJECT_SHAPE,
  type AuthoredResponse,
  type BoundResponse,
  type EventCursor,
  type KestrelSdk,
  type KestrelSession,
  type SessionRef,
  type Transport,
} from "../sdk/index.ts";

import {
  checkOperatorSecretKey,
  defaultSecretsPath,
  deleteSecret,
  saveSecret,
  secretNames,
} from "../cli/credentials.ts";

import {
  ARTIFACT_URI_PREFIX,
  CATALOG_URI_PREFIX,
  PRICING_URI,
  artifactUri,
  INTERNAL_ERROR,
  INVALID_PARAMS,
  INVALID_REQUEST,
  McpProtocolError,
  METHOD_NOT_FOUND,
  PARSE_ERROR,
  TOOL,
  type ContentBlock,
  type JsonRpcId,
  type JsonRpcRequest,
  type JsonRpcResponse,
  type Resource,
  type ResourceContents,
  type ToolDescriptor,
  type ToolResult,
} from "./protocol.ts";

/* ─────────────────────────────── public surface ───────────────────────────── */

/** How to build the MCP face: the ONE transport CHOICE it projects (`localTransport()` for in-process
 *  execution, `remoteTransport({ baseUrl, fetch })` for the managed backend). No other knob. */
export interface KestrelMcpServerOptions {
  readonly transport: Transport;
}

/**
 * The MCP server as an in-process JSON-RPC 2.0 dispatcher. `handle(request)` answers a PARSED request;
 * `handleFrame(rawLine)` answers a RAW stdio frame (a malformed frame fails closed as a JSON-RPC parse
 * error). The stdio pipe wiring ({@link ../mcp/stdio.ts serveStdio}) is a thin adapter over this dispatcher.
 */
export interface KestrelMcpServer {
  /**
   * Answer a parsed JSON-RPC 2.0 message. Never throws. A REQUEST (an `id` member present) always yields a
   * {@link JsonRpcResponse}; a NOTIFICATION (no `id` member — JSON-RPC 2.0 §4.1) yields `undefined`, because
   * both JSON-RPC and MCP FORBID responding to one. Every real MCP client sends `notifications/initialized`
   * as the mandatory third handshake step — a server that answers it puts an uncorrelatable `id:null` frame
   * on the wire and trips 'unknown message ID' faults client-side.
   */
  handle(request: JsonRpcRequest): Promise<JsonRpcResponse | undefined>;
  /** Answer a raw stdio frame: parse it, then {@link handle} it. A malformed frame → JSON-RPC parse error;
   *  a well-formed NOTIFICATION frame → `undefined` (nothing may be written to the wire for it). */
  handleFrame(rawLine: string): Promise<JsonRpcResponse | undefined>;
}

/** Build the MCP face over a transport CHOICE. Constructs `createSdk(transport)` internally; holds no runtime
 *  semantics of its own. */
export function createKestrelMcpServer(options: KestrelMcpServerOptions): KestrelMcpServer {
  return new KestrelMcpServerImpl(options.transport);
}

/* ─────────────────────────── the tool catalogue (tools/list) ───────────────── */

const sessionIdSchema = { type: "string", description: "the durable SessionId returned by kestrel.session.open" } as const;
// Argument SHAPE descriptions are single-sourced from the agent op-schema catalogue (src/sdk/op-schema.ts,
// kestrel-yuw4) so the MCP face and the `kestrel agent describe` face can never advertise divergent shapes.
const authoredSchema = {
  type: "object",
  description: `an AuthoredResponse — ${AUTHORED_RESPONSE_SHAPE}`,
} as const;

function tool(name: string, description: string, properties: Record<string, unknown>, required: readonly string[]): ToolDescriptor {
  return { name, description, inputSchema: { type: "object", properties, required, additionalProperties: false } };
}

/** One tool per SDK verb — the COMPLETE lifecycle available through the SDK / HTTP client. */
const TOOL_DESCRIPTORS: readonly ToolDescriptor[] = [
  tool(TOOL.validate, "Parse + validate a Kestrel document; returns the { ok, diagnostics } ValidateOutcome verbatim.", {
    document: { type: "string", description: "the Kestrel document source" },
  }, ["document"]),
  tool(TOOL.open, "Open an incremental Session over a subject; returns { gated:false, sessionId } or the 402/Offer as { gated:true, payment }.", {
    subject: { type: "object", description: `a SessionRef — ${SUBJECT_SHAPE}` },
  }, ["subject"]),
  tool(TOOL.start, "Deliver the date-blind OPEN Frame for a live Session; returns the djm.4 Delivery verbatim — its `frame` is the TYPED SessionFrame struct (not rendered text; `frameRoot` = sha256 of its canonical JSON).", {
    sessionId: sessionIdSchema,
  }, ["sessionId"]),
  tool(TOOL.advance, "Author-or-stand-down at the pending slot; returns the djm.4 SessionResponse verbatim (receipt + next Delivery carrying the typed SessionFrame struct, or a typed refusal).", {
    sessionId: sessionIdSchema,
    response: authoredSchema,
  }, ["sessionId", "response"]),
  tool(TOOL.revise, "Author a superseding document at the current slot; returns the SessionResponse verbatim.", {
    sessionId: sessionIdSchema,
    response: authoredSchema,
  }, ["sessionId", "response"]),
  tool(TOOL.submit, "Explicit-binding turn: reconcile a fully-bound BoundResponse against the recorded slot; returns the SessionResponse verbatim.", {
    sessionId: sessionIdSchema,
    response: { type: "object", description: `a BoundResponse — ${BOUND_RESPONSE_SHAPE}` },
  }, ["sessionId", "response"]),
  tool(TOOL.resume, "Re-derive the committed pentad-chained transcript; returns the SessionTranscript verbatim.", {
    sessionId: sessionIdSchema,
    after: { type: "object", description: `optional resume cursor — an EventCursor ${EVENT_CURSOR_SHAPE}` },
  }, ["sessionId"]),
  tool(TOOL.finalize, "Seal the chain; returns { blotter, sessionId, tipHash, conformanceRoot, artifacts } verbatim.", {
    sessionId: sessionIdSchema,
  }, ["sessionId"]),
  tool(TOOL.grade, "Grade one or more finalized Blotter artifacts; returns the Gated<GradeOutcome> verbatim (may gate as DATA).", {
    blotters: { type: "array", items: { type: "string" }, description: "Blotter artifact ids (a Blotter's sessionId is its id)" },
  }, ["blotters"]),
  tool(TOOL.operationResume, "Resume a durable Operation from its cursor; returns the OperationResumption { cursor, payload } verbatim.", {
    operationId: { type: "string", description: "the durable Operation id" },
    after: { type: "object", description: `optional resume cursor — an EventCursor ${EVENT_CURSOR_SHAPE}` },
  }, ["operationId"]),
  // ── The LOCAL operator-secret store (kestrel-jh9w.3): self-install BYOK keys once into ~/.kestrel/.env so
  // they persist across context windows / worktrees. NO tool ever returns a secret value (there is no `get`).
  tool(TOOL.secretsSet, "Store one operator/BYOK secret in the local ~/.kestrel/.env store (owner-only 0600), keyed by a canonical uppercase env name. The VALUE is never echoed back — the result names only { key, path, stored:true }. Refuses a *LIVE* key (paper store is paper-only) and a non-[A-Z_][A-Z0-9_]* name.", {
    key: { type: "string", description: "the canonical UPPERCASE env-var name, e.g. DATABENTO_API_KEY" },
    value: { type: "string", description: "the secret value to store — never returned by any face; only internal resolution reads it" },
  }, ["key", "value"]),
  tool(TOOL.secretsUnset, "Remove one operator secret from the local ~/.kestrel/.env store; returns { key, path, removed:true }. An absent key is a typed NOT_FOUND refusal (never a silent success).", {
    key: { type: "string", description: "the secret name to remove" },
  }, ["key"]),
  tool(TOOL.secretsList, "List the NAMES held in the local ~/.kestrel/.env store, sorted; returns { path, names }. Names only — no code path on this tool ever holds a value to render.", {}, []),
];

/* ─────────────────────────────── the dispatcher ────────────────────────────── */

class KestrelMcpServerImpl implements KestrelMcpServer {
  private readonly sdk: KestrelSdk;
  /** Live Sessions keyed by their deterministic SessionId (the durable handle every later verb carries). A
   *  live KestrelSession cannot cross the wire, so the face holds it and the caller carries only the id. */
  private readonly sessions = new Map<string, KestrelSession>();
  /**
   * The content-addressed artifact refs this face has PRODUCED — collected from every `kestrel.session.finalize`
   * it drives (kestrel-djm.18). `resources/list` enumerates these as `kestrel://artifact/…` so an MCP client can
   * DISCOVER the finalized artifacts (the same Blotter / conformance roots the other three faces expose), not
   * only reach them by a ref it already holds. CAPABILITY HONESTY: a finalize registers its artifacts in the
   * bound transport, so every ref here is guaranteed servable by `resources/read` (`sdk.artifact(ref)`) — the
   * face declares ONLY what it can actually serve, never a phantom URI. Empty until the first finalize.
   * Insertion-ordered + deduped (a Set); it holds refs, never payloads (no runtime semantics).
   */
  private readonly producedArtifacts = new Set<string>();

  constructor(transport: Transport) {
    this.sdk = createSdk(transport);
  }

  async handleFrame(rawLine: string): Promise<JsonRpcResponse | undefined> {
    let parsed: unknown;
    try {
      parsed = JSON.parse(rawLine);
    } catch (e) {
      // A malformed MCP frame fails closed at the TRANSPORT layer — a JSON-RPC parse error, id uncorrelatable.
      return errorResponse(null, PARSE_ERROR, `malformed JSON-RPC frame: ${messageOf(e)}`);
    }
    if (!isJsonRpcRequest(parsed)) {
      const id = isRecord(parsed) && isId(parsed["id"]) ? (parsed["id"] as JsonRpcId) : null;
      return errorResponse(id, INVALID_REQUEST, "not a JSON-RPC 2.0 request (missing string `method`)");
    }
    return this.handle(parsed);
  }

  async handle(request: JsonRpcRequest): Promise<JsonRpcResponse | undefined> {
    // A NOTIFICATION — a well-formed message with NO `id` member (JSON-RPC 2.0 §4.1) — is NEVER answered:
    // both JSON-RPC and MCP forbid a response, and every real MCP client's third handshake step is the
    // `notifications/initialized` notification (an answered one is an uncorrelatable `id:null` frame that
    // trips 'unknown message ID' faults client-side). The face holds no notification side effects — the
    // dispatcher is stateless per-frame, so initialized/cancelled are no-ops — and the silence covers ANY
    // id-less method, known or unknown (METHOD_NOT_FOUND is a REQUEST-only outcome by spec).
    // (`id: null` is a PRESENT member — a degenerate request, answered as before — only ABSENCE notifies.)
    if (!("id" in request) || request.id === undefined) return undefined;
    const id = isId(request.id) ? request.id : null;
    try {
      switch (request.method) {
        case "initialize":
          return okResponse(id, this.initialize());
        case "resources/list":
          return okResponse(id, await this.listResources());
        case "resources/read":
          return okResponse(id, await this.readResource(fieldOf(request.params, "uri")));
        case "tools/list":
          return okResponse(id, { tools: TOOL_DESCRIPTORS });
        case "tools/call":
          return okResponse(id, await this.callTool(request.params));
        default:
          return errorResponse(id, METHOD_NOT_FOUND, `unknown method ${JSON.stringify(request.method)}`);
      }
    } catch (e) {
      // A raised protocol error is a TRANSPORT failure with its own code; anything else is an internal fault
      // (kept DISTINCT from a domain refusal, which never reaches here — the tool path handles it in-band).
      if (e instanceof McpProtocolError) return errorResponse(id, e.jsonRpcCode, e.message);
      return errorResponse(id, INTERNAL_ERROR, messageOf(e));
    }
  }

  /* ── the MCP handshake (self-describe; not load-bearing for the projection contract) ── */

  private initialize(): Record<string, unknown> {
    return {
      protocolVersion: "2024-11-05",
      capabilities: { resources: {}, tools: {} },
      serverInfo: { name: "kestrel.markets/mcp", version: SDK_VERSION },
    };
  }

  /* ── RESOURCES: the static djm.8 catalog + the immutable, content-addressed artifacts ── */

  private async listResources(): Promise<{ resources: readonly Resource[] }> {
    // Catalog discovery goes through the SDK (never the loader): the canonical CatalogPage (kestrel-adge) —
    // one resource per CatalogListing (kestrel-dnxq, byte-identical across transports; the
    // reproducibility-complete CatalogEntry surface resolves at openSession/grade, not in the listing) PLUS a
    // SINGLE ex-ante pricing resource when the catalog carries a `pricing` block (kestrel-adge / z3is).
    const { entries, pricing } = await this.sdk.catalog();
    const resources: Resource[] = entries.map((_entry, index) => ({
      uri: `${CATALOG_URI_PREFIX}${index}`,
      name: `kestrel-catalog-entry-${index}`,
      description: "A catalog discovery listing (id + title + free-ness); openSession by its `id` for the run surface.",
      mimeType: "application/json",
    }));
    if (pricing !== undefined) {
      // The ex-ante price sheet a budget-holding agent reads BEFORE provoking a 402 (kestrel-adge). Advertised
      // only when the catalog serves one, so a self-hosted mirror with no price sheet lists no pricing resource.
      resources.push({
        uri: PRICING_URI,
        name: "kestrel-catalog-pricing",
        description: "The ex-ante price sheet (every purchasable SKU's price/asset/what-you-get + settlement rails); the machine-readable answer to \"what can I buy, and for how much\" without provoking a 402.",
        mimeType: "application/json",
      });
    }
    // The immutable, content-addressed artifacts THIS face has produced — one resource per finalized ref
    // (kestrel-djm.18). Enumerating them lets an MCP client DISCOVER the Blotter / conformance roots (the same
    // artifacts the other three faces expose), closing the 'four equal faces' gap. Every ref was returned by a
    // finalize (and so registered in the transport), so each declared URI is servable by `resources/read` — the
    // face declares ONLY what it can serve (capability honesty); before the first finalize the set is empty and
    // NO artifact resource is listed (never a phantom URI).
    for (const ref of this.producedArtifacts) {
      resources.push({
        uri: artifactUri(ref),
        name: `kestrel-artifact-${ref}`,
        description: "An immutable, content-addressed finalized Session artifact (Blotter / conformance root); read it by this URI for the djm.2 ArtifactResult { ref, kind, value } verbatim.",
        mimeType: "application/json",
      });
    }
    return { resources };
  }

  private async readResource(uri: unknown): Promise<{ contents: readonly ResourceContents[] }> {
    if (typeof uri !== "string") throw new McpProtocolError(INVALID_PARAMS, "resources/read requires a string `uri`");

    if (uri === PRICING_URI) {
      // The ex-ante pricing block VERBATIM (kestrel-adge) — the SAME CatalogPricing the SDK surfaces. Fail
      // closed (never a fabricated sheet) when the catalog serves no pricing: the resource was not advertised.
      const { pricing } = await this.sdk.catalog();
      if (pricing === undefined) throw new McpProtocolError(INVALID_PARAMS, `no catalog pricing resource ${JSON.stringify(uri)}`);
      return { contents: [jsonContents(uri, pricing)] };
    }

    if (uri.startsWith(CATALOG_URI_PREFIX)) {
      const index = Number.parseInt(uri.slice(CATALOG_URI_PREFIX.length), 10);
      const { entries } = await this.sdk.catalog();
      const entry = Number.isInteger(index) && index >= 0 ? entries[index] : undefined;
      if (entry === undefined) throw new McpProtocolError(INVALID_PARAMS, `no catalog resource ${JSON.stringify(uri)}`);
      // the canonical CatalogListing VERBATIM as JSON (no reshaped summary; byte-identical across transports).
      return { contents: [jsonContents(uri, entry)] };
    }

    if (uri.startsWith(ARTIFACT_URI_PREFIX)) {
      const ref = decodeURIComponent(uri.slice(ARTIFACT_URI_PREFIX.length));
      // the SDK's ArtifactResult VERBATIM (immutable, content-addressed by root). An unknown ref is the SDK's
      // typed refusal → an internal JSON-RPC error at the resource layer (fail-closed, never a silent empty).
      const artifact = await this.sdk.artifact(ref);
      return { contents: [jsonContents(uri, artifact)] };
    }

    throw new McpProtocolError(INVALID_PARAMS, `unknown resource uri ${JSON.stringify(uri)}`);
  }

  /* ── TOOLS: one per SDK verb; the djm.2 object rides VERBATIM in structuredContent ── */

  private async callTool(params: unknown): Promise<ToolResult> {
    const name = asString(fieldOf(params, "name"), "name");
    const args = asRecord(fieldOf(params, "arguments"));
    try {
      const structuredContent = await this.invoke(name, args);
      return { content: [textBlock(structuredContent)], structuredContent };
    } catch (e) {
      // A TYPED KESTREL REFUSAL is delivered IN-BAND as a tool error result — NOT a JSON-RPC transport error.
      if (isKestrelRefusal(e)) {
        const refusal = { code: e.code, message: e.message };
        return { content: [textBlock({ refusal })], structuredContent: { refusal }, isError: true };
      }
      throw e; // a protocol error / unexpected fault → surfaced by handle() as a JSON-RPC error (distinct class)
    }
  }

  /** Marshal ONE MCP tool call into ONE SDK verb and return the SDK's protocol object (or the open
   *  projection). Holds no logic beyond argument extraction — the SDK carries every semantic. */
  private async invoke(name: string, args: Record<string, unknown>): Promise<unknown> {
    switch (name) {
      case TOOL.validate:
        return this.sdk.validate(asString(args["document"], "document"));

      case TOOL.open: {
        // VALIDATE the subject argument (do not blind-cast): a structurally malformed subject (missing / null /
        // non-object / no string `kind`) is a bad ARGUMENT → INVALID_PARAMS. The kind VALUE is left unchecked —
        // an unsupported/unknown kind (e.g. "bogus", or "dataset" on local) is a DOMAIN matter the SDK answers
        // with a typed refusal (in-band), NEVER absorbed into a transport error.
        const opened = await this.sdk.openSession(asSessionRef(args["subject"], "subject"));
        if (opened.gated) return { gated: true, payment: opened.payment }; // the 402/Offer as DATA (verbatim)
        this.sessions.set(opened.value.sessionId, opened.value);
        return { gated: false, sessionId: opened.value.sessionId };
      }

      case TOOL.start:
        return this.session(args["sessionId"]).start();
      case TOOL.advance:
        return this.session(args["sessionId"]).advance(asAuthoredResponse(args["response"], "response"));
      case TOOL.revise:
        return this.session(args["sessionId"]).revise(asAuthoredResponse(args["response"], "response"));
      case TOOL.submit:
        return this.session(args["sessionId"]).submit(asBoundResponse(args["response"], "response"));
      case TOOL.resume:
        return this.session(args["sessionId"]).resume(asEventCursor(args["after"], "after"));
      case TOOL.finalize: {
        const finalized = await this.session(args["sessionId"]).finalize();
        // REMEMBER the produced artifact refs so `resources/list` can enumerate them (kestrel-djm.18). Each ref
        // is now registered in the bound transport (finalize did so), hence guaranteed servable by
        // `resources/read` — this is the ONLY place the discoverable-artifact set grows, and it grows ONLY with
        // refs the face can serve (capability honesty). The SdkFinalized still rides back VERBATIM (projection
        // unchanged — no summary, no reshape).
        for (const ref of finalized.artifacts) this.producedArtifacts.add(ref);
        return finalized;
      }

      case TOOL.grade:
        return this.sdk.grade({ blotters: asBlotterIds(args["blotters"], "blotters") });

      case TOOL.operationResume: {
        const operationId = asString(args["operationId"], "operationId");
        const after = asEventCursor(args["after"], "after");
        return this.sdk.resumeOperation(after === undefined ? { operationId } : { operationId, after });
      }

      // ── The LOCAL operator-secret store (kestrel-jh9w.3). These do NOT go through the SDK transport — they
      // manage `~/.kestrel/.env` directly (the same local store the CLI verb owns). The invariant: NO result
      // ever carries a secret VALUE. A structurally bad argument (a non-string key/value) is a TRANSPORT
      // INVALID_PARAMS; a jh9w.5.1 key-policy refusal / an empty value / an absent unset is a TYPED KESTREL
      // REFUSAL (an SdkLocalError, delivered IN-BAND as a tool error result), never a transport error.
      case TOOL.secretsSet:
        return this.setSecret(asString(args["key"], "key"), asString(args["value"], "value"));
      case TOOL.secretsUnset:
        return this.unsetSecret(asString(args["key"], "key"));
      case TOOL.secretsList:
        return this.listSecrets();

      default:
        throw new McpProtocolError(INVALID_PARAMS, `unknown tool ${JSON.stringify(name)}`);
    }
  }

  /** Resolve a live Session by its durable id; an unknown id is a typed Kestrel refusal (fail-closed). */
  private session(sessionId: unknown): KestrelSession {
    const id = typeof sessionId === "string" ? sessionId : undefined;
    const session = id === undefined ? undefined : this.sessions.get(id);
    if (session === undefined) {
      throw new SdkLocalError("unknown-session", `no open MCP Session for id ${JSON.stringify(sessionId)} — call ${TOOL.open} first`);
    }
    return session;
  }

  /* ── the LOCAL operator-secret store (kestrel-jh9w.3): set / unset / list(names-only), no `get` ── */

  /**
   * Store one operator secret in `~/.kestrel/.env` and confirm WITHOUT echoing the value. Applies the SAME
   * jh9w.5.1 key policy the CLI verb does (single-sourced {@link checkOperatorSecretKey}) — a *LIVE* key or a
   * non-canonical name is a typed refusal BEFORE anything is written; an empty value is refused too (never a
   * silent empty write). Every refusal and the success result name only the key + store path, never the value.
   */
  private setSecret(key: string, value: string): { schema: string; key: string; path: string; stored: true } {
    const violation = checkOperatorSecretKey(key);
    if (violation !== undefined) {
      // Fail closed — nothing written. Carry the hint in the message so an agent gets the wrangler pointer; the
      // key (not the value) is already in the message, and the value never appears anywhere on this path.
      throw new SdkLocalError(violation.code, `${violation.message} — ${violation.hint}`);
    }
    if (value.length === 0) {
      throw new SdkLocalError(
        "SECRET_EMPTY",
        `refusing to store an EMPTY value for ${JSON.stringify(key)} — to remove it instead call ${TOOL.secretsUnset}`,
      );
    }
    const path = defaultSecretsPath();
    saveSecret(key, value, path);
    // NAMES + path only — the value is never returned (no face ever returns a secret VALUE).
    return { schema: "kestrel.secrets.set/v1", key, path, stored: true };
  }

  /** Remove one operator secret; an absent key is a typed NOT_FOUND refusal (never a silent success). */
  private unsetSecret(key: string): { schema: string; key: string; path: string; removed: true } {
    const path = defaultSecretsPath();
    if (!deleteSecret(key, path)) {
      throw new SdkLocalError("NOT_FOUND", `no secret named ${JSON.stringify(key)} in ${path} — call ${TOOL.secretsList} for the stored names`);
    }
    return { schema: "kestrel.secrets.unset/v1", key, path, removed: true };
  }

  /** The NAMES held in the store, sorted — built on `secretNames`, which never returns a value, so this path
   *  cannot render one. This IS the read face: there is deliberately no tool that returns a secret value. */
  private listSecrets(): { schema: string; path: string; names: readonly string[] } {
    const path = defaultSecretsPath();
    return { schema: "kestrel.secrets.list/v1", path, names: secretNames(path) };
  }
}

/* ─────────────────────────────── module helpers ────────────────────────────── */

function okResponse(id: JsonRpcId, result: unknown): JsonRpcResponse {
  return { jsonrpc: "2.0", id, result };
}

function errorResponse(id: JsonRpcId, code: number, message: string): JsonRpcResponse {
  return { jsonrpc: "2.0", id, error: { code, message } };
}

function textBlock(value: unknown): ContentBlock {
  return { type: "text", text: safeJson(value) };
}

function jsonContents(uri: string, value: unknown): ResourceContents {
  return { uri, mimeType: "application/json", text: safeJson(value) };
}

/** Serialize a protocol object to lossless JSON (drops `undefined` exactly as the codebase's canon does). */
function safeJson(value: unknown): string {
  return JSON.stringify(value ?? null);
}

function messageOf(e: unknown): string {
  return e instanceof Error ? e.message : String(e);
}

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null;
}

function isId(v: unknown): v is JsonRpcId {
  return typeof v === "number" || typeof v === "string" || v === null;
}

function isJsonRpcRequest(v: unknown): v is JsonRpcRequest {
  return isRecord(v) && typeof v["method"] === "string";
}

function fieldOf(params: unknown, key: string): unknown {
  return isRecord(params) ? params[key] : undefined;
}

function asString(v: unknown, field: string): string {
  if (typeof v !== "string") throw new McpProtocolError(INVALID_PARAMS, `expected string \`${field}\`, got ${v === null ? "null" : typeof v}`);
  return v;
}

function asRecord(v: unknown): Record<string, unknown> {
  return isRecord(v) ? v : {};
}

/** A short, safe description of an ill-typed argument for an INVALID_PARAMS message (never leaks the value). */
function describeArg(v: unknown): string {
  return v === null ? "null" : Array.isArray(v) ? "array" : typeof v;
}

/**
 * VALIDATE a {@link SessionRef} argument (no blind cast). A subject is well-formed when it is an OBJECT with a
 * string `kind` discriminant; a missing / null / non-object / kind-less subject is a bad ARGUMENT → a TRANSPORT
 * {@link INVALID_PARAMS} error. The kind VALUE is deliberately NOT checked: resolving an unsupported/unknown
 * kind ("bogus", or "dataset" on the local transport) is a DOMAIN matter the SDK answers with a typed refusal
 * (in-band tool result) — the transport-error class must NEVER absorb a Kestrel refusal (failure taxonomy).
 */
function asSessionRef(v: unknown, field: string): SessionRef {
  if (!isRecord(v) || typeof v["kind"] !== "string") {
    throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be a SessionRef object with a string \`kind\`, got ${describeArg(v)}`);
  }
  return v as unknown as SessionRef;
}

/** VALIDATE an {@link AuthoredResponse} argument (no blind cast): an OBJECT with a string `kind` discriminant.
 *  The kind VALUE is left to the Session (an unknown kind is a domain outcome, not a transport error). */
function asAuthoredResponse(v: unknown, field: string): AuthoredResponse {
  if (!isRecord(v) || typeof v["kind"] !== "string") {
    throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be an AuthoredResponse object with a string \`kind\`, got ${describeArg(v)}`);
  }
  return v as unknown as AuthoredResponse;
}

/** VALIDATE a {@link BoundResponse} argument (no blind cast): a bound turn is a plain OBJECT (the full pentad
 *  header + TurnBody). Field-level reconciliation is the Session's job (a mismatch is a domain refusal). */
function asBoundResponse(v: unknown, field: string): BoundResponse {
  if (!isRecord(v)) {
    throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be a BoundResponse object, got ${describeArg(v)}`);
  }
  return v as unknown as BoundResponse;
}

/**
 * VALIDATE the {@link TOOL.grade} `blotters` argument (no blind cast): an ARRAY whose EVERY element is a string
 * Blotter id. A non-array (a number / object / string) or an array holding a non-string element is a bad
 * ARGUMENT SHAPE → a TRANSPORT {@link INVALID_PARAMS} error — NOT the misleading char-indexed / empty-grade
 * domain refusal the blind `as readonly string[]` cast used to leak. An EMPTY array is structurally VALID and
 * flows through to the SDK, whose `empty-grade` typed refusal is a DOMAIN matter (an in-band tool result). A
 * well-formed but UNKNOWN id likewise reaches the SDK → a typed `unknown-artifact` refusal: the transport-error
 * class must NEVER absorb a Kestrel refusal (the failure taxonomy stays three distinct shapes).
 */
function asBlotterIds(v: unknown, field: string): readonly string[] {
  if (!Array.isArray(v) || !v.every((id) => typeof id === "string")) {
    throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be an array of Blotter id strings, got ${describeArg(v)}`);
  }
  return v as readonly string[];
}

/**
 * VALIDATE an optional {@link EventCursor} resume argument (no blind cast) for `kestrel.session.resume` /
 * `kestrel.operation.resume`: an ABSENT cursor is fine (resume from the start), but a PRESENT one must be a
 * well-formed `{ sequence:number, token:string }`. A malformed cursor (a non-object, or wrong-typed fields) is
 * a bad ARGUMENT SHAPE → a TRANSPORT {@link INVALID_PARAMS} error — never silently ignored the way the blind
 * `as EventCursor` cast left it. The cursor's resolvability (an out-of-range sequence / stale token) is a DOMAIN
 * matter the SDK answers (a well-formed but stale cursor PASSES here and reaches an in-band domain outcome —
 * NOT a transport error; pinned by tests/mcp.projection.test.ts's ks8 no-refusal-conflation positive test), so
 * only the SHAPE is checked here.
 *
 * STRICT `after: null` (KEEP-STRICT, kestrel-ks8): only `undefined` (absent) signals resume-from-start. JSON
 * `null` is a PRESENT, wrong-typed value — the tool schema declares `after: { type:"object" }`, which EXCLUDES
 * null — so it fails closed here as {@link INVALID_PARAMS}, DELIBERATELY not coalesced to `undefined`. Coalescing
 * null→undefined would silently accept an off-schema value; rejecting it keeps the schema and this check
 * consistent and fail-closed (the `v === undefined` guard above is intentionally `undefined`-only, not nullish).
 */
function asEventCursor(v: unknown, field: string): EventCursor | undefined {
  if (v === undefined) return undefined;
  if (!isRecord(v) || typeof v["sequence"] !== "number" || typeof v["token"] !== "string") {
    throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be an EventCursor { sequence:number, token:string }, got ${describeArg(v)}`);
  }
  return v as unknown as EventCursor;
}

/**
 * A typed KESTREL REFUSAL carries a stable string `code` (agents match on `code`, not prose): both the LOCAL
 * transport's `SdkLocalError` and the HTTP transport's `KestrelClientError` do. A malformed-frame `SyntaxError`
 * / a null-deref `TypeError` carries no string `code`, so it is NOT mistaken for a refusal — it stays a
 * transport / internal error. This keeps the three failure classes distinct without importing the HTTP
 * client's error class into the face (projection stays SDK-only).
 */
function isKestrelRefusal(e: unknown): e is { readonly code: string; readonly message: string } {
  return e instanceof SdkLocalError || (e instanceof Error && typeof (e as { code?: unknown }).code === "string");
}
