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.
- package/manifest.v1.schema.json +7 -1
- package/package.json +2 -2
- package/site/content/docs/ai/mcp.mdx +27 -2
- package/site/content/docs/elements/ai.mdx +58 -11
- package/site/content/docs/elements/clock.mdx +27 -12
- package/site/content/docs/elements/flow.mdx +6 -3
- package/site/content/docs/elements/signal.mdx +71 -25
- package/site/content/docs/elements/store.mdx +1 -1
- package/site/content/docs/get-started/installation.mdx +2 -2
- package/site/content/docs/reference/fx.mdx +27 -22
- package/src/cli/ai-setup/recommend.test.ts +25 -0
- package/src/cli/ai-setup/recommend.ts +8 -3
- package/src/compiler/effects-infer.ts +58 -3
- package/src/compiler/extract.test.ts +54 -1
- package/src/compiler/extract.ts +69 -2
- package/src/compiler/response.ts +45 -1
- package/src/console/server/ai.ts +5 -2
- package/src/console/server/flows.ts +1 -0
- package/src/console/server/serve.ts +17 -2
- package/src/console/ui-next/dist/assets/cache-glyph-BanhLsEY.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DxDsOd4f.js +1 -0
- package/src/console/ui-next/dist/assets/http-method-CJCBYL2j.js +1 -0
- package/src/console/ui-next/dist/assets/{index-Bp-R7jtM.js → index-Ce6WKWKM.js} +3 -3
- package/src/console/ui-next/dist/assets/observability-page-BEZDzyYh.js +4 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-DFLFfUUX.js +2 -0
- package/src/console/ui-next/dist/assets/units-page-C0gW6Kdo.js +1 -0
- package/src/console/ui-next/dist/assets/{vault-page-Ca-MvcmJ.js → vault-page-DuKzqwzW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-invoke-host.ts +2 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.test.ts +18 -0
- package/src/console/ui-next/src/features/flows/graph/build-flow-graph.ts +14 -1
- package/src/console/ui-next/src/features/flows/graph/neighborhood.test.ts +17 -0
- package/src/console/ui-next/src/features/flows/graph/neighborhood.ts +16 -3
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +3 -1
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +9 -3
- package/src/console/ui-next/src/features/flows/traces/trace-detail.test.ts +10 -1
- package/src/console/ui-next/src/features/observability/lib/ask-count.test.ts +25 -0
- package/src/console/ui-next/src/features/observability/lib/ask-count.ts +4 -1
- package/src/console/ui-next/src/features/units/detail/effects-summary.tsx +12 -4
- package/src/docker/docker.test.ts +1 -1
- package/src/docker/dockerfile.ts +1 -1
- package/src/drivers/ai-anthropic.ts +5 -0
- package/src/drivers/ai-ollama.ts +49 -30
- package/src/drivers/ai-openai-compatible.ts +57 -46
- package/src/drivers/ai-providers.test.ts +3 -0
- package/src/drivers/bun-native-completeness.test.ts +7 -9
- package/src/drivers/redis.ts +11 -4
- package/src/drivers/signal-redis.ts +24 -14
- package/src/drivers/signal-types.ts +2 -1
- package/src/elements/ai/declare.ts +109 -0
- package/src/elements/ai/errors.test.ts +5 -1
- package/src/elements/ai/errors.ts +30 -2
- package/src/elements/ai/eval.ts +4 -6
- package/src/elements/ai/mcp-client.test.ts +206 -0
- package/src/elements/ai/mcp-client.ts +362 -0
- package/src/elements/ai/mcp-http.ts +159 -0
- package/src/elements/ai/mcp-mock.ts +134 -0
- package/src/elements/ai/mcp-protocol.ts +234 -0
- package/src/elements/ai/mcp-stdio.test.ts +50 -0
- package/src/elements/ai/mcp-stdio.ts +212 -0
- package/src/elements/ai/mcp-transport.ts +70 -0
- package/src/elements/ai/runtime.ts +159 -29
- package/src/elements/ai.test.ts +139 -0
- package/src/elements/ai.ts +16 -0
- package/src/elements/clock/health.test.ts +43 -0
- package/src/elements/clock/runtime.ts +55 -2
- package/src/elements/clock/schedule.ts +71 -142
- package/src/elements/clock.test.ts +9 -40
- package/src/elements/clock.ts +6 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/signal/declare.ts +2 -1
- package/src/elements/signal/runtime.ts +16 -1
- package/src/elements/signal.ts +1 -0
- package/src/elements/store/cache.test.ts +2 -0
- package/src/elements/store/cache.ts +3 -3
- package/src/elements/vault/builtin-adapter.ts +17 -0
- package/src/full.ts +2 -0
- package/src/index.ts +4 -0
- package/src/kernel/app.ts +97 -64
- package/src/kernel/auto-registry.test.ts +5 -0
- package/src/kernel/boot-bind/ai.ts +24 -0
- package/src/kernel/boot-bind/clock.ts +2 -0
- package/src/kernel/boot.ts +3 -1
- package/src/kernel/element-registries.ts +4 -1
- package/src/kernel/fx-dead-letters.test.ts +77 -0
- package/src/kernel/fx.test.ts +22 -0
- package/src/kernel/fx.ts +112 -6
- package/src/kernel/http-stream.test.ts +174 -0
- package/src/kernel/index.ts +2 -0
- package/src/manifest/mcp-ref.ts +88 -0
- package/src/manifest/types.ts +28 -2
- package/src/manifest/validate.test.ts +20 -0
- package/src/mcp/docs-server.ts +1 -1
- package/src/mcp/server.ts +1 -1
- package/src/plugins/compression.test.ts +21 -0
- package/src/plugins/compression.ts +1 -0
- package/src/runtime/bun.ts +41 -4
- package/src/test/reset-element-registries.ts +2 -0
- package/src/console/ui-next/dist/assets/cache-glyph-CLPBqZeb.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dg8CTE29.js +0 -1
- package/src/console/ui-next/dist/assets/http-method-BJ92Z_ke.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-DAnpEaq1.js +0 -4
- package/src/console/ui-next/dist/assets/trace-detail-sheet-D16lWQMt.js +0 -2
- package/src/console/ui-next/dist/assets/units-page-BtQ0bqMe.js +0 -1
|
@@ -74,8 +74,10 @@ export function isRetryableAiError(err: unknown): boolean {
|
|
|
74
74
|
if (code >= 400 && code <= 499) return false;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
if (name === "
|
|
78
|
-
|
|
77
|
+
if (name === "TimeoutError" || isTimeoutAbort(err)) return true;
|
|
78
|
+
// Client disconnect / explicit cancel must not retry or advance `via`.
|
|
79
|
+
if (name === "AbortError") return false;
|
|
80
|
+
if (name === "AiBudgetExceededError") return false;
|
|
79
81
|
if (
|
|
80
82
|
lower.includes("econnreset") ||
|
|
81
83
|
lower.includes("econnrefused") ||
|
|
@@ -103,6 +105,32 @@ export function aiHttpError(message: string, status: number): Error {
|
|
|
103
105
|
return err;
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* True when an abort came from a deadline (`AbortSignal.timeout`), not a
|
|
110
|
+
* client disconnect / explicit cancel.
|
|
111
|
+
*
|
|
112
|
+
* @param err - Thrown value
|
|
113
|
+
*/
|
|
114
|
+
function isTimeoutAbort(err: unknown): boolean {
|
|
115
|
+
if (err == null || typeof err !== "object") return false;
|
|
116
|
+
const name = err instanceof Error ? err.name : "";
|
|
117
|
+
const message = err instanceof Error ? err.message.toLowerCase() : "";
|
|
118
|
+
if (name === "TimeoutError") return true;
|
|
119
|
+
if (message.includes("timeout")) return true;
|
|
120
|
+
const cause = "cause" in err ? (err as { cause: unknown }).cause : undefined;
|
|
121
|
+
if (cause instanceof Error && (cause.name === "TimeoutError" || /timeout/i.test(cause.message))) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const reason = "reason" in err ? (err as { reason: unknown }).reason : undefined;
|
|
125
|
+
if (
|
|
126
|
+
reason instanceof Error &&
|
|
127
|
+
(reason.name === "TimeoutError" || /timeout/i.test(reason.message))
|
|
128
|
+
) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
106
134
|
function readStatus(err: unknown): number | undefined {
|
|
107
135
|
if (typeof err !== "object" || err === null) return undefined;
|
|
108
136
|
const status = (err as AiErrorFields).status;
|
package/src/elements/ai/eval.ts
CHANGED
|
@@ -124,11 +124,9 @@ export async function runPromptEvals(options: RunPromptEvalsOptions): Promise<Ev
|
|
|
124
124
|
* @param text - File text
|
|
125
125
|
*/
|
|
126
126
|
export function parseEvalJsonl(text: string): EvalCase[] {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (!trimmed) continue;
|
|
131
|
-
cases.push(JSON.parse(trimmed) as EvalCase);
|
|
127
|
+
const jsonl = (Bun as typeof Bun & { JSONL?: { parse: (t: string) => unknown[] } }).JSONL;
|
|
128
|
+
if (!jsonl) {
|
|
129
|
+
throw new Error("ai.eval: Bun.JSONL is required (Bun >= 1.4.0)");
|
|
132
130
|
}
|
|
133
|
-
return
|
|
131
|
+
return jsonl.parse(text) as EvalCase[];
|
|
134
132
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP client — allowlist, era fallback, abort, capability.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createMockAiDriver } from "../../drivers/ai-mock.ts";
|
|
7
|
+
import { createFx } from "../../kernel/fx.ts";
|
|
8
|
+
import {
|
|
9
|
+
mcpCapabilityRefFromName,
|
|
10
|
+
mcpModelToolName,
|
|
11
|
+
parseMcpToolRef,
|
|
12
|
+
} from "../../manifest/mcp-ref.ts";
|
|
13
|
+
import { ai } from "./declare.ts";
|
|
14
|
+
import { createMcpClient } from "./mcp-client.ts";
|
|
15
|
+
import { createMockMcpTransport } from "./mcp-mock.ts";
|
|
16
|
+
import { createAiRuntime } from "./runtime.ts";
|
|
17
|
+
|
|
18
|
+
describe("mcp refs", () => {
|
|
19
|
+
test("parses capability and model names", () => {
|
|
20
|
+
expect(parseMcpToolRef("mcp:github/create_issue")).toEqual({
|
|
21
|
+
server: "github",
|
|
22
|
+
tool: "create_issue",
|
|
23
|
+
});
|
|
24
|
+
expect(mcpModelToolName("github", "create_issue")).toBe("github__create_issue");
|
|
25
|
+
expect(mcpCapabilityRefFromName("github__create_issue")).toBe("mcp:github/create_issue");
|
|
26
|
+
expect(parseMcpToolRef("github/create_issue")).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("mcp client allowlist", () => {
|
|
31
|
+
test("drops extras from tools/list and calls only allowlisted tools", async () => {
|
|
32
|
+
const github = ai.mcpServer("github", {
|
|
33
|
+
url: "https://mcp.example/github",
|
|
34
|
+
tools: ["create_issue"],
|
|
35
|
+
});
|
|
36
|
+
const called: string[] = [];
|
|
37
|
+
const transport = createMockMcpTransport({
|
|
38
|
+
tools: [
|
|
39
|
+
{ name: "create_issue", description: "Open an issue" },
|
|
40
|
+
{ name: "delete_repo", description: "should be dropped" },
|
|
41
|
+
],
|
|
42
|
+
extraTools: [{ name: "admin_wipe" }],
|
|
43
|
+
onCall: (name) => {
|
|
44
|
+
called.push(name);
|
|
45
|
+
return { content: [{ type: "text", text: "opened" }] };
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const client = createMcpClient({
|
|
49
|
+
servers: [github],
|
|
50
|
+
transports: { github: transport },
|
|
51
|
+
});
|
|
52
|
+
const listed = await client.listedTool({ server: "github", tool: "create_issue" });
|
|
53
|
+
expect(listed?.name).toBe("create_issue");
|
|
54
|
+
expect(await client.listedTool({ server: "github", tool: "delete_repo" })).toBeUndefined();
|
|
55
|
+
await client.call("mcp:github/create_issue", { title: "bug" });
|
|
56
|
+
expect(called).toEqual(["create_issue"]);
|
|
57
|
+
await expect(client.call("mcp:github/delete_repo", {})).rejects.toThrow(/not allowlisted/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("mcp era fallback", () => {
|
|
62
|
+
test("400 + UnsupportedProtocolVersion stays modern (no initialize)", async () => {
|
|
63
|
+
const methods: string[] = [];
|
|
64
|
+
const github = ai.mcpServer("github", {
|
|
65
|
+
url: "https://mcp.example/github",
|
|
66
|
+
tools: ["create_issue"],
|
|
67
|
+
});
|
|
68
|
+
const transport = createMockMcpTransport({
|
|
69
|
+
tools: [{ name: "create_issue" }],
|
|
70
|
+
httpStatusOnFirst: 400,
|
|
71
|
+
firstError: { code: -32022, message: "UnsupportedProtocolVersion" },
|
|
72
|
+
});
|
|
73
|
+
const wrapped = {
|
|
74
|
+
kind: "mock" as const,
|
|
75
|
+
async request(req: Parameters<typeof transport.request>[0]) {
|
|
76
|
+
methods.push(req.method);
|
|
77
|
+
return transport.request(req);
|
|
78
|
+
},
|
|
79
|
+
close: () => transport.close(),
|
|
80
|
+
};
|
|
81
|
+
const client = createMcpClient({
|
|
82
|
+
servers: [github],
|
|
83
|
+
transports: { github: wrapped },
|
|
84
|
+
});
|
|
85
|
+
await expect(client.listedTool({ server: "github", tool: "create_issue" })).rejects.toThrow(
|
|
86
|
+
/UnsupportedProtocolVersion/,
|
|
87
|
+
);
|
|
88
|
+
expect(methods).toEqual(["tools/list"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("400 without a modern body falls back to initialize", async () => {
|
|
92
|
+
const methods: string[] = [];
|
|
93
|
+
const github = ai.mcpServer("github", {
|
|
94
|
+
url: "https://mcp.example/github",
|
|
95
|
+
tools: ["create_issue"],
|
|
96
|
+
});
|
|
97
|
+
const transport = createMockMcpTransport({
|
|
98
|
+
era: "legacy",
|
|
99
|
+
tools: [{ name: "create_issue" }],
|
|
100
|
+
httpStatusOnFirst: 400,
|
|
101
|
+
firstBody: "bad request",
|
|
102
|
+
});
|
|
103
|
+
const wrapped = {
|
|
104
|
+
kind: "mock" as const,
|
|
105
|
+
async request(req: Parameters<typeof transport.request>[0]) {
|
|
106
|
+
methods.push(req.method);
|
|
107
|
+
return transport.request(req);
|
|
108
|
+
},
|
|
109
|
+
close: () => transport.close(),
|
|
110
|
+
};
|
|
111
|
+
const client = createMcpClient({
|
|
112
|
+
servers: [github],
|
|
113
|
+
transports: { github: wrapped },
|
|
114
|
+
});
|
|
115
|
+
const listed = await client.listedTool({ server: "github", tool: "create_issue" });
|
|
116
|
+
expect(listed?.name).toBe("create_issue");
|
|
117
|
+
expect(methods).toEqual(["tools/list", "initialize", "tools/list"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("mcp abort", () => {
|
|
122
|
+
test("HTTP cancel aborts an in-flight mock request", async () => {
|
|
123
|
+
const github = ai.mcpServer("github", {
|
|
124
|
+
url: "https://mcp.example/github",
|
|
125
|
+
tools: ["create_issue"],
|
|
126
|
+
});
|
|
127
|
+
const transport = createMockMcpTransport({
|
|
128
|
+
tools: [{ name: "create_issue" }],
|
|
129
|
+
delayMs: 5_000,
|
|
130
|
+
});
|
|
131
|
+
const client = createMcpClient({
|
|
132
|
+
servers: [github],
|
|
133
|
+
transports: { github: transport },
|
|
134
|
+
});
|
|
135
|
+
const ctrl = new AbortController();
|
|
136
|
+
const pending = client.call("mcp:github/create_issue", {}, ctrl.signal);
|
|
137
|
+
ctrl.abort();
|
|
138
|
+
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("mcp capability + toolLoop", () => {
|
|
143
|
+
test("undeclared MCP tool throws OKE1007", async () => {
|
|
144
|
+
const github = ai.mcpServer("github", {
|
|
145
|
+
url: "https://mcp.example/github",
|
|
146
|
+
tools: ["create_issue"],
|
|
147
|
+
});
|
|
148
|
+
const runtime = createAiRuntime({
|
|
149
|
+
mcpServers: [github],
|
|
150
|
+
mcpTransports: { github: createMockMcpTransport({ tools: [{ name: "create_issue" }] }) },
|
|
151
|
+
});
|
|
152
|
+
const fx = createFx({
|
|
153
|
+
flow: "support.triage",
|
|
154
|
+
effects: { asks: ["triage"] },
|
|
155
|
+
aiRuntime: runtime,
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
await fx.call(github.tool("create_issue"), { title: "x" });
|
|
159
|
+
throw new Error("expected OKE1007");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
expect(err).toMatchObject({ code: 1007 });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("model-facing server__tool dispatches through fx.call as mcp: ref", async () => {
|
|
166
|
+
const github = ai.mcpServer("github", {
|
|
167
|
+
url: "https://mcp.example/github",
|
|
168
|
+
tools: ["create_issue"],
|
|
169
|
+
});
|
|
170
|
+
const called: string[] = [];
|
|
171
|
+
const runtime = createAiRuntime({
|
|
172
|
+
models: [ai.model("smart")],
|
|
173
|
+
prompts: [ai.model("smart").prompt("triage")],
|
|
174
|
+
mcpServers: [github],
|
|
175
|
+
mcpTransports: {
|
|
176
|
+
github: createMockMcpTransport({
|
|
177
|
+
tools: [{ name: "create_issue", description: "Open an issue" }],
|
|
178
|
+
onCall: (name) => {
|
|
179
|
+
called.push(name);
|
|
180
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
defaultDriver: createMockAiDriver({
|
|
185
|
+
"*": {
|
|
186
|
+
__toolCalls: [{ id: "c1", name: "github__create_issue", arguments: { title: "bug" } }],
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
const fx = createFx({
|
|
191
|
+
flow: "support.triage",
|
|
192
|
+
effects: { asks: ["triage"], calls: ["mcp:github/create_issue"] },
|
|
193
|
+
aiRuntime: runtime,
|
|
194
|
+
});
|
|
195
|
+
await runtime.ask(
|
|
196
|
+
"triage",
|
|
197
|
+
{ q: "open" },
|
|
198
|
+
{
|
|
199
|
+
tools: ["mcp:github/create_issue"],
|
|
200
|
+
maxSteps: 1,
|
|
201
|
+
callTool: (name, input) => fx.call(name, input),
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
expect(called).toEqual(["create_issue"]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -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
|
+
}
|