okengine 0.13.0 → 0.14.1

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 (105) hide show
  1. package/manifest.v1.schema.json +7 -1
  2. package/package.json +2 -2
  3. package/site/content/docs/ai/mcp.mdx +27 -2
  4. package/site/content/docs/elements/ai.mdx +58 -11
  5. package/site/content/docs/elements/clock.mdx +27 -12
  6. package/site/content/docs/elements/flow.mdx +6 -3
  7. package/site/content/docs/elements/signal.mdx +71 -25
  8. package/site/content/docs/elements/store.mdx +1 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/reference/fx.mdx +27 -22
  11. package/src/cli/ai-setup/recommend.test.ts +25 -0
  12. package/src/cli/ai-setup/recommend.ts +8 -3
  13. package/src/compiler/effects-infer.ts +58 -3
  14. package/src/compiler/extract.test.ts +54 -1
  15. package/src/compiler/extract.ts +69 -2
  16. package/src/compiler/response.ts +45 -1
  17. package/src/console/server/ai.ts +5 -2
  18. package/src/console/server/flows.ts +1 -0
  19. package/src/console/server/serve.ts +17 -2
  20. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  21. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  22. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  23. package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
  24. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  25. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  26. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  27. package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
  28. package/src/console/ui-next/dist/index.html +1 -1
  29. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  30. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  31. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  32. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  33. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  34. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  35. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  36. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  37. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  38. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  39. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  40. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  41. package/src/docker/docker.test.ts +1 -1
  42. package/src/docker/dockerfile.ts +1 -1
  43. package/src/drivers/ai-anthropic.ts +5 -0
  44. package/src/drivers/ai-ollama.ts +49 -30
  45. package/src/drivers/ai-openai-compatible.ts +57 -46
  46. package/src/drivers/ai-providers.test.ts +3 -0
  47. package/src/drivers/bun-native-completeness.test.ts +7 -9
  48. package/src/drivers/redis.ts +11 -4
  49. package/src/drivers/signal-redis.ts +24 -14
  50. package/src/drivers/signal-types.ts +2 -1
  51. package/src/elements/ai/declare.ts +109 -0
  52. package/src/elements/ai/errors.test.ts +5 -1
  53. package/src/elements/ai/errors.ts +30 -2
  54. package/src/elements/ai/eval.ts +4 -6
  55. package/src/elements/ai/mcp-client.test.ts +206 -0
  56. package/src/elements/ai/mcp-client.ts +362 -0
  57. package/src/elements/ai/mcp-http.ts +159 -0
  58. package/src/elements/ai/mcp-mock.ts +134 -0
  59. package/src/elements/ai/mcp-protocol.ts +234 -0
  60. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  61. package/src/elements/ai/mcp-stdio.ts +212 -0
  62. package/src/elements/ai/mcp-transport.ts +70 -0
  63. package/src/elements/ai/runtime.ts +159 -29
  64. package/src/elements/ai.test.ts +139 -0
  65. package/src/elements/ai.ts +16 -0
  66. package/src/elements/clock/health.test.ts +43 -0
  67. package/src/elements/clock/runtime.ts +55 -2
  68. package/src/elements/clock/schedule.ts +71 -142
  69. package/src/elements/clock.test.ts +9 -40
  70. package/src/elements/clock.ts +6 -1
  71. package/src/elements/index.ts +2 -0
  72. package/src/elements/signal/declare.ts +2 -1
  73. package/src/elements/signal/runtime.ts +16 -1
  74. package/src/elements/signal.ts +1 -0
  75. package/src/elements/store/cache.test.ts +2 -0
  76. package/src/elements/store/cache.ts +3 -3
  77. package/src/elements/vault/builtin-adapter.ts +17 -0
  78. package/src/full.ts +2 -0
  79. package/src/index.ts +4 -0
  80. package/src/kernel/app.ts +97 -64
  81. package/src/kernel/auto-registry.test.ts +5 -0
  82. package/src/kernel/boot-bind/ai.ts +24 -0
  83. package/src/kernel/boot-bind/clock.ts +2 -0
  84. package/src/kernel/boot.ts +3 -1
  85. package/src/kernel/element-registries.ts +4 -1
  86. package/src/kernel/fx-dead-letters.test.ts +77 -0
  87. package/src/kernel/fx.test.ts +22 -0
  88. package/src/kernel/fx.ts +112 -6
  89. package/src/kernel/http-stream.test.ts +174 -0
  90. package/src/kernel/index.ts +2 -0
  91. package/src/manifest/mcp-ref.ts +88 -0
  92. package/src/manifest/types.ts +28 -2
  93. package/src/manifest/validate.test.ts +20 -0
  94. package/src/mcp/docs-server.ts +1 -1
  95. package/src/mcp/server.ts +1 -1
  96. package/src/plugins/compression.test.ts +21 -0
  97. package/src/plugins/compression.ts +1 -0
  98. package/src/runtime/bun.ts +41 -4
  99. package/src/test/reset-element-registries.ts +2 -0
  100. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  101. package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
  102. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  103. package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
  104. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  105. package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Streamable HTTP MCP transport — POST JSON-RPC, optional SSE response.
