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.

91 lines (84 loc) 5.26 kB
/** * # cli/commands/mcp — the `kestrel mcp` verb: the MCP face over stdio (kestrel-0f7i) * * `kestrel mcp` serves the EXISTING MCP dispatcher ({@link createKestrelMcpServer} — the one source of * the tool/resource surface, the same projection the hosted MCP server exposes) over stdin/stdout as * newline-delimited JSON-RPC 2.0, so an MCP client configures the package as a local stdio server: * * { "command": "npx", "args": ["kestrel.markets", "mcp"] } * * It is a THIN COMPOSITION: transport choice + `serveStdio(createKestrelMcpServer({ transport }))`. * No MCP semantics live here — the dispatcher (`src/mcp/server.ts`) is the contract; this verb is only * the launch wiring the published bin was missing (the face previously ran solely via * `bun src/mcp/stdio.ts`, unreachable through `npx` under plain node). * * ── Transport choice (the ONE knob, mirroring `agent`'s vocabulary) ── * - DEFAULT: the REMOTE transport at {@link DEFAULT_API} (`--api <url>` / `KESTREL_API` select another * base; bare `--api` / `--api default` ⇒ the canonical base). Pure `fetch` — ZERO bun dependency — * so `npx kestrel.markets mcp` is node-runnable end-to-end, and the tool surface it serves is the * hosted platform's. The default deliberately DIFFERS from `agent`'s (local): an MCP client * configures this verb as a drop-in stdio server on hosts where only node exists, so the * node-runnable transport must be the zero-config path (the kestrel-33yr.4 direction). * - `--local`: the in-process engine (`localTransport()`). HEAVY — the router gates this arm through * `loadHeavy`, so under a bun-less node it refuses LOUD (exit 4, RUNTIME_UNAVAILABLE), never a raw * `Bun is not defined` mid-handshake. * - `--local` + `--api` is a CONTRADICTION → fail closed (USAGE), never silently honour one. * * stdout is the MCP wire ONLY (serveStdio writes one JSON-RPC response per REQUEST; a NOTIFICATION — * an id-less frame such as the handshake's `notifications/initialized` — elicits nothing, as JSON-RPC 2.0 * and MCP require); this module writes nothing else to it. The process serves until stdin ends — exactly * the lifecycle an MCP client drives. */ import { parseArgs } from "../args.ts"; import { DEFAULT_API } from "../backend/remote.ts"; import type { GlobalFlags } from "../context.ts"; import { CliError, EXIT } from "../errors.ts"; import { createKestrelMcpServer, serveStdio } from "../../mcp/index.ts"; import { localTransport, remoteTransport, type Transport } from "../../sdk/index.ts"; /** The web `fetch` shape the remote transport speaks (structurally the client's `FetchLike`). */ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; /** Environment shape (for `KESTREL_API`); default `process.env`. Injectable for the hermetic shape tests. */ interface Env { readonly [k: string]: string | undefined; } function usage(message: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message, hint: "usage: kestrel mcp [--local | --api <url>] (see `kestrel mcp --help`)", }); } /** Resolve the ONE transport choice from the verb's args + the global `--api` flag + `KESTREL_API`. * Remote-base precedence is `--api` > `KESTREL_API` env > {@link DEFAULT_API} — the SAME ladder * `backend/select.ts` pins for every other verb, so the copy that says "also KESTREL_API" is TRUE here * (an MCP client's config `env` block is the natural way to point this verb at another base). Only the * EXPLICIT `--api` flag contradicts `--local`: an exported KESTREL_API is ambient, so `--local` simply * outranks it (flag > env) rather than turning every such shell into a refusal. Exported for the * in-process contradiction/shape tests; the spawn e2e proves the WIRING through the real bin. */ export function resolveMcpTransport(args: readonly string[], api: string | undefined, env: Env = process.env): Transport { // The one verb-local flag; parseArgs rejects any unknown flag uniformly (kestrel-gbv, fail-closed). const parsed = parseArgs(args, new Set(["local"])); if (parsed.positionals.length > 0) { // A stray positional must never be silently absorbed by a verb whose stdout is a machine wire. 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 as FetchLike }); } /** * Serve the MCP face over stdio until stdin ends. The router gates the `--local` arm through * `loadHeavy` BEFORE this module runs its local transport, so reaching `localTransport()` here * implies a usable bun. */ export async function mcpCommand(args: readonly string[], globals: GlobalFlags): Promise<number> { const transport = resolveMcpTransport(args, globals.api); await serveStdio(createKestrelMcpServer({ transport })); return EXIT.OK; }