UNPKG

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.

476 lines (471 loc) 19.2 kB
import { AUTHORED_RESPONSE_SHAPE, BOUND_RESPONSE_SHAPE, EVENT_CURSOR_SHAPE, SDK_VERSION, SUBJECT_SHAPE, SdkLocalError, createSdk, localTransport, remoteTransport } from "./bin-a4jbxqxk.js"; import"./bin-cv13fq8c.js"; import"./bin-kxzrsgjw.js"; import { DEFAULT_API } from "./bin-qewrvqtv.js"; import { parseArgs } from "./bin-1gc4zavq.js"; import { CliError, EXIT } from "./bin-t9ggwnv5.js"; import { checkOperatorSecretKey, defaultSecretsPath, deleteSecret, saveSecret, secretNames } from "./bin-q0g6xvsw.js"; import"./bin-xx1qrxe3.js"; import"./bin-t58k60v9.js"; import"./bin-p5jfg5b0.js"; import"./bin-29be75ss.js"; import"./bin-n77kea5n.js"; import"./bin-mn7wcxhv.js"; import"./bin-8pchjxn2.js"; import"./bin-am3351y2.js"; import"./bin-73jr945c.js"; import"./bin-df1tn094.js"; import"./bin-3ft0k1jq.js"; import"./bin-2ywrx58g.js"; import"./bin-wckvcay0.js"; // src/mcp/protocol.ts var PARSE_ERROR = -32700; var INVALID_REQUEST = -32600; var METHOD_NOT_FOUND = -32601; var INVALID_PARAMS = -32602; var INTERNAL_ERROR = -32603; var CATALOG_URI_PREFIX = "kestrel://catalog/"; var ARTIFACT_URI_PREFIX = "kestrel://artifact/"; var PRICING_URI = "kestrel://catalog-pricing"; var artifactUri = (ref) => `${ARTIFACT_URI_PREFIX}${encodeURIComponent(ref)}`; var TOOL = { validate: "kestrel.validate", open: "kestrel.session.open", start: "kestrel.session.start", advance: "kestrel.session.advance", revise: "kestrel.session.revise", submit: "kestrel.session.submit", resume: "kestrel.session.resume", finalize: "kestrel.session.finalize", grade: "kestrel.grade", operationResume: "kestrel.operation.resume", secretsSet: "kestrel.secrets.set", secretsUnset: "kestrel.secrets.unset", secretsList: "kestrel.secrets.list" }; class McpProtocolError extends Error { name = "McpProtocolError"; jsonRpcCode; constructor(jsonRpcCode, message) { super(message); this.jsonRpcCode = jsonRpcCode; } } // src/mcp/server.ts function createKestrelMcpServer(options) { return new KestrelMcpServerImpl(options.transport); } var sessionIdSchema = { type: "string", description: "the durable SessionId returned by kestrel.session.open" }; var 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 } }; } var 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"]), 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.", {}, []) ]; class KestrelMcpServerImpl { sdk; sessions = new Map; producedArtifacts = new Set; constructor(transport) { this.sdk = createSdk(transport); } async handleFrame(rawLine) { let parsed; try { parsed = JSON.parse(rawLine); } catch (e) { 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) { if (!("id" in request) || request.id === undefined) return; 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) { if (e instanceof McpProtocolError) return errorResponse(id, e.jsonRpcCode, e.message); return errorResponse(id, INTERNAL_ERROR, messageOf(e)); } } initialize() { return { protocolVersion: "2024-11-05", capabilities: { resources: {}, tools: {} }, serverInfo: { name: "kestrel.markets/mcp", version: SDK_VERSION } }; } async listResources() { 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) { 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" }); } 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) { 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)}`); return { contents: [jsonContents(uri, entry)] }; } if (uri.startsWith(ARTIFACT_URI_PREFIX)) { const ref = decodeURIComponent(uri.slice(ARTIFACT_URI_PREFIX.length)); const artifact = await this.sdk.artifact(ref); return { contents: [jsonContents(uri, artifact)] }; } throw new McpProtocolError(INVALID_PARAMS, `unknown resource uri ${JSON.stringify(uri)}`); } 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) { if (isKestrelRefusal(e)) { const refusal = { code: e.code, message: e.message }; return { content: [textBlock({ refusal })], structuredContent: { refusal }, isError: true }; } throw e; } } async invoke(name, args) { switch (name) { case TOOL.validate: return this.sdk.validate(asString(args["document"], "document")); case TOOL.open: { const opened = await this.sdk.openSession(asSessionRef(args["subject"], "subject")); if (opened.gated) return { gated: true, payment: opened.payment }; 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(); 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 }); } 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)}`); } } 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; } setSecret(key, value) { const violation = checkOperatorSecretKey(key); if (violation !== undefined) { 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); return { schema: "kestrel.secrets.set/v1", key, path, stored: true }; } 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 }; } listSecrets() { const path = defaultSecretsPath(); return { schema: "kestrel.secrets.list/v1", path, names: secretNames(path) }; } } 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) }; } 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 : {}; } function describeArg(v) { return v === null ? "null" : Array.isArray(v) ? "array" : typeof v; } 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; } 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; } function asBoundResponse(v, field) { if (!isRecord(v)) { throw new McpProtocolError(INVALID_PARAMS, `expected \`${field}\` to be a BoundResponse object, got ${describeArg(v)}`); } return v; } 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; } function asEventCursor(v, field) { if (v === undefined) return; 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; } function isKestrelRefusal(e) { return e instanceof SdkLocalError || e instanceof Error && typeof e.code === "string"; } // src/mcp/stdio.ts function serveStdio(server) { const input = process.stdin; const output = process.stdout; input.setEncoding("utf8"); let buffer = ""; let chain = Promise.resolve(); const enqueue = (line) => { chain = chain.then(async () => { const response = await server.handleFrame(line); if (response !== undefined) output.write(JSON.stringify(response) + ` `); }); }; return new Promise((resolve, reject) => { input.on("data", (chunk) => { buffer += String(chunk); let nl; while ((nl = buffer.indexOf(` `)) >= 0) { const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1); if (line !== "") enqueue(line); } }); input.on("end", () => { const tail = buffer.trim(); if (tail !== "") enqueue(tail); chain.then(() => resolve(), reject); }); input.on("error", reject); }); } if (false) {} // src/cli/commands/mcp.ts function usage(message) { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message, hint: "usage: kestrel mcp [--local | --api <url>] (see `kestrel mcp --help`)" }); } function resolveMcpTransport(args, api, env = process.env) { const parsed = parseArgs(args, new Set(["local"])); if (parsed.positionals.length > 0) { throw usage(`unexpected \`mcp\` argument ${JSON.stringify(parsed.positionals[0])}`); } const local = parsed.bools.has("local"); if (local && api !== undefined) { throw usage("`--local` contradicts `--api` — the MCP server speaks exactly one transport"); } if (local) return localTransport(); const raw = api ?? env.KESTREL_API; const baseUrl = raw === undefined || raw === "" || raw === "default" ? DEFAULT_API : raw; return remoteTransport({ baseUrl, fetch: globalThis.fetch }); } async function mcpCommand(args, globals) { const transport = resolveMcpTransport(args, globals.api); await serveStdio(createKestrelMcpServer({ transport })); return EXIT.OK; } export { resolveMcpTransport, mcpCommand };