@boardwalk-labs/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +69 -0
  3. package/bin/boardwalk-server.js +16 -0
  4. package/dist/agent/conversation.d.ts +42 -0
  5. package/dist/agent/conversation.js +4 -0
  6. package/dist/agent/leaf.d.ts +81 -0
  7. package/dist/agent/leaf.js +190 -0
  8. package/dist/agent/providers.d.ts +23 -0
  9. package/dist/agent/providers.js +347 -0
  10. package/dist/agent/rates.d.ts +13 -0
  11. package/dist/agent/rates.js +35 -0
  12. package/dist/agent/redact.d.ts +9 -0
  13. package/dist/agent/redact.js +27 -0
  14. package/dist/agent/resolve.d.ts +58 -0
  15. package/dist/agent/resolve.js +153 -0
  16. package/dist/agent/sse.d.ts +2 -0
  17. package/dist/agent/sse.js +30 -0
  18. package/dist/agent/tools.d.ts +57 -0
  19. package/dist/agent/tools.js +324 -0
  20. package/dist/clock.d.ts +8 -0
  21. package/dist/clock.js +32 -0
  22. package/dist/cron/cron.d.ts +34 -0
  23. package/dist/cron/cron.js +331 -0
  24. package/dist/engine.d.ts +106 -0
  25. package/dist/engine.js +183 -0
  26. package/dist/errors.d.ts +15 -0
  27. package/dist/errors.js +40 -0
  28. package/dist/ids.d.ts +7 -0
  29. package/dist/ids.js +42 -0
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.js +8 -0
  32. package/dist/json_value.d.ts +7 -0
  33. package/dist/json_value.js +29 -0
  34. package/dist/mcp/client.d.ts +39 -0
  35. package/dist/mcp/client.js +112 -0
  36. package/dist/mcp/jsonrpc.d.ts +57 -0
  37. package/dist/mcp/jsonrpc.js +117 -0
  38. package/dist/mcp/oauth.d.ts +72 -0
  39. package/dist/mcp/oauth.js +337 -0
  40. package/dist/mcp/token_store.d.ts +30 -0
  41. package/dist/mcp/token_store.js +101 -0
  42. package/dist/mcp/transport_http.d.ts +38 -0
  43. package/dist/mcp/transport_http.js +143 -0
  44. package/dist/mcp/transport_stdio.d.ts +27 -0
  45. package/dist/mcp/transport_stdio.js +94 -0
  46. package/dist/run/child.d.ts +1 -0
  47. package/dist/run/child.js +139 -0
  48. package/dist/run/child_host.d.ts +26 -0
  49. package/dist/run/child_host.js +124 -0
  50. package/dist/run/idempotency.d.ts +5 -0
  51. package/dist/run/idempotency.js +31 -0
  52. package/dist/run/ipc.d.ts +159 -0
  53. package/dist/run/ipc.js +150 -0
  54. package/dist/run/run_dir.d.ts +31 -0
  55. package/dist/run/run_dir.js +106 -0
  56. package/dist/run/supervisor.d.ts +107 -0
  57. package/dist/run/supervisor.js +676 -0
  58. package/dist/scheduler/scheduler.d.ts +54 -0
  59. package/dist/scheduler/scheduler.js +215 -0
  60. package/dist/server/http.d.ts +42 -0
  61. package/dist/server/http.js +183 -0
  62. package/dist/server/routes/api.d.ts +17 -0
  63. package/dist/server/routes/api.js +107 -0
  64. package/dist/server/routes/hooks.d.ts +2 -0
  65. package/dist/server/routes/hooks.js +88 -0
  66. package/dist/server/routes/router.d.ts +15 -0
  67. package/dist/server/routes/router.js +75 -0
  68. package/dist/server/routes/stream.d.ts +2 -0
  69. package/dist/server/routes/stream.js +79 -0
  70. package/dist/server/routes/ui.d.ts +2 -0
  71. package/dist/server/routes/ui.js +120 -0
  72. package/dist/server/server.d.ts +25 -0
  73. package/dist/server/server.js +67 -0
  74. package/dist/server_main.d.ts +46 -0
  75. package/dist/server_main.js +203 -0
  76. package/dist/store/migrations.d.ts +21 -0
  77. package/dist/store/migrations.js +159 -0
  78. package/dist/store/store.d.ts +194 -0
  79. package/dist/store/store.js +567 -0
  80. package/package.json +57 -0
