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
|
@@ -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
|
+
});
|