3
+ *
4
+ * Cancel = abort `fetch` / close the SSE stream. Never POST
5
+ * `notifications/cancelled` on HTTP.
6
+ */
7
+
8
+ import { abortError } from "../../kernel/abort-scope.ts";
9
+ import { parseJsonRpcResponse } from "./mcp-protocol.ts";
10
+ import {
11
+ McpTransportError,
12
+ type McpTransport,
13
+ type McpTransportRequest,
14
+ type McpWireResult,
15
+ } from "./mcp-transport.ts";
16
+
17
+ /** Options for {@link createMcpHttpTransport}. */
18
+ export interface CreateMcpHttpTransportOptions {
19
+ readonly url: string;
20
+ readonly bearer?: string;
21
+ readonly fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ /**
25
+ * Create a Streamable HTTP transport for one MCP origin.
26
+ *
27
+ * @param options - URL + optional bearer
28
+ */
29
+ export function createMcpHttpTransport(options: CreateMcpHttpTransportOptions): McpTransport {
30
+ const fetchImpl = options.fetchImpl ?? fetch;
31
+ return {
32
+ kind: "http",
33
+ async request(request: McpTransportRequest): Promise<McpWireResult> {
34
+ if (request.signal?.aborted) throw abortError(request.signal.reason);
35
+ const headers: Record<string, string> = {
36
+ "content-type": "application/json",
37
+ accept: "application/json, text/event-stream",
38
+ ...request.headers,
39
+ };
40
+ if (options.bearer) headers.authorization = `Bearer ${options.bearer}`;
41
+ const body = JSON.stringify({
42
+ jsonrpc: "2.0",
43
+ id: request.id,
44
+ method: request.method,
45
+ ...(request.params !== undefined ? { params: request.params } : {}),
46
+ });
47
+ let response: Response;
48
+ try {
49
+ response = await fetchImpl(options.url, {
50
+ method: "POST",
51
+ headers,
52
+ body,
53
+ signal: request.signal,
54
+ });
55
+ } catch (err) {
56
+ if (request.signal?.aborted || (err instanceof Error && err.name === "AbortError")) {
57
+ throw abortError(request.signal?.reason ?? err);
58
+ }
59
+ throw new McpTransportError(
60
+ err instanceof Error ? err.message : "ai.mcp: HTTP request failed",
61
+ { network: true },
62
+ );
63
+ }
64
+
65
+ const sessionId = response.headers.get("mcp-session-id") ?? undefined;
66
+ const ctype = response.headers.get("content-type") ?? "";
67
+ const rawBody = ctype.includes("text/event-stream")
68
+ ? await readSseJsonRpc(response, request.id, request.signal)
69
+ : await readJsonBody(response);
70
+
71
+ const parsed = parseJsonRpcResponse(rawBody);
72
+ if (parsed?.ok) {
73
+ return {
74
+ ok: true,
75
+ result: parsed.result,
76
+ httpStatus: response.status,
77
+ ...(sessionId !== undefined ? { sessionId } : {}),
78
+ rawBody,
79
+ };
80
+ }
81
+ if (parsed && !parsed.ok) {
82
+ return {
83
+ ok: false,
84
+ error: parsed.error,
85
+ httpStatus: response.status,
86
+ ...(sessionId !== undefined ? { sessionId } : {}),
87
+ rawBody,
88
+ };
89
+ }
90
+ return {
91
+ ok: false,
92
+ httpStatus: response.status,
93
+ ...(sessionId !== undefined ? { sessionId } : {}),
94
+ rawBody,
95
+ };
96
+ },
97
+ async close() {
98
+ /* HTTP is per-request */
99
+ },
100
+ };
101
+ }
102
+
103
+ async function readJsonBody(response: Response): Promise<unknown> {
104
+ const text = await response.text();
105
+ if (text.length === 0) return undefined;
106
+ try {
107
+ return JSON.parse(text) as unknown;
108
+ } catch {
109
+ return text;
110
+ }
111
+ }
112
+
113
+ async function readSseJsonRpc(
114
+ response: Response,
115
+ id: string | number,
116
+ signal?: AbortSignal,
117
+ ): Promise<unknown> {
118
+ let buf = "";
119
+ let last: unknown;
120
+ for await (const piece of responseTextStream(response)) {
121
+ if (signal?.aborted) throw abortError(signal.reason);
122
+ buf += piece;
123
+ const parts = buf.split("\n\n");
124
+ buf = parts.pop() ?? "";
125
+ for (const part of parts) {
126
+ const data = part
127
+ .split("\n")
128
+ .filter((line) => line.startsWith("data:"))
129
+ .map((line) => line.slice(5).trimStart())
130
+ .join("\n");
131
+ if (!data || data === "[DONE]") continue;
132
+ try {
133
+ const json: unknown = JSON.parse(data);
134
+ if (
135
+ json !== null &&
136
+ typeof json === "object" &&
137
+ !Array.isArray(json) &&
138
+ (json as { id?: unknown }).id === id
139
+ ) {
140
+ last = json;
141
+ }
142
+ } catch {
143
+ /* skip malformed frames */
144
+ }
145
+ }
146
+ }
147
+ if (last === undefined) {
148
+ throw new Error("ai.mcp: SSE stream ended without a matching response");
149
+ }
150
+ return last;
151
+ }
152
+
153
+ function responseTextStream(res: Response): AsyncIterable<string> {
154
+ const stream = (res as Response & { textStream?: () => AsyncIterable<string> }).textStream;
155
+ if (typeof stream !== "function") {
156
+ throw new Error("ai.mcp: Response.textStream is required (Bun >= 1.4.0)");
157
+ }
158
+ return stream.call(res);
159
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * In-process MCP transport for tests — no child process, no network.
3
+ */
4
+
5
+ import { abortError, abortableSleep } from "../../kernel/abort-scope.ts";
6
+ import type { McpListedTool, McpRpcError } from "./mcp-protocol.ts";
7
+ import {
8
+ type McpTransport,
9
+ type McpTransportRequest,
10
+ type McpWireResult,
11
+ } from "./mcp-transport.ts";
12
+
13
+ /** One tool the mock server exposes. */
14
+ export interface MockMcpTool extends McpListedTool {}
15
+
16
+ /** Options for {@link createMockMcpTransport}. */
17
+ export interface CreateMockMcpTransportOptions {
18
+ readonly era?: "modern" | "legacy";
19
+ readonly tools?: readonly MockMcpTool[];
20
+ /** HTTP status of the first request (default 200). */
21
+ readonly httpStatusOnFirst?: number;
22
+ /** JSON-RPC error on the first request. */
23
+ readonly firstError?: McpRpcError;
24
+ /** Unrecognized first-request body (400 without a modern error object). */
25
+ readonly firstBody?: unknown;
26
+ readonly onCall?: (name: string, args: unknown, signal?: AbortSignal) => unknown;
27
+ readonly delayMs?: number;
28
+ /** `server/discover` hangs until abort / never resolves. */
29
+ readonly discoverHang?: boolean;
30
+ readonly extraTools?: readonly MockMcpTool[];
31
+ }
32
+
33
+ /**
34
+ * Create a mock MCP transport.
35
+ *
36
+ * @param options - Era / tools / first-request fault
37
+ */
38
+ export function createMockMcpTransport(options: CreateMockMcpTransportOptions = {}): McpTransport {
39
+ let requestCount = 0;
40
+ const era = options.era ?? "modern";
41
+ const tools = [...(options.tools ?? []), ...(options.extraTools ?? [])];
42
+
43
+ return {
44
+ kind: "mock",
45
+ async request(request: McpTransportRequest): Promise<McpWireResult> {
46
+ if (request.signal?.aborted) throw abortError(request.signal.reason);
47
+ requestCount += 1;
48
+ if (options.delayMs) {
49
+ await abortableSleep(options.delayMs, request.signal);
50
+ }
51
+ if (options.discoverHang && request.method === "server/discover") {
52
+ await hangUntilAbort(request.signal);
53
+ }
54
+
55
+ if (requestCount === 1 && options.httpStatusOnFirst !== undefined) {
56
+ if (options.firstError) {
57
+ return {
58
+ ok: false,
59
+ error: options.firstError,
60
+ httpStatus: options.httpStatusOnFirst,
61
+ rawBody: { jsonrpc: "2.0", id: request.id, error: options.firstError },
62
+ };
63
+ }
64
+ return {
65
+ ok: false,
66
+ httpStatus: options.httpStatusOnFirst,
67
+ rawBody: options.firstBody,
68
+ };
69
+ }
70
+
71
+ if (request.method === "initialize") {
72
+ if (era === "modern") {
73
+ return {
74
+ ok: false,
75
+ error: { code: -32022, message: "UnsupportedProtocolVersion" },
76
+ httpStatus: 400,
77
+ };
78
+ }
79
+ return {
80
+ ok: true,
81
+ result: {
82
+ protocolVersion: "2024-11-05",
83
+ capabilities: { tools: {} },
84
+ serverInfo: { name: "mock", version: "0" },
85
+ },
86
+ httpStatus: 200,
87
+ sessionId: "mock-session",
88
+ };
89
+ }
90
+
91
+ if (request.method === "server/discover" || request.method === "tools/list") {
92
+ return {
93
+ ok: true,
94
+ result: { tools },
95
+ httpStatus: 200,
96
+ };
97
+ }
98
+
99
+ if (request.method === "tools/call") {
100
+ const params = request.params as { name?: unknown; arguments?: unknown } | undefined;
101
+ const name = typeof params?.name === "string" ? params.name : "";
102
+ const output = options.onCall
103
+ ? await Promise.resolve(options.onCall(name, params?.arguments ?? {}, request.signal))
104
+ : { content: [{ type: "text", text: `ok:${name}` }] };
105
+ return { ok: true, result: output, httpStatus: 200 };
106
+ }
107
+
108
+ return {
109
+ ok: false,
110
+ error: { code: -32601, message: `method not found: ${request.method}` },
111
+ httpStatus: 200,
112
+ };
113
+ },
114
+ async close() {
115
+ /* mock */
116
+ },
117
+ };
118
+ }
119
+
120
+ function hangUntilAbort(signal?: AbortSignal): Promise<never> {
121
+ return new Promise((_, reject) => {
122
+ if (signal?.aborted) {
123
+ reject(abortError(signal.reason));
124
+ return;
125
+ }
126
+ signal?.addEventListener(
127
+ "abort",
128
+ () => {
129
+ reject(abortError(signal.reason));
130
+ },
131
+ { once: true },
132
+ );
133
+ });
134
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * MCP client protocol — 2026-07-28 happy path + dual-era fallback types.
3
+ *
4
+ * Reuses only the JSON-RPC envelope. Does not import the app MCP *server*
5
+ * dialect (`2024-11-05` initialize) as the client version.
6
+ */
7
+
8
+ /** Current MCP protocol version (stateless core). */
9
+ export const MCP_CLIENT_PROTOCOL_VERSION = "2026-07-28";
10
+
11
+ /** Legacy initialize dialect still in production. */
12
+ export const MCP_LEGACY_PROTOCOL_VERSION = "2024-11-05";
13
+
14
+ /** JSON-RPC request id. */
15
+ export type JsonRpcId = string | number;
16
+
17
+ /** Modern protocol era vs initialize handshake. */
18
+ export type McpProtocolEra = "modern" | "legacy";
19
+
20
+ /** JSON-RPC error object. */
21
+ export interface McpRpcError {
22
+ readonly code: number;
23
+ readonly message: string;
24
+ readonly data?: unknown;
25
+ }
26
+
27
+ /** Modern MCP error codes that are *not* a cue to `initialize`. */
28
+ export const McpModernErrorCode = {
29
+ headerMismatch: -32020,
30
+ missingRequiredClientCapability: -32021,
31
+ unsupportedProtocolVersion: -32022,
32
+ /** Resource not found — remapped to invalid params; not version negotiation. */
33
+ resourceNotFound: -32002,
34
+ } as const;
35
+
36
+ /** Per-request `_meta` for the 2026-07-28 stateless core. */
37
+ export interface McpClientMeta {
38
+ readonly "io.modelcontextprotocol/protocolVersion": typeof MCP_CLIENT_PROTOCOL_VERSION;
39
+ readonly clientCapabilities: {
40
+ readonly extensions: Readonly<Record<string, never>>;
41
+ };
42
+ readonly clientInfo: {
43
+ readonly name: string;
44
+ readonly version: string;
45
+ };
46
+ }
47
+
48
+ /** Tool listed by `tools/list`. */
49
+ export interface McpListedTool {
50
+ readonly name: string;
51
+ readonly description?: string;
52
+ readonly inputSchema?: unknown;
53
+ readonly outputSchema?: unknown;
54
+ }
55
+
56
+ /** Parsed JSON-RPC success or error. */
57
+ export type ParsedJsonRpcResponse =
58
+ | { readonly ok: true; readonly id: JsonRpcId | null; readonly result: unknown }
59
+ | { readonly ok: false; readonly id: JsonRpcId | null; readonly error: McpRpcError };
60
+
61
+ /**
62
+ * Build the required per-request `_meta` bag.
63
+ *
64
+ * @param version - Client package version
65
+ */
66
+ export function mcpClientMeta(version: string): McpClientMeta {
67
+ return {
68
+ "io.modelcontextprotocol/protocolVersion": MCP_CLIENT_PROTOCOL_VERSION,
69
+ clientCapabilities: { extensions: {} },
70
+ clientInfo: { name: "okengine", version },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * True when a 400 JSON-RPC error is a modern protocol reject (do not initialize).
76
+ *
77
+ * @param code - JSON-RPC error code
78
+ */
79
+ export function isModernProtocolReject(code: number): boolean {
80
+ return (
81
+ code === McpModernErrorCode.unsupportedProtocolVersion ||
82
+ code === McpModernErrorCode.headerMismatch ||
83
+ code === McpModernErrorCode.missingRequiredClientCapability
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Parse a JSON-RPC 2.0 response object.
89
+ *
90
+ * @param body - Decoded JSON
91
+ */
92
+ export function parseJsonRpcResponse(body: unknown): ParsedJsonRpcResponse | null {
93
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
94
+ const o = body as Record<string, unknown>;
95
+ if (o.jsonrpc !== "2.0") return null;
96
+ const id = o.id;
97
+ if (id !== null && id !== undefined && typeof id !== "string" && typeof id !== "number") {
98
+ return null;
99
+ }
100
+ const rid = (id ?? null) as JsonRpcId | null;
101
+ if ("error" in o) {
102
+ const err = o.error;
103
+ if (err === null || typeof err !== "object" || Array.isArray(err)) return null;
104
+ const e = err as Record<string, unknown>;
105
+ if (typeof e.code !== "number" || typeof e.message !== "string") return null;
106
+ return {
107
+ ok: false,
108
+ id: rid,
109
+ error: {
110
+ code: e.code,
111
+ message: e.message,
112
+ ...(e.data !== undefined ? { data: e.data } : {}),
113
+ },
114
+ };
115
+ }
116
+ if (!("result" in o)) return null;
117
+ return { ok: true, id: rid, result: o.result };
118
+ }
119
+
120
+ /**
121
+ * Parse `tools/list` result.tools.
122
+ *
123
+ * @param result - RPC result
124
+ */
125
+ export function parseToolsListResult(result: unknown): McpListedTool[] {
126
+ if (result === null || typeof result !== "object" || Array.isArray(result)) return [];
127
+ const tools = (result as Record<string, unknown>).tools;
128
+ if (!Array.isArray(tools)) return [];
129
+ const out: McpListedTool[] = [];
130
+ for (const raw of tools) {
131
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) continue;
132
+ const t = raw as Record<string, unknown>;
133
+ if (typeof t.name !== "string" || t.name.length === 0) continue;
134
+ out.push({
135
+ name: t.name,
136
+ ...(typeof t.description === "string" ? { description: t.description } : {}),
137
+ ...(t.inputSchema !== undefined ? { inputSchema: t.inputSchema } : {}),
138
+ ...(t.outputSchema !== undefined ? { outputSchema: t.outputSchema } : {}),
139
+ });
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Interpret `tools/call` result. Missing `resultType` means `"complete"`.
146
+ *
147
+ * @param result - RPC result
148
+ */
149
+ export function interpretToolsCallResult(result: unknown): unknown {
150
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
151
+ return result;
152
+ }
153
+ const o = result as Record<string, unknown>;
154
+ const resultType = o.resultType;
155
+ if (resultType === undefined || resultType === "complete") return result;
156
+ if (resultType === "input_required") {
157
+ throw new Error("ai.mcp: tool returned input_required (HITL is not supported)");
158
+ }
159
+ const label = typeof resultType === "string" ? resultType : JSON.stringify(resultType);
160
+ throw new Error(`ai.mcp: unrecognized resultType "${label}"`);
161
+ }
162
+
163
+ /**
164
+ * Valid HTTP header token (RFC 9110).
165
+ *
166
+ * @param name - Candidate header name
167
+ */
168
+ export function isHttpHeaderToken(name: string): boolean {
169
+ return name.length > 0 && /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/.test(name);
170
+ }
171
+
172
+ /**
173
+ * Collect `x-mcp-header` params → `Mcp-Param-*` headers.
174
+ * Returns null when a declared header name is invalid (caller drops the tool).
175
+ *
176
+ * @param inputSchema - Tool JSON schema
177
+ * @param args - Call arguments
178
+ */
179
+ export function mcpParamHeaders(
180
+ inputSchema: unknown,
181
+ args: Record<string, unknown>,
182
+ ): { readonly headers: Record<string, string>; readonly body: Record<string, unknown> } | null {
183
+ const props = schemaProperties(inputSchema);
184
+ if (!props) return { headers: {}, body: { ...args } };
185
+ const headers: Record<string, string> = {};
186
+ const body: Record<string, unknown> = { ...args };
187
+ for (const [key, schema] of Object.entries(props)) {
188
+ const header = headerNameFromSchema(key, schema);
189
+ if (header === undefined) continue;
190
+ if (header === null) return null;
191
+ const value = args[key];
192
+ if (value === undefined) continue;
193
+ headers[header] = typeof value === "string" ? value : JSON.stringify(value);
194
+ delete body[key];
195
+ }
196
+ return { headers, body };
197
+ }
198
+
199
+ /**
200
+ * True when the tool's `x-mcp-header` annotations are all valid.
201
+ *
202
+ * @param inputSchema - Tool JSON schema
203
+ */
204
+ export function mcpHeaderAnnotationsValid(inputSchema: unknown): boolean {
205
+ return mcpParamHeaders(inputSchema, {}) !== null;
206
+ }
207
+
208
+ function schemaProperties(inputSchema: unknown): Record<string, unknown> | undefined {
209
+ if (inputSchema === null || typeof inputSchema !== "object" || Array.isArray(inputSchema)) {
210
+ return undefined;
211
+ }
212
+ const props = (inputSchema as Record<string, unknown>).properties;
213
+ if (props === null || typeof props !== "object" || Array.isArray(props)) return undefined;
214
+ return props as Record<string, unknown>;
215
+ }
216
+
217
+ function headerNameFromSchema(key: string, schema: unknown): string | null | undefined {
218
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return undefined;
219
+ const raw = (schema as Record<string, unknown>)["x-mcp-header"];
220
+ if (raw === undefined || raw === false) return undefined;
221
+ const name =
222
+ raw === true
223
+ ? `Mcp-Param-${key
224
+ .split(/[-_]/)
225
+ .map((part) => (part[0] ? part[0].toUpperCase() + part.slice(1) : ""))
226
+ .join("")}`
227
+ : typeof raw === "string"
228
+ ? raw
229
+ : null;
230
+ if (name === null || !isHttpHeaderToken(name) || !name.toLowerCase().startsWith("mcp-param-")) {
231
+ return null;
232
+ }
233
+ return name;
234
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * stdio MCP transport — cancel writes notifications/cancelled then kills.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { createMcpStdioTransport, type StdioChild } from "./mcp-stdio.ts";
7
+
8
+ describe("mcp stdio cancel", () => {
9
+ test("abort sends notifications/cancelled then kills the process", async () => {
10
+ const writes: string[] = [];
11
+ let killed = false;
12
+ const stdin = new TransformStream<Uint8Array, Uint8Array>();
13
+ const stdout = new TransformStream<Uint8Array, Uint8Array>();
14
+ const reader = stdin.readable.getReader();
15
+ const decoder = new TextDecoder();
16
+ void (async () => {
17
+ while (true) {
18
+ const { done, value } = await reader.read();
19
+ if (done) break;
20
+ writes.push(decoder.decode(value));
21
+ }
22
+ })();
23
+
24
+ const child: StdioChild = {
25
+ stdin: stdin.writable,
26
+ stdout: stdout.readable,
27
+ kill() {
28
+ killed = true;
29
+ },
30
+ };
31
+ const transport = createMcpStdioTransport({
32
+ command: "mcp-server",
33
+ spawn: () => child,
34
+ });
35
+ const ctrl = new AbortController();
36
+ const pending = transport.request({
37
+ id: 1,
38
+ method: "tools/call",
39
+ params: { name: "create_issue" },
40
+ headers: {},
41
+ signal: ctrl.signal,
42
+ cancelMode: "stdio",
43
+ });
44
+ await new Promise((r) => setTimeout(r, 10));
45
+ ctrl.abort();
46
+ await expect(pending).rejects.toMatchObject({ name: "AbortError" });
47
+ expect(killed).toBe(true);
48
+ expect(writes.some((w) => w.includes("notifications/cancelled"))).toBe(true);
49
+ });
50
+ });