okengine 0.12.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 (127) 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 +21 -1
  9. package/site/content/docs/get-started/installation.mdx +2 -2
  10. package/site/content/docs/providers/index.mdx +1 -0
  11. package/site/content/docs/recipes/dragonfly.mdx +4 -3
  12. package/site/content/docs/recipes/redis.mdx +4 -5
  13. package/site/content/docs/recipes/valkey.mdx +5 -3
  14. package/site/content/docs/reference/configuration.mdx +3 -1
  15. package/site/content/docs/reference/environment-variables.mdx +5 -5
  16. package/site/content/docs/reference/fx.mdx +27 -22
  17. package/src/cli/ai-setup/recommend.test.ts +25 -0
  18. package/src/cli/ai-setup/recommend.ts +8 -3
  19. package/src/cli/load-config.images.test.ts +1 -0
  20. package/src/compiler/effects-infer.ts +58 -3
  21. package/src/compiler/extract.test.ts +68 -1
  22. package/src/compiler/extract.ts +70 -2
  23. package/src/compiler/response.ts +45 -1
  24. package/src/console/server/ai.ts +5 -2
  25. package/src/console/server/flows.ts +1 -0
  26. package/src/console/server/serve.ts +17 -2
  27. package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
  28. package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
  29. package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
  30. package/src/console/ui-next/dist/assets/{index-DX248G39.js → index-Ce6WKWKM.js} +3 -3
  31. package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
  32. package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
  33. package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
  34. package/src/console/ui-next/dist/assets/{vault-page-CSOYMHhO.js → vault-page-DuKzqwzW.js} +1 -1
  35. package/src/console/ui-next/dist/index.html +1 -1
  36. package/src/console/ui-next/seed-invoke-host.ts +2 -0
  37. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
  38. package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
  39. package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
  40. package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
  41. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
  42. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  43. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
  44. package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
  45. package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
  46. package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
  47. package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
  48. package/src/docker/docker.test.ts +1 -1
  49. package/src/docker/dockerfile.ts +1 -1
  50. package/src/docker/helpers.ts +28 -0
  51. package/src/docker/recipes/dragonfly.ts +3 -6
  52. package/src/docker/recipes/redis.ts +2 -6
  53. package/src/docker/recipes/valkey.ts +2 -6
  54. package/src/drivers/ai-anthropic.ts +5 -0
  55. package/src/drivers/ai-ollama.ts +49 -30
  56. package/src/drivers/ai-openai-compatible.ts +57 -46
  57. package/src/drivers/ai-providers.test.ts +3 -0
  58. package/src/drivers/bun-native-completeness.test.ts +7 -9
  59. package/src/drivers/redis.ts +11 -4
  60. package/src/drivers/signal-redis.ts +24 -14
  61. package/src/drivers/signal-types.ts +2 -1
  62. package/src/drivers/types.ts +1 -1
  63. package/src/elements/ai/declare.ts +109 -0
  64. package/src/elements/ai/errors.test.ts +5 -1
  65. package/src/elements/ai/errors.ts +30 -2
  66. package/src/elements/ai/eval.ts +4 -6
  67. package/src/elements/ai/mcp-client.test.ts +206 -0
  68. package/src/elements/ai/mcp-client.ts +362 -0
  69. package/src/elements/ai/mcp-http.ts +159 -0
  70. package/src/elements/ai/mcp-mock.ts +134 -0
  71. package/src/elements/ai/mcp-protocol.ts +234 -0
  72. package/src/elements/ai/mcp-stdio.test.ts +50 -0
  73. package/src/elements/ai/mcp-stdio.ts +212 -0
  74. package/src/elements/ai/mcp-transport.ts +70 -0
  75. package/src/elements/ai/runtime.ts +159 -29
  76. package/src/elements/ai.test.ts +139 -0
  77. package/src/elements/ai.ts +16 -0
  78. package/src/elements/clock/health.test.ts +43 -0
  79. package/src/elements/clock/runtime.ts +55 -2
  80. package/src/elements/clock/schedule.ts +71 -142
  81. package/src/elements/clock.test.ts +9 -40
  82. package/src/elements/clock.ts +6 -1
  83. package/src/elements/gate/runtime.ts +3 -3
  84. package/src/elements/index.ts +2 -0
  85. package/src/elements/signal/declare.ts +2 -1
  86. package/src/elements/signal/runtime.ts +16 -1
  87. package/src/elements/signal.ts +1 -0
  88. package/src/elements/store/cache.test.ts +2 -0
  89. package/src/elements/store/cache.ts +3 -3
  90. package/src/elements/store/declare.ts +7 -0
  91. package/src/elements/store/kv-sql.test.ts +86 -0
  92. package/src/elements/store/kv-sql.ts +178 -0
  93. package/src/elements/store/runtime.ts +35 -9
  94. package/src/elements/store.test.ts +2 -0
  95. package/src/elements/vault/builtin-adapter.ts +17 -0
  96. package/src/full.ts +2 -0
  97. package/src/index.ts +4 -0
  98. package/src/kernel/app.ts +97 -64
  99. package/src/kernel/auto-registry.test.ts +5 -0
  100. package/src/kernel/boot-bind/ai.ts +24 -0
  101. package/src/kernel/boot-bind/clock.ts +2 -0
  102. package/src/kernel/boot-bind/store.test.ts +150 -1
  103. package/src/kernel/boot-bind/store.ts +66 -1
  104. package/src/kernel/boot.ts +3 -1
  105. package/src/kernel/element-registries.ts +4 -1
  106. package/src/kernel/fx-dead-letters.test.ts +77 -0
  107. package/src/kernel/fx.test.ts +22 -0
  108. package/src/kernel/fx.ts +112 -6
  109. package/src/kernel/http-stream.test.ts +174 -0
  110. package/src/kernel/index.ts +2 -0
  111. package/src/manifest/diff.test.ts +13 -0
  112. package/src/manifest/diff.ts +15 -0
  113. package/src/manifest/mcp-ref.ts +88 -0
  114. package/src/manifest/types.ts +33 -2
  115. package/src/manifest/validate.test.ts +20 -0
  116. package/src/mcp/docs-server.ts +1 -1
  117. package/src/mcp/server.ts +1 -1
  118. package/src/plugins/compression.test.ts +21 -0
  119. package/src/plugins/compression.ts +1 -0
  120. package/src/runtime/bun.ts +41 -4
  121. package/src/test/reset-element-registries.ts +2 -0
  122. package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
  123. package/src/console/ui-next/dist/assets/flows-page-C_Tas1E1.js +0 -1
  124. package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
  125. package/src/console/ui-next/dist/assets/observability-page-BwVS5Jvm.js +0 -4
  126. package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
  127. package/src/console/ui-next/dist/assets/units-page-C5KOI7qG.js +0 -1
