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
text/typescript
/**
* # 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;
}