kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
504 lines (503 loc) • 33.8 kB
JavaScript
/**
* # 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, } from "../sdk/index.js";
import { checkOperatorSecretKey, defaultSecretsPath, deleteSecret, saveSecret, secretNames, } from "../cli/credentials.js";
import { ARTIFACT_URI_PREFIX, CATALOG_URI_PREFIX, PRICING_URI, artifactUri, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, McpProtocolError, METHOD_NOT_FOUND, PARSE_ERROR, TOOL, } from "./protocol.js";
/** Build the MCP face over a transport CHOICE. Constructs `createSdk(transport)` internally; holds no runtime
* semantics of its own. */
export function createKestrelMcpServer(options) {
return new KestrelMcpServerImpl(options.transport);
}
/* ─────────────────────────── the tool catalogue (tools/list) ───────────────── */
const sessionIdSchema = { type: "string", description: "the durable SessionId returned by kestrel.session.open" };
// 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}`,
};
function tool(name, description, properties, required) {
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 = [
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 {
sdk;
/** 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. */
sessions = new Map();
/**
* 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).
*/
producedArtifacts = new Set();
constructor(transport) {
this.sdk = createSdk(transport);
}
async handleFrame(rawLine) {
let parsed;
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"] : null;
return errorResponse(id, INVALID_REQUEST, "not a JSON-RPC 2.0 request (missing string `method`)");
}
return this.handle(parsed);
}
async handle(request) {
// 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) ── */
initialize() {
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 ── */
async listResources() {
// 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 = 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 };
}
async readResource(uri) {
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 ── */
async callTool(params) {
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. */
async invoke(name, args) {
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). */
session(sessionId) {
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.
*/
setSecret(key, value) {
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). */
unsetSecret(key) {
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. */
listSecrets() {
const path = defaultSecretsPath();
return { schema: "kestrel.secrets.list/v1", path, names: secretNames(path) };
}
}
/* ─────────────────────────────── module helpers ────────────────────────────── */
function okResponse(id, result) {
return { jsonrpc: "2.0", id, result };
}
function errorResponse(id, code, message) {
return { jsonrpc: "2.0", id, error: { code, message } };
}
function textBlock(value) {
return { type: "text", text: safeJson(value) };
}
function jsonContents(uri, value) {
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) {
return JSON.stringify(value ?? null);
}
function messageOf(e) {
return e instanceof Error ? e.message : String(e);
}
function isRecord(v) {
return typeof v === "object" && v !== null;
}
function isId(v) {
return typeof v === "number" || typeof v === "string" || v === null;
}
function isJsonRpcRequest(v) {
return isRecord(v) && typeof v["method"] === "string";
}
function fieldOf(params, key) {
return isRecord(params) ? params[key] : undefined;
}
function asString(v, field) {
if (typeof v !== "string")
throw new McpProtocolError(INVALID_PARAMS, `expected string \`${field}\`, got ${v === null ? "null" : typeof v}`);
return v;
}
function asRecord(v) {
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) {
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, field) {
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;
}
/** 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, field) {
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;
}
/** 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, field) {
if (!isRecord(v)) {
throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be a BoundResponse object, got ${describeArg(v)}`);
}
return v;
}
/**
* 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, field) {
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;
}
/**
* 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, field) {
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;
}
/**
* 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) {
return e instanceof SdkLocalError || (e instanceof Error && typeof e.code === "string");
}