@@ -0,0 +1,362 @@
1
+ /**
2
+ * MCP client session — modern `_meta` first, dual-era fallback, allowlist filter.
3
+ */
4
+
5
+ import type { AiMcpServerDecl } from "./declare.ts";
6
+ import { parseMcpToolRef, type ParsedMcpToolRef } from "../../manifest/mcp-ref.ts";
7
+ import { createMcpHttpTransport } from "./mcp-http.ts";
8
+ import { createMcpStdioTransport } from "./mcp-stdio.ts";
9
+ import {
10
+ interpretToolsCallResult,
11
+ isModernProtocolReject,
12
+ MCP_CLIENT_PROTOCOL_VERSION,
13
+ MCP_LEGACY_PROTOCOL_VERSION,
14
+ mcpClientMeta,
15
+ mcpHeaderAnnotationsValid,
16
+ mcpParamHeaders,
17
+ parseToolsListResult,
18
+ type McpClientMeta,
19
+ type McpListedTool,
20
+ type McpProtocolEra,
21
+ } from "./mcp-protocol.ts";
22
+ import { McpTransportError, type McpTransport, type McpWireResult } from "./mcp-transport.ts";
23
+
24
+ /** Options for {@link createMcpClient}. */
25
+ export interface CreateMcpClientOptions {
26
+ readonly servers?: readonly AiMcpServerDecl[];
27
+ readonly resolveSecret?: (name: string) => string | Promise<string>;
28
+ readonly transports?: Readonly<Record<string, McpTransport>>;
29
+ readonly clientVersion?: string;
30
+ }
31
+
32
+ /** Allowlisted tool + cached schema. */
33
+ export interface McpResolvedTool {
34
+ readonly server: string;
35
+ readonly tool: string;
36
+ readonly ref: string;
37
+ readonly listed?: McpListedTool;
38
+ }
39
+
40
+ /** MCP client used by the AI runtime. */
41
+ export interface McpClient {
42
+ readonly servers: ReadonlyMap<string, AiMcpServerDecl>;
43
+ listedTool(ref: ParsedMcpToolRef, signal?: AbortSignal): Promise<McpListedTool | undefined>;
44
+ call(ref: string, input: unknown, signal?: AbortSignal): Promise<unknown>;
45
+ close(): Promise<void>;
46
+ }
47
+
48
+ interface ServerSession {
49
+ readonly decl: AiMcpServerDecl;
50
+ readonly transport: McpTransport;
51
+ readonly eraKey: string;
52
+ readonly cancelMode: "http" | "stdio";
53
+ era?: McpProtocolEra;
54
+ sessionId?: string;
55
+ listed?: readonly McpListedTool[];
56
+ }
57
+
58
+ /**
59
+ * Create an MCP client for declared servers.
60
+ *
61
+ * @param options - Decls + optional injected transports (tests)
62
+ */
63
+ export function createMcpClient(options: CreateMcpClientOptions = {}): McpClient {
64
+ const meta = mcpClientMeta(options.clientVersion ?? process.env.npm_package_version ?? "0.0.0");
65
+ const servers = new Map<string, AiMcpServerDecl>();
66
+ for (const decl of options.servers ?? []) servers.set(decl.name, decl);
67
+ const sessions = new Map<string, ServerSession>();
68
+ const eraByKey = new Map<string, McpProtocolEra>();
69
+ let nextId = 1;
70
+
71
+ function sessionFor(decl: AiMcpServerDecl): ServerSession {
72
+ const existing = sessions.get(decl.name);
73
+ if (existing) return existing;
74
+ const injected = options.transports?.[decl.name];
75
+ const transport = injected ?? transportFor(decl, options.resolveSecret);
76
+ const eraKey = decl.url
77
+ ? httpOrigin(decl.url)
78
+ : `stdio:${decl.command}:${(decl.args ?? []).join("\0")}`;
79
+ const session: ServerSession = {
80
+ decl,
81
+ transport,
82
+ eraKey,
83
+ cancelMode: decl.url ? "http" : "stdio",
84
+ era: eraByKey.get(eraKey),
85
+ };
86
+ sessions.set(decl.name, session);
87
+ return session;
88
+ }
89
+
90
+ async function rpc(
91
+ session: ServerSession,
92
+ method: string,
93
+ params: unknown,
94
+ signal: AbortSignal | undefined,
95
+ extra: { readonly toolName?: string; readonly extraHeaders?: Record<string, string> } = {},
96
+ ): Promise<unknown> {
97
+ const id = nextId++;
98
+ const era = session.era ?? "modern";
99
+ const headers = requestHeaders(
100
+ method,
101
+ era,
102
+ session.sessionId,
103
+ extra.toolName,
104
+ extra.extraHeaders,
105
+ );
106
+ const wireParams = era === "modern" ? withMeta(params, meta) : params;
107
+ const result = await session.transport.request({
108
+ id,
109
+ method,
110
+ ...(wireParams !== undefined ? { params: wireParams } : {}),
111
+ headers,
112
+ ...(signal !== undefined ? { signal } : {}),
113
+ cancelMode: session.cancelMode,
114
+ });
115
+ if (result.sessionId) session.sessionId = result.sessionId;
116
+ return result;
117
+ }
118
+
119
+ async function ensureEra(session: ServerSession, signal?: AbortSignal): Promise<void> {
120
+ if (session.era) return;
121
+ const cached = eraByKey.get(session.eraKey);
122
+ if (cached) {
123
+ session.era = cached;
124
+ if (cached === "legacy") await initializeLegacy(session, signal);
125
+ return;
126
+ }
127
+
128
+ const probeMethod = session.cancelMode === "stdio" ? "server/discover" : "tools/list";
129
+ const probeSignal = session.cancelMode === "stdio" ? withTimeout(signal, 2_000) : signal;
130
+ let first: McpWireResult;
131
+ try {
132
+ first = (await rpc(session, probeMethod, {}, probeSignal)) as McpWireResult;
133
+ } catch (err) {
134
+ if (session.cancelMode === "stdio") {
135
+ setEra(session, "legacy");
136
+ await initializeLegacy(session, signal);
137
+ return;
138
+ }
139
+ throw err;
140
+ }
141
+ if (first.ok) {
142
+ setEra(session, "modern");
143
+ if (probeMethod === "tools/list") {
144
+ session.listed = filterAllowlist(session.decl, parseToolsListResult(first.result));
145
+ }
146
+ return;
147
+ }
148
+
149
+ const decision = classifyFirstError(first, session.cancelMode);
150
+ if (decision === "modern") {
151
+ setEra(session, "modern");
152
+ throw rpcFailure(first, probeMethod);
153
+ }
154
+ if (decision === "fail") {
155
+ throw rpcFailure(first, probeMethod);
156
+ }
157
+
158
+ setEra(session, "legacy");
159
+ await initializeLegacy(session, signal);
160
+ }
161
+
162
+ async function initializeLegacy(session: ServerSession, signal?: AbortSignal): Promise<void> {
163
+ const result = (await rpc(
164
+ session,
165
+ "initialize",
166
+ {
167
+ protocolVersion: MCP_LEGACY_PROTOCOL_VERSION,
168
+ capabilities: {},
169
+ clientInfo: meta.clientInfo,
170
+ },
171
+ signal,
172
+ )) as McpWireResult;
173
+ if (!result.ok) throw rpcFailure(result, "initialize");
174
+ if (result.sessionId) session.sessionId = result.sessionId;
175
+ // `initialized` notification is optional; HTTP clients do not need it.
176
+ }
177
+
178
+ function setEra(session: ServerSession, era: McpProtocolEra): void {
179
+ session.era = era;
180
+ eraByKey.set(session.eraKey, era);
181
+ }
182
+
183
+ async function listTools(
184
+ session: ServerSession,
185
+ signal?: AbortSignal,
186
+ ): Promise<readonly McpListedTool[]> {
187
+ if (session.listed) return session.listed;
188
+ await ensureEra(session, signal);
189
+ if (session.listed) return session.listed;
190
+ const result = (await rpc(session, "tools/list", {}, signal)) as McpWireResult;
191
+ if (!result.ok) throw rpcFailure(result, "tools/list");
192
+ session.listed = filterAllowlist(session.decl, parseToolsListResult(result.result));
193
+ return session.listed;
194
+ }
195
+
196
+ return {
197
+ servers,
198
+ async listedTool(ref, signal) {
199
+ const decl = servers.get(ref.server);
200
+ if (!decl) return undefined;
201
+ const listed = await listTools(sessionFor(decl), signal);
202
+ return listed.find((t) => t.name === ref.tool);
203
+ },
204
+ async call(ref, input, signal) {
205
+ const parsed = parseMcpToolRef(ref);
206
+ if (!parsed) throw new Error(`ai.mcp: invalid tool ref "${ref}"`);
207
+ const decl = servers.get(parsed.server);
208
+ if (!decl) throw new Error(`ai.mcp: unknown server "${parsed.server}"`);
209
+ if (!decl.tools.includes(parsed.tool)) {
210
+ throw new Error(`ai.mcp: tool "${parsed.tool}" is not allowlisted on "${parsed.server}"`);
211
+ }
212
+ const session = sessionFor(decl);
213
+ await ensureEra(session, signal);
214
+ const listed = await listTools(session, signal);
215
+ const tool = listed.find((t) => t.name === parsed.tool);
216
+ const args =
217
+ input && typeof input === "object" && !Array.isArray(input)
218
+ ? (input as Record<string, unknown>)
219
+ : {};
220
+ const split = mcpParamHeaders(tool?.inputSchema, args);
221
+ if (split === null) {
222
+ throw new Error(`ai.mcp: tool "${parsed.tool}" has an invalid x-mcp-header annotation`);
223
+ }
224
+ const result = (await rpc(
225
+ session,
226
+ "tools/call",
227
+ {
228
+ name: parsed.tool,
229
+ arguments: split.body,
230
+ },
231
+ signal,
232
+ {
233
+ toolName: parsed.tool,
234
+ extraHeaders: split.headers,
235
+ },
236
+ )) as McpWireResult;
237
+ if (!result.ok) throw rpcFailure(result, "tools/call");
238
+ return interpretToolsCallResult(result.result);
239
+ },
240
+ async close() {
241
+ await Promise.all([...sessions.values()].map((s) => s.transport.close()));
242
+ sessions.clear();
243
+ },
244
+ };
245
+ }
246
+
247
+ function transportFor(
248
+ decl: AiMcpServerDecl,
249
+ resolveSecret?: (name: string) => string | Promise<string>,
250
+ ): McpTransport {
251
+ if (decl.url) {
252
+ return {
253
+ kind: "http",
254
+ async request(request) {
255
+ const bearer = decl.auth && resolveSecret ? await resolveSecret(decl.auth) : undefined;
256
+ const inner = createMcpHttpTransport({
257
+ url: decl.url!,
258
+ ...(bearer !== undefined ? { bearer } : {}),
259
+ });
260
+ return inner.request(request);
261
+ },
262
+ async close() {
263
+ /* per-request */
264
+ },
265
+ };
266
+ }
267
+ return createMcpStdioTransport({
268
+ command: decl.command!,
269
+ ...(decl.args !== undefined ? { args: decl.args } : {}),
270
+ });
271
+ }
272
+
273
+ function withMeta(params: unknown, meta: McpClientMeta): unknown {
274
+ const base =
275
+ params && typeof params === "object" && !Array.isArray(params)
276
+ ? (params as Record<string, unknown>)
277
+ : {};
278
+ return { ...base, _meta: meta };
279
+ }
280
+
281
+ function requestHeaders(
282
+ method: string,
283
+ era: McpProtocolEra,
284
+ sessionId: string | undefined,
285
+ toolName: string | undefined,
286
+ extra: Record<string, string> | undefined,
287
+ ): Record<string, string> {
288
+ const headers: Record<string, string> = {
289
+ "MCP-Protocol-Version":
290
+ era === "modern" ? MCP_CLIENT_PROTOCOL_VERSION : MCP_LEGACY_PROTOCOL_VERSION,
291
+ "Mcp-Method": method,
292
+ ...extra,
293
+ };
294
+ if (method === "tools/call" && toolName) headers["Mcp-Name"] = toolName;
295
+ if (era === "legacy" && sessionId) headers["Mcp-Session-Id"] = sessionId;
296
+ return headers;
297
+ }
298
+
299
+ function filterAllowlist(decl: AiMcpServerDecl, listed: McpListedTool[]): McpListedTool[] {
300
+ const allow = new Set(decl.tools);
301
+ return listed.filter((t) => allow.has(t.name) && mcpHeaderAnnotationsValid(t.inputSchema));
302
+ }
303
+
304
+ function classifyFirstError(
305
+ result: McpWireResult,
306
+ mode: "http" | "stdio",
307
+ ): "modern" | "legacy" | "fail" {
308
+ const code = result.error?.code;
309
+ if (code !== undefined && isModernProtocolReject(code)) return "modern";
310
+ if (mode === "stdio") return "legacy";
311
+ const status = result.httpStatus;
312
+ if (status === 401 || status === 403) return "fail";
313
+ if (status !== undefined && status >= 500) return "fail";
314
+ if (status === 400) return "legacy";
315
+ return "fail";
316
+ }
317
+
318
+ function rpcFailure(result: McpWireResult, method: string): Error {
319
+ if (result.error) {
320
+ return new McpTransportError(`ai.mcp: ${method} failed: ${result.error.message}`, {
321
+ ...(result.httpStatus !== undefined ? { httpStatus: result.httpStatus } : {}),
322
+ rpc: result.error,
323
+ });
324
+ }
325
+ return new McpTransportError(`ai.mcp: ${method} failed`, {
326
+ ...(result.httpStatus !== undefined ? { httpStatus: result.httpStatus } : {}),
327
+ network: result.httpStatus === undefined,
328
+ });
329
+ }
330
+
331
+ function httpOrigin(url: string): string {
332
+ try {
333
+ return new URL(url).origin;
334
+ } catch {
335
+ return url;
336
+ }
337
+ }
338
+
339
+ function withTimeout(parent: AbortSignal | undefined, ms: number): AbortSignal {
340
+ const ctrl = new AbortController();
341
+ const timer = setTimeout(() => ctrl.abort(new Error("ai.mcp: stdio discover timeout")), ms);
342
+ const unlink = (): void => {
343
+ clearTimeout(timer);
344
+ };
345
+ if (parent) {
346
+ if (parent.aborted) {
347
+ clearTimeout(timer);
348
+ ctrl.abort(parent.reason);
349
+ } else {
350
+ parent.addEventListener(
351
+ "abort",
352
+ () => {
353
+ clearTimeout(timer);
354
+ ctrl.abort(parent.reason);
355
+ },
356
+ { once: true },
357
+ );
358
+ }
359
+ }
360
+ ctrl.signal.addEventListener("abort", unlink, { once: true });
361
+ return ctrl.signal;
362
+ }
@@ -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
+ }