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.
75 lines (74 loc) • 4.03 kB
JavaScript
/**
* # mcp/stdio — the thin stdio adapter over the MCP dispatcher (kestrel-djm.7)
*
* The MCP wire is newline-delimited JSON-RPC 2.0 over stdin/stdout. This module is a THIN ADAPTER: it frames
* stdin into lines, hands each raw frame to {@link KestrelMcpServer.handleFrame} (a malformed frame fails
* closed as a JSON-RPC parse error — the transport-layer signal), and writes each JSON-RPC response as one
* line to stdout. A NOTIFICATION frame (no `id` member) yields `undefined` from the dispatcher and NOTHING is
* written for it — JSON-RPC 2.0 and MCP both forbid answering a notification, and every real MCP client sends
* `notifications/initialized` as its third handshake step. It holds NO Kestrel semantics — the dispatcher
* (`./server.ts`) is the contract; this is only the pipe. Responses are serialized in arrival order (a
* per-frame async chain), so a slow verb never reorders the stream.
*
* RUN IT: `bun src/mcp/stdio.ts` (the `import.meta.main` guard below constructs a default LOCAL server —
* `createSdk(localTransport())` — and serves stdio). An embedder builds its own transport CHOICE and calls
* `serveStdio(createKestrelMcpServer({ transport }))`.
*
* ── DESIGN FORK RESOLVED (kestrel-0f7i; platform kestrel-markets-ff9m) — a node-runnable entry EXISTS ──
* This header once logged "NO node bin, on purpose": the LOCAL transport pulls the Bun-hosted engine
* (`Bun.CryptoHasher` et al.), so a plain-`node` bin over `localTransport()` would fault. The resolution is
* the `kestrel mcp` verb (src/cli/commands/mcp.ts): the published bin serves this SAME adapter with the
* REMOTE transport by DEFAULT (pure fetch — node-runnable end-to-end; the `npx kestrel.markets mcp` stdio
* drop-in an MCP client configures), while `--local` opts into the in-process engine behind the launcher's
* heavy gate (bun-less ⇒ loud exit 4 RUNTIME_UNAVAILABLE, never a raw Bun fault on the MCP wire). The
* dispatcher — not any bin — is still what the contract pins; `bun src/mcp/stdio.ts` stays the direct local
* entry below.
*/
import { localTransport } from "../sdk/index.js";
import { createKestrelMcpServer } from "./server.js";
/**
* Serve one MCP server over stdin/stdout as newline-delimited JSON-RPC 2.0. Resolves when stdin ends. Each
* frame is dispatched through {@link KestrelMcpServer.handleFrame}; responses are written in arrival order.
*/
export 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);
// `undefined` ⇒ the frame was a NOTIFICATION — the wire carries NO response for it (JSON-RPC 2.0 §4.1).
if (response !== undefined)
output.write(JSON.stringify(response) + "\n");
});
};
return new Promise((resolve, reject) => {
input.on("data", (chunk) => {
buffer += String(chunk);
let nl;
while ((nl = buffer.indexOf("\n")) >= 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);
});
}
// Runnable entry: `bun src/mcp/stdio.ts` → a default LOCAL MCP server over stdio (does not fire when this
// module is merely imported as the library face).
if (import.meta.main) {
serveStdio(createKestrelMcpServer({ transport: localTransport() })).catch((e) => {
process.stderr.write(`kestrel-mcp: fatal ${e instanceof Error ? e.message : String(e)}\n`);
process.exitCode = 1;
});
}