package/dist/ids.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare const ULID_LENGTH: number;
2
+ /** Generate a ULID for the given timestamp (defaults to now). */
3
+ export declare function ulid(timeMs?: number): string;
4
+ /** True when `value` is a well-formed ULID (shape check only, not provenance). */
5
+ export declare function isUlid(value: string): boolean;
6
+ /** Recover the millisecond timestamp encoded in a ULID's time component. */
7
+ export declare function ulidTime(id: string): number;
package/dist/ids.js ADDED
@@ -0,0 +1,42 @@
1
+ // ULIDs — the engine's primary-key format (CODE_QUALITY §2.2): time-sortable, URL-safe,
2
+ // no auto-increment integers. Implemented in-house: 26 chars of Crockford base32 over
3
+ // 48 bits of timestamp + 80 bits of crypto randomness. Zero dependencies on purpose —
4
+ // every dependency in a public package is supply-chain surface.
5
+ import { randomBytes } from "node:crypto";
6
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32 (no I, L, O, U)
7
+ const TIME_LEN = 10;
8
+ const RANDOM_LEN = 16;
9
+ export const ULID_LENGTH = TIME_LEN + RANDOM_LEN;
10
+ /** Generate a ULID for the given timestamp (defaults to now). */
11
+ export function ulid(timeMs = Date.now()) {
12
+ if (!Number.isInteger(timeMs) || timeMs < 0 || timeMs > 2 ** 48 - 1) {
13
+ throw new RangeError(`ulid timestamp out of range: ${String(timeMs)}`);
14
+ }
15
+ let time = "";
16
+ let t = timeMs;
17
+ for (let i = 0; i < TIME_LEN; i++) {
18
+ time = ENCODING.charAt(t % 32) + time;
19
+ t = Math.floor(t / 32);
20
+ }
21
+ let rand = "";
22
+ for (const byte of randomBytes(RANDOM_LEN)) {
23
+ // Why modulo a byte: 256 % 32 === 0, so each character stays uniformly distributed.
24
+ rand += ENCODING.charAt(byte % 32);
25
+ }
26
+ return time + rand;
27
+ }
28
+ const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
29
+ /** True when `value` is a well-formed ULID (shape check only, not provenance). */
30
+ export function isUlid(value) {
31
+ return ULID_RE.test(value);
32
+ }
33
+ /** Recover the millisecond timestamp encoded in a ULID's time component. */
34
+ export function ulidTime(id) {
35
+ if (!isUlid(id))
36
+ throw new RangeError(`not a ULID: ${id}`);
37
+ let t = 0;
38
+ for (let i = 0; i < TIME_LEN; i++) {
39
+ t = t * 32 + ENCODING.indexOf(id.charAt(i));
40
+ }
41
+ return t;
42
+ }
@@ -0,0 +1,6 @@
1
+ export { Engine, type EngineOptions, type DeployArgs, type AuthorizeMcpServerOptions, } from "./engine.js";
2
+ export type { InferenceConfig, ProviderConfig } from "./agent/resolve.js";
3
+ export type { EventRow, RunRow, WorkflowRow, ArtifactRow, RunStatus, TriggerKind, RunErrorShape, } from "./store/store.js";
4
+ export { EngineError, type EngineErrorCode } from "./errors.js";
5
+ export { isTerminal } from "./run/supervisor.js";
6
+ export { createEngineServer } from "./server/server.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // @boardwalk-labs/engine — the open-source single-node runtime.
2
+ //
3
+ // Two consumers, one implementation (SPEC §1): the CLI embeds it for `boardwalk dev`
4
+ // (construct → runOnce → close), the server binary runs it long-lived (construct → start).
5
+ export { Engine, } from "./engine.js";
6
+ export { EngineError } from "./errors.js";
7
+ export { isTerminal } from "./run/supervisor.js";
8
+ export { createEngineServer } from "./server/server.js";
@@ -0,0 +1,7 @@
1
+ import type { JsonValue } from "@boardwalk-labs/workflow";
2
+ /** True when `value` is a plain JSON tree (no functions, symbols, bigints, class instances). */
3
+ export declare function isJsonValue(value: unknown): value is JsonValue;
4
+ /** Narrow to a plain object (prototype Object.prototype or null — no class instances). */
5
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
6
+ /** Narrow to JsonValue or throw — used where a JSON shape is a protocol requirement. */
7
+ export declare function asJsonValue(value: unknown, what: string): JsonValue;
@@ -0,0 +1,29 @@
1
+ // Runtime narrowing for JsonValue. Values arriving over the run process's JSON-serialized IPC
2
+ // channel are JSON by construction, but "by construction" is exactly what trust boundaries
3
+ // don't get to assume (CODE_QUALITY §2.1) — so narrow structurally instead of casting.
4
+ import { EngineError } from "./errors.js";
5
+ /** True when `value` is a plain JSON tree (no functions, symbols, bigints, class instances). */
6
+ export function isJsonValue(value) {
7
+ if (value === null || typeof value === "string" || typeof value === "boolean")
8
+ return true;
9
+ if (typeof value === "number")
10
+ return Number.isFinite(value);
11
+ if (Array.isArray(value))
12
+ return value.every(isJsonValue);
13
+ if (isPlainObject(value))
14
+ return Object.values(value).every(isJsonValue);
15
+ return false;
16
+ }
17
+ /** Narrow to a plain object (prototype Object.prototype or null — no class instances). */
18
+ export function isPlainObject(value) {
19
+ if (typeof value !== "object" || value === null || Array.isArray(value))
20
+ return false;
21
+ const proto = Object.getPrototypeOf(value);
22
+ return proto === Object.prototype || proto === null;
23
+ }
24
+ /** Narrow to JsonValue or throw — used where a JSON shape is a protocol requirement. */
25
+ export function asJsonValue(value, what) {
26
+ if (isJsonValue(value))
27
+ return value;
28
+ throw new EngineError("VALIDATION", `${what} must be a JSON-serializable value.`);
29
+ }
@@ -0,0 +1,39 @@
1
+ import { type McpTransport } from "./jsonrpc.js";
2
+ /**
3
+ * Protocol revisions this client speaks, newest first. We OFFER the newest; a server may
4
+ * answer with any revision it prefers, and these three are wire-compatible for the subset we
5
+ * use (initialize / tools/list / tools/call). Anything else is rejected loudly — guessing at
6
+ * an unknown revision's semantics would be a silent-degradation bug.
7
+ */
8
+ export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly ["2025-06-18", "2025-03-26", "2024-11-05"];
9
+ /** A tool the server advertises, normalized for the agent loop's ToolSpec shape. */
10
+ export interface McpToolInfo {
11
+ name: string;
12
+ description: string;
13
+ inputSchema: Record<string, unknown>;
14
+ }
15
+ /** A tools/call outcome: flattened text for model context + the server's error flag. */
16
+ export interface McpCallResult {
17
+ content: string;
18
+ isError: boolean;
19
+ }
20
+ export interface McpConnectionOptions {
21
+ /** The server's name from the agent() call — prefixes its tools, names it in errors. */
22
+ serverName: string;
23
+ /** Per-request timeout override (tests use short ones). */
24
+ timeoutMs?: number;
25
+ }
26
+ export declare class McpConnection {
27
+ private readonly rpc;
28
+ private readonly transport;
29
+ readonly serverName: string;
30
+ constructor(transport: McpTransport, opts: McpConnectionOptions);
31
+ /** The MCP handshake: version negotiation, then the initialized notification. */
32
+ initialize(): Promise<void>;
33
+ /** Every tool the server advertises, following nextCursor pagination to the end. */
34
+ listTools(): Promise<McpToolInfo[]>;
35
+ /** Invoke a server tool; content is flattened to model-bound text (non-text summarized). */
36
+ callTool(name: string, args: Record<string, unknown>): Promise<McpCallResult>;
37
+ /** Tear the connection down (rejects anything in flight; kills/deletes transport state). */
38
+ close(): Promise<void>;
39
+ }
@@ -0,0 +1,112 @@
1
+ // The MCP connection: the protocol conversation (initialize handshake, tools/list pagination,
2
+ // tools/call) over any transport. Lives in the RUN PROCESS — tool execution must happen where
3
+ // the program runs — while OAuth token state stays parent-side (the transport's hook brokers
4
+ // it over IPC). Every server response is Zod-validated: an MCP server's output is untrusted
5
+ // input like any provider's (CODE_QUALITY §2.1).
6
+ import { z } from "zod";
7
+ import { EngineError } from "../errors.js";
8
+ import { JsonRpcClient } from "./jsonrpc.js";
9
+ /**
10
+ * Protocol revisions this client speaks, newest first. We OFFER the newest; a server may
11
+ * answer with any revision it prefers, and these three are wire-compatible for the subset we
12
+ * use (initialize / tools/list / tools/call). Anything else is rejected loudly — guessing at
13
+ * an unknown revision's semantics would be a silent-degradation bug.
14
+ */
15
+ export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
16
+ const initializeResultSchema = z.looseObject({ protocolVersion: z.string().min(1) });
17
+ const listToolsResultSchema = z.looseObject({
18
+ tools: z.array(z.looseObject({
19
+ name: z.string().min(1),
20
+ description: z.string().optional(),
21
+ inputSchema: z.record(z.string(), z.unknown()).optional(),
22
+ })),
23
+ nextCursor: z.string().nullish(),
24
+ });
25
+ const callToolResultSchema = z.looseObject({
26
+ content: z.array(z.looseObject({ type: z.string(), text: z.string().optional() })).optional(),
27
+ isError: z.boolean().nullish(),
28
+ });
29
+ /** Pagination runaway guard — a server endlessly re-issuing cursors must not hang the run. */
30
+ const MAX_TOOL_PAGES = 100;
31
+ export class McpConnection {
32
+ rpc;
33
+ transport;
34
+ serverName;
35
+ constructor(transport, opts) {
36
+ this.transport = transport;
37
+ this.serverName = opts.serverName;
38
+ this.rpc = new JsonRpcClient(transport, {
39
+ label: opts.serverName,
40
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
41
+ });
42
+ }
43
+ /** The MCP handshake: version negotiation, then the initialized notification. */
44
+ async initialize() {
45
+ const raw = await this.rpc.request("initialize", {
46
+ protocolVersion: SUPPORTED_PROTOCOL_VERSIONS[0],
47
+ capabilities: {},
48
+ clientInfo: { name: "boardwalk-engine", version: "0.1.0" },
49
+ });
50
+ const parsed = initializeResultSchema.safeParse(raw);
51
+ if (!parsed.success) {
52
+ throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" returned a malformed initialize response.`);
53
+ }
54
+ const version = parsed.data.protocolVersion;
55
+ if (!SUPPORTED_PROTOCOL_VERSIONS.some((v) => v === version)) {
56
+ throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" negotiated protocol version "${version}", which this ` +
57
+ `engine does not speak.`, `Supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}.`);
58
+ }
59
+ // The HTTP transport replays the negotiated version as a header on every later request
60
+ // (spec requirement); stdio has no headers — hence the optional structural probe.
61
+ if (hasVersionHook(this.transport))
62
+ this.transport.setProtocolVersion(version);
63
+ await this.rpc.notify("notifications/initialized");
64
+ }
65
+ /** Every tool the server advertises, following nextCursor pagination to the end. */
66
+ async listTools() {
67
+ const tools = [];
68
+ let cursor;
69
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
70
+ const raw = await this.rpc.request("tools/list", cursor !== undefined ? { cursor } : {});
71
+ const parsed = listToolsResultSchema.safeParse(raw);
72
+ if (!parsed.success) {
73
+ throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" returned a malformed tools/list response.`);
74
+ }
75
+ for (const tool of parsed.data.tools) {
76
+ tools.push({
77
+ name: tool.name,
78
+ description: tool.description ?? "",
79
+ // A missing schema means "takes anything" — advertise the loosest valid object schema.
80
+ inputSchema: tool.inputSchema ?? { type: "object" },
81
+ });
82
+ }
83
+ if (parsed.data.nextCursor === undefined || parsed.data.nextCursor === null)
84
+ return tools;
85
+ cursor = parsed.data.nextCursor;
86
+ }
87
+ throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" paginated tools/list past ${String(MAX_TOOL_PAGES)} pages.`);
88
+ }
89
+ /** Invoke a server tool; content is flattened to model-bound text (non-text summarized). */
90
+ async callTool(name, args) {
91
+ const raw = await this.rpc.request("tools/call", { name, arguments: args });
92
+ const parsed = callToolResultSchema.safeParse(raw);
93
+ if (!parsed.success) {
94
+ throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" returned a malformed tools/call response for "${name}".`);
95
+ }
96
+ const content = (parsed.data.content ?? [])
97
+ .map((item) => item.type === "text" && item.text !== undefined ? item.text : `[${item.type}]`)
98
+ .join("\n");
99
+ return { content, isError: parsed.data.isError ?? false };
100
+ }
101
+ /** Tear the connection down (rejects anything in flight; kills/deletes transport state). */
102
+ async close() {
103
+ await this.rpc.close();
104
+ }
105
+ }
106
+ /**
107
+ * Structurally probe for the HTTP transport's version hook without importing it — keeps this
108
+ * file transport-agnostic so test fakes (and the stdio transport) need no stub method.
109
+ */
110
+ function hasVersionHook(transport) {
111
+ return typeof Reflect.get(transport, "setProtocolVersion") === "function";
112
+ }
@@ -0,0 +1,57 @@
1
+ /** A frame this client sends: a request, a notification, or an error reply to a server request. */
2
+ export type JsonRpcOutbound = {
3
+ jsonrpc: "2.0";
4
+ id: number;
5
+ method: string;
6
+ params?: unknown;
7
+ } | {
8
+ jsonrpc: "2.0";
9
+ method: string;
10
+ params?: unknown;
11
+ } | {
12
+ jsonrpc: "2.0";
13
+ id: string | number;
14
+ error: {
15
+ code: number;
16
+ message: string;
17
+ };
18
+ };
19
+ /**
20
+ * What a transport must provide: framing only — no protocol knowledge. Both implementations
21
+ * (stdio child process, streamable HTTP) fit behind this so the client and every test fake
22
+ * are transport-agnostic.
23
+ */
24
+ export interface McpTransport {
25
+ send(message: JsonRpcOutbound): Promise<void>;
26
+ /** Deliver every inbound frame (already JSON-parsed, still untrusted) to the client. */
27
+ onMessage(cb: (message: unknown) => void): void;
28
+ /** Invoked at most once if the transport dies out from under the client (process exit). */
29
+ onClose(cb: (err: Error) => void): void;
30
+ close(): Promise<void>;
31
+ }
32
+ /** Default per-request timeout — a hung MCP server must fail the call, not hold the run. */
33
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
34
+ export interface JsonRpcClientOptions {
35
+ /** Names the peer in every error message (the MCP server's name from the agent() call). */
36
+ label: string;
37
+ timeoutMs?: number;
38
+ }
39
+ export declare class JsonRpcClient {
40
+ private readonly transport;
41
+ private readonly label;
42
+ private readonly timeoutMs;
43
+ private readonly pending;
44
+ private nextId;
45
+ private closed;
46
+ constructor(transport: McpTransport, opts: JsonRpcClientOptions);
47
+ /** Send a request and resolve with its (still-unknown) result; the caller Zod-narrows it. */
48
+ request(method: string, params?: unknown): Promise<unknown>;
49
+ /** Fire-and-forget notification (e.g. `notifications/initialized`). */
50
+ notify(method: string, params?: unknown): Promise<void>;
51
+ /** Close the transport and reject everything in flight — callers must never hang. */
52
+ close(): Promise<void>;
53
+ private handleInbound;
54
+ private settle;
55
+ private failAllPending;
56
+ private connectionError;
57
+ }
@@ -0,0 +1,117 @@
1
+ // JSON-RPC 2.0 request/notification correlation for the hand-rolled MCP client (the
2
+ // @modelcontextprotocol/sdk dependency tree was rejected for the flagship — zero new deps).
3
+ // The transport moves frames; this layer owns ids, correlation, timeouts, and the trust
4
+ // boundary: every inbound frame is Zod-validated before anything dereferences it
5
+ // (CODE_QUALITY §2.1 — an MCP server is as untrusted as any provider).
6
+ import { z } from "zod";
7
+ import { EngineError } from "../errors.js";
8
+ // One loose schema for every inbound frame; classification happens after validation. A frame
9
+ // is a server request/notification (has `method`) or a response (has `id` + result/error).
10
+ const inboundFrameSchema = z.looseObject({
11
+ jsonrpc: z.literal("2.0"),
12
+ id: z.union([z.string(), z.number()]).nullish(),
13
+ method: z.string().optional(),
14
+ result: z.unknown().optional(),
15
+ error: z.looseObject({ code: z.number(), message: z.string() }).optional(),
16
+ });
17
+ /** Default per-request timeout — a hung MCP server must fail the call, not hold the run. */
18
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
19
+ export class JsonRpcClient {
20
+ transport;
21
+ label;
22
+ timeoutMs;
23
+ pending = new Map();
24
+ nextId = 1;
25
+ closed = false;
26
+ constructor(transport, opts) {
27
+ this.transport = transport;
28
+ this.label = opts.label;
29
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
30
+ transport.onMessage((message) => this.handleInbound(message));
31
+ transport.onClose((err) => this.failAllPending(err));
32
+ }
33
+ /** Send a request and resolve with its (still-unknown) result; the caller Zod-narrows it. */
34
+ request(method, params) {
35
+ if (this.closed) {
36
+ return Promise.reject(this.connectionError(`cannot call ${method} — connection is closed`));
37
+ }
38
+ const id = this.nextId++;
39
+ const promise = new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => {
41
+ this.pending.delete(id);
42
+ reject(this.connectionError(`"${method}" timed out after ${String(this.timeoutMs / 1000)}s — the server never answered`));
43
+ }, this.timeoutMs);
44
+ this.pending.set(id, { method, resolve, reject, timer });
45
+ });
46
+ this.transport
47
+ .send({ jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) })
48
+ .catch((err) => {
49
+ this.settle(id, (pending) => pending.reject(err instanceof Error ? err : this.connectionError(String(err))));
50
+ });
51
+ return promise;
52
+ }
53
+ /** Fire-and-forget notification (e.g. `notifications/initialized`). */
54
+ async notify(method, params) {
55
+ await this.transport.send({
56
+ jsonrpc: "2.0",
57
+ method,
58
+ ...(params !== undefined ? { params } : {}),
59
+ });
60
+ }
61
+ /** Close the transport and reject everything in flight — callers must never hang. */
62
+ async close() {
63
+ if (this.closed)
64
+ return;
65
+ this.closed = true;
66
+ this.failAllPending(this.connectionError("connection closed"));
67
+ await this.transport.close();
68
+ }
69
+ handleInbound(message) {
70
+ const parsed = inboundFrameSchema.safeParse(message);
71
+ if (!parsed.success)
72
+ return; // not a JSON-RPC 2.0 frame — drop, never crash on peer noise
73
+ const frame = parsed.data;
74
+ if (frame.method !== undefined) {
75
+ // A server→client request (sampling, roots, …): this minimal client implements none, so
76
+ // answer "method not found" rather than leaving the server hanging. Notifications
77
+ // (no id) are dropped — nothing here consumes them yet.
78
+ if (frame.id !== undefined && frame.id !== null) {
79
+ void this.transport
80
+ .send({
81
+ jsonrpc: "2.0",
82
+ id: frame.id,
83
+ error: { code: -32601, message: "This client supports no server-initiated requests" },
84
+ })
85
+ .catch(() => {
86
+ // The reply is best-effort courtesy; a dead transport already failed the pending map.
87
+ });
88
+ }
89
+ return;
90
+ }
91
+ if (typeof frame.id !== "number")
92
+ return; // our ids are numeric; anything else isn't ours
93
+ const id = frame.id;
94
+ if (frame.error !== undefined) {
95
+ const { code, message } = frame.error;
96
+ this.settle(id, (pending) => pending.reject(this.connectionError(`"${pending.method}" failed: ${message} (code ${String(code)})`)));
97
+ return;
98
+ }
99
+ this.settle(id, (pending) => pending.resolve(frame.result));
100
+ }
101
+ settle(id, fn) {
102
+ const pending = this.pending.get(id);
103
+ if (pending === undefined)
104
+ return; // already timed out / unknown id — nothing to do
105
+ this.pending.delete(id);
106
+ clearTimeout(pending.timer);
107
+ fn(pending);
108
+ }
109
+ failAllPending(err) {
110
+ for (const [id] of [...this.pending]) {
111
+ this.settle(id, (pending) => pending.reject(err));
112
+ }
113
+ }
114
+ connectionError(detail) {
115
+ return new EngineError("PROVIDER_ERROR", `MCP server "${this.label}": ${detail}.`);
116
+ }
117
+ }
@@ -0,0 +1,72 @@
1
+ import { type McpTokenStore } from "./token_store.js";
2
+ export interface OAuthIo {
3
+ fetchImpl?: typeof fetch | undefined;
4
+ }
5
+ export interface PkcePair {
6
+ verifier: string;
7
+ challenge: string;
8
+ }
9
+ /** A fresh high-entropy verifier and its S256 challenge. */
10
+ export declare function createPkcePair(): PkcePair;
11
+ /** The S256 transform, exported so tests can verify the challenge against the verifier. */
12
+ export declare function s256Challenge(verifier: string): string;
13
+ /**
14
+ * Extract the parameters of a Bearer challenge (`resource_metadata`, `error`, …). Returns an
15
+ * empty record for non-Bearer or malformed headers — discovery then falls back to the
16
+ * well-known path, so a sloppy header degrades gracefully instead of failing authorization.
17
+ */
18
+ export declare function parseBearerChallenge(header: string): Record<string, string>;
19
+ export interface AuthorizationDiscovery {
20
+ authorizationEndpoint: string;
21
+ tokenEndpoint: string;
22
+ registrationEndpoint: string | undefined;
23
+ /** The RFC 8707 resource indicator to bind grants to (canonical server URL by default). */
24
+ resource: string;
25
+ }
26
+ /**
27
+ * Find the authorization server for an MCP server: probe it unauthenticated, follow the 401's
28
+ * `resource_metadata` hint (RFC 9728), fall back to the well-known protected-resource path,
29
+ * and finally — for pre-9728 servers — treat the server's own origin as the issuer (the
30
+ * 2025-03-26 MCP default). Failing any later step is loud: authorization can't be guessed.
31
+ */
32
+ export declare function discoverAuthorization(serverUrl: string, io?: OAuthIo): Promise<AuthorizationDiscovery>;
33
+ /** Register a public client (no secret — token_endpoint_auth_method "none", per OAuth 2.1 CLI practice). */
34
+ export declare function registerClient(registrationEndpoint: string, redirectUri: string, io?: OAuthIo): Promise<string>;
35
+ export interface TokenGrant {
36
+ accessToken: string;
37
+ refreshToken: string | undefined;
38
+ /** Epoch ms, derived from expires_in at grant time. Undefined when the AS declared none. */
39
+ expiresAt: number | undefined;
40
+ }
41
+ export interface ExchangeCodeArgs {
42
+ tokenEndpoint: string;
43
+ clientId: string;
44
+ code: string;
45
+ redirectUri: string;
46
+ codeVerifier: string;
47
+ resource: string;
48
+ }
49
+ export declare function exchangeAuthorizationCode(args: ExchangeCodeArgs, io?: OAuthIo): Promise<TokenGrant>;
50
+ export interface RefreshTokenArgs {
51
+ tokenEndpoint: string;
52
+ clientId: string;
53
+ refreshToken: string;
54
+ resource: string | undefined;
55
+ }
56
+ export declare function refreshAccessToken(args: RefreshTokenArgs, io?: OAuthIo): Promise<TokenGrant>;
57
+ export interface AuthorizeFlowArgs {
58
+ serverUrl: string;
59
+ store: McpTokenStore;
60
+ /** Hands the URL a human must open to the caller (CLI prints it, a UI links it). */
61
+ onAuthorizationUrl: (url: string) => void;
62
+ /** How long to wait for the human. Default 5 minutes. */
63
+ timeoutMs?: number;
64
+ fetchImpl?: typeof fetch;
65
+ }
66
+ /**
67
+ * The one-time interactive step: discovery → dynamic registration → PKCE authorization-code
68
+ * grant with a loopback redirect → tokens persisted to the store. Resolves when the grant is
69
+ * stored; rejects on state mismatch, an AS `error` redirect, or timeout. After this, runs use
70
+ * the server headlessly (silent refresh included) until the grant dies.
71
+ */
72
+ export declare function runAuthorizationFlow(args: AuthorizeFlowArgs): Promise<void>;