poe-code 8.0.1 → 9.0.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/dist/cli/commands/harness.js +28 -3
- package/dist/cli/commands/harness.js.map +1 -1
- package/dist/index.js +1516 -711
- package/dist/index.js.map +4 -4
- package/dist/metafile.json +1 -1
- package/package.json +1 -1
- package/packages/safejs/dist/chunks/chunk-4YIDX44G.js +40 -0
- package/packages/safejs/dist/chunks/chunk-4YIDX44G.js.map +7 -0
- package/packages/safejs/dist/chunks/chunk-55ZK3CR7.js +7802 -0
- package/packages/safejs/dist/chunks/chunk-55ZK3CR7.js.map +7 -0
- package/packages/safejs/dist/chunks/{chunk-ME5BTAZT.js → chunk-GQBEEFVQ.js} +150 -19
- package/packages/safejs/dist/chunks/{chunk-ME5BTAZT.js.map → chunk-GQBEEFVQ.js.map} +4 -4
- package/packages/safejs/dist/chunks/chunk-NI4WFFTL.js +5701 -0
- package/packages/safejs/dist/chunks/chunk-NI4WFFTL.js.map +7 -0
- package/packages/safejs/dist/chunks/inMemory-RLZ4QGDZ.js +48 -0
- package/packages/safejs/dist/chunks/inMemory-RLZ4QGDZ.js.map +7 -0
- package/packages/safejs/dist/chunks/server-3SNT3RE2.js +12518 -0
- package/packages/safejs/dist/chunks/server-3SNT3RE2.js.map +7 -0
- package/packages/safejs/dist/chunks/types-BTF37PZ6.js +348 -0
- package/packages/safejs/dist/chunks/types-BTF37PZ6.js.map +7 -0
- package/packages/safejs/dist/cli.d.ts +2 -0
- package/packages/safejs/dist/cli.js +26 -2
- package/packages/safejs/dist/cli.js.map +2 -2
- package/packages/safejs/dist/core.js +2 -1
- package/packages/safejs/dist/index.d.ts +3 -0
- package/packages/safejs/dist/index.js +6 -287
- package/packages/safejs/dist/index.js.map +4 -4
- package/packages/safejs/dist/interp/host-bridge.d.ts +2 -0
- package/packages/safejs/dist/interp/host-bridge.js +20 -9
- package/packages/safejs/dist/interp/host-call.d.ts +5 -0
- package/packages/safejs/dist/interp/host-call.js +48 -3
- package/packages/safejs/dist/interp/resources.d.ts +7 -0
- package/packages/safejs/dist/interp/resources.js +36 -0
- package/packages/safejs/dist/modules/mcp-transport.d.ts +26 -0
- package/packages/safejs/dist/modules/mcp-transport.js +259 -0
- package/packages/safejs/dist/modules/mcp.d.ts +15 -1
- package/packages/safejs/dist/modules/mcp.js +107 -0
- package/packages/safejs/dist/run.js +6 -3
- package/packages/safejs/dist/snapshot/dump-format.d.ts +1 -1
- package/packages/safejs/dist/snapshot/dump-format.js +1 -1
- package/packages/safejs/dist/snapshot/replay-data.d.ts +1 -0
- package/packages/safejs/dist/snapshot/replay-data.js +3 -1
- package/packages/safejs/dist/snapshot/replay-inputs.d.ts +2 -2
- package/packages/safejs/dist/snapshot/replay-inputs.js +2 -1
- package/packages/safejs/dist/chunks/chunk-AVDRLE3Y.js +0 -3461
- package/packages/safejs/dist/chunks/chunk-AVDRLE3Y.js.map +0 -7
|
@@ -29,18 +29,19 @@ export function readHostOperationPolicy(operation) {
|
|
|
29
29
|
return hostOperationPolicies.get(operation);
|
|
30
30
|
}
|
|
31
31
|
export function wrapCallerInjectedBindings(bindings, options) {
|
|
32
|
+
options = { ...options, registerCapabilities: true };
|
|
32
33
|
const state = { seen: new WeakMap() };
|
|
33
34
|
const copied = Object.fromEntries(Object.entries(bindings).map(([name, value]) => [
|
|
34
35
|
name,
|
|
35
36
|
typeof value === "function"
|
|
36
|
-
? wrapCallerInjectedFunction(name, value, options, state)
|
|
37
|
-
: copyHostValueToSandbox(value, [], { ...options, operation: name }, state, "<root>")
|
|
37
|
+
? wrapCallerInjectedFunction(name, value, { ...options, capabilityPath: [name] }, state)
|
|
38
|
+
: copyHostValueToSandbox(value, [], { ...options, operation: name, capabilityPath: [name] }, state, "<root>")
|
|
38
39
|
]));
|
|
39
40
|
options.budget.provisionDataUsage(measureSandboxData(Object.values(copied)));
|
|
40
41
|
return copied;
|
|
41
42
|
}
|
|
42
43
|
function wrapCallerInjectedFunction(name, value, options, state) {
|
|
43
|
-
const existing = state.seen.get(value);
|
|
44
|
+
const existing = state.seen.get(value) ?? options.hostCalls?.nativeClosures.get(value);
|
|
44
45
|
if (existing !== undefined)
|
|
45
46
|
return existing;
|
|
46
47
|
const bindingName = name === "default" && value.name.length > 0 ? value.name : name;
|
|
@@ -132,6 +133,12 @@ function wrapCallerInjectedFunction(name, value, options, state) {
|
|
|
132
133
|
name: bindingName,
|
|
133
134
|
properties: (closure) => {
|
|
134
135
|
state.seen.set(value, closure);
|
|
136
|
+
if (options.registerCapabilities) {
|
|
137
|
+
options.hostCalls?.registerHostCapability(JSON.stringify([
|
|
138
|
+
options.moduleId ?? "<bindings>",
|
|
139
|
+
...(options.capabilityPath ?? [bindingName])
|
|
140
|
+
]), closure, value);
|
|
141
|
+
}
|
|
135
142
|
return (copyFunctionProperties(callable, [], options, state, options.operation ?? bindingName) ?? {});
|
|
136
143
|
}
|
|
137
144
|
});
|
|
@@ -316,7 +323,7 @@ function isAbortReason(reason) {
|
|
|
316
323
|
("code" in reason && reason.code === "aborted")));
|
|
317
324
|
}
|
|
318
325
|
function copyHostResultToSandbox(result, stackFrames, options) {
|
|
319
|
-
const value = copyHostValueToSandbox(result, stackFrames, options, {
|
|
326
|
+
const value = copyHostValueToSandbox(result, stackFrames, { ...options, registerCapabilities: false }, {
|
|
320
327
|
seen: new WeakMap()
|
|
321
328
|
}, "<root>");
|
|
322
329
|
options.budget.provisionDataUsage(measureSandboxData([value]));
|
|
@@ -603,7 +610,7 @@ function copyHostValueToSandbox(value, stackFrames, options, state, path) {
|
|
|
603
610
|
if (!("value" in descriptor)) {
|
|
604
611
|
throw new TypeError(`Unsupported sandbox value at ${path}[${index}]: accessor property`);
|
|
605
612
|
}
|
|
606
|
-
copy[index] = copyHostValueToSandbox(descriptor.value, stackFrames, options, state, `${path}[${index}]`);
|
|
613
|
+
copy[index] = copyHostValueToSandbox(descriptor.value, stackFrames, { ...options, capabilityPath: [...(options.capabilityPath ?? []), String(index)] }, state, `${path}[${index}]`);
|
|
607
614
|
}
|
|
608
615
|
return copy;
|
|
609
616
|
}
|
|
@@ -616,7 +623,8 @@ function copyHostValueToSandbox(value, stackFrames, options, state, path) {
|
|
|
616
623
|
state.seen.set(value, copy);
|
|
617
624
|
budget.allocateCollectionEntries(value.size);
|
|
618
625
|
for (const [key, entry] of value) {
|
|
619
|
-
copy.entries.
|
|
626
|
+
const ordinal = copy.entries.size;
|
|
627
|
+
copy.entries.set(copyHostValueToSandbox(key, stackFrames, { ...options, capabilityPath: [...(options.capabilityPath ?? []), `key:${ordinal}`] }, state, `${path}.<key>`), copyHostValueToSandbox(entry, stackFrames, { ...options, capabilityPath: [...(options.capabilityPath ?? []), `value:${ordinal}`] }, state, `${path}.<value>`));
|
|
620
628
|
}
|
|
621
629
|
return copy;
|
|
622
630
|
}
|
|
@@ -629,7 +637,10 @@ function copyHostValueToSandbox(value, stackFrames, options, state, path) {
|
|
|
629
637
|
state.seen.set(value, copy);
|
|
630
638
|
budget.allocateCollectionEntries(value.size);
|
|
631
639
|
for (const entry of value) {
|
|
632
|
-
copy.values.add(copyHostValueToSandbox(entry, stackFrames,
|
|
640
|
+
copy.values.add(copyHostValueToSandbox(entry, stackFrames, {
|
|
641
|
+
...options,
|
|
642
|
+
capabilityPath: [...(options.capabilityPath ?? []), String(copy.values.size)]
|
|
643
|
+
}, state, `${path}.<value>`));
|
|
633
644
|
}
|
|
634
645
|
return copy;
|
|
635
646
|
}
|
|
@@ -653,7 +664,7 @@ function copyHostValueToSandbox(value, stackFrames, options, state, path) {
|
|
|
653
664
|
enumerable: true,
|
|
654
665
|
configurable: true,
|
|
655
666
|
writable: true,
|
|
656
|
-
value: copyHostValueToSandbox(descriptor.value, stackFrames, options, state, joinPath(path, key))
|
|
667
|
+
value: copyHostValueToSandbox(descriptor.value, stackFrames, { ...options, capabilityPath: [...(options.capabilityPath ?? []), key] }, state, joinPath(path, key))
|
|
657
668
|
});
|
|
658
669
|
}
|
|
659
670
|
return copy;
|
|
@@ -725,7 +736,7 @@ function copyFunctionProperties(callable, stackFrames, options, state, path) {
|
|
|
725
736
|
if (typeof descriptor.value !== "function") {
|
|
726
737
|
continue;
|
|
727
738
|
}
|
|
728
|
-
properties[key] = copyHostValueToSandbox(descriptor.value, stackFrames, options, state, joinPath(path, key));
|
|
739
|
+
properties[key] = copyHostValueToSandbox(descriptor.value, stackFrames, { ...options, capabilityPath: [...(options.capabilityPath ?? []), key] }, state, joinPath(path, key));
|
|
729
740
|
}
|
|
730
741
|
return Object.keys(properties).length > 0 ? properties : undefined;
|
|
731
742
|
}
|
|
@@ -83,8 +83,10 @@ export declare class HostCallJournal {
|
|
|
83
83
|
private retainedSize;
|
|
84
84
|
private readonly outcomeSizes;
|
|
85
85
|
private readonly capabilities;
|
|
86
|
+
private readonly requiredHostCapabilities;
|
|
86
87
|
private readonly capabilityIds;
|
|
87
88
|
readonly nativeClosures: WeakMap<object, SandboxClosure>;
|
|
89
|
+
private readonly hostSources;
|
|
88
90
|
private readonly encodedOutcomes;
|
|
89
91
|
private readonly callbackSizes;
|
|
90
92
|
private readonly completedCallbackOwners;
|
|
@@ -110,6 +112,9 @@ export declare class HostCallJournal {
|
|
|
110
112
|
snapshot(): HostCallRecord[];
|
|
111
113
|
dispose(): void;
|
|
112
114
|
registerCallbackFunction(record: HostCallRecord, id: number, closure: SandboxClosure, native: object): void;
|
|
115
|
+
registerHostCapability(path: string, closure: SandboxClosure, native: object): void;
|
|
116
|
+
rebindHostCapability(original: SandboxClosure, restored: SandboxClosure): void;
|
|
117
|
+
validateHostCapabilities(): void;
|
|
113
118
|
waitForCapability(id: string): Promise<void>;
|
|
114
119
|
trackCallbackCompletion(record: HostCallRecord, callbacks: readonly Promise<unknown>[]): void;
|
|
115
120
|
recordCallback(record: HostCallRecord, id: number, args: SandboxValue[], step: number): string;
|
|
@@ -36,8 +36,10 @@ export class HostCallJournal {
|
|
|
36
36
|
retainedSize = 0;
|
|
37
37
|
outcomeSizes = new Map();
|
|
38
38
|
capabilities = new Map();
|
|
39
|
+
requiredHostCapabilities = new Set();
|
|
39
40
|
capabilityIds = new WeakMap();
|
|
40
41
|
nativeClosures = new WeakMap();
|
|
42
|
+
hostSources = new WeakMap();
|
|
41
43
|
encodedOutcomes = new Map();
|
|
42
44
|
callbackSizes = new Map();
|
|
43
45
|
completedCallbackOwners = new Set();
|
|
@@ -55,7 +57,7 @@ export class HostCallJournal {
|
|
|
55
57
|
this.budget = budget;
|
|
56
58
|
this.recordedReplay = replay !== undefined;
|
|
57
59
|
if (replay !== undefined) {
|
|
58
|
-
const replayRecords = restoreReplayCalls(replay, this.encodedOutcomes, this.callbackSizes);
|
|
60
|
+
const replayRecords = restoreReplayCalls(replay, this.encodedOutcomes, this.callbackSizes, this.requiredHostCapabilities);
|
|
59
61
|
const restoredRunId = replayRecords[0]?.runId ?? records[0]?.runId ?? randomUUID();
|
|
60
62
|
validateRestoredRecords(records, restoredRunId, sourceHash);
|
|
61
63
|
for (const record of records) {
|
|
@@ -221,6 +223,34 @@ export class HostCallJournal {
|
|
|
221
223
|
this.capabilityIds.set(closure, identity);
|
|
222
224
|
this.nativeClosures.set(native, closure);
|
|
223
225
|
}
|
|
226
|
+
registerHostCapability(path, closure, native) {
|
|
227
|
+
const identity = `host:${path}`;
|
|
228
|
+
const existing = this.capabilities.get(identity);
|
|
229
|
+
if (existing !== undefined && existing !== closure)
|
|
230
|
+
throw new TypeError(`Conflicting replay capability '${identity}'.`);
|
|
231
|
+
this.capabilities.set(identity, closure);
|
|
232
|
+
if (!this.capabilityIds.has(closure))
|
|
233
|
+
this.capabilityIds.set(closure, identity);
|
|
234
|
+
if (!this.nativeClosures.has(native))
|
|
235
|
+
this.nativeClosures.set(native, closure);
|
|
236
|
+
this.hostSources.set(closure, native);
|
|
237
|
+
}
|
|
238
|
+
rebindHostCapability(original, restored) {
|
|
239
|
+
const identity = this.capabilityIds.get(original);
|
|
240
|
+
const source = this.hostSources.get(original);
|
|
241
|
+
if (identity === undefined || source === undefined)
|
|
242
|
+
return;
|
|
243
|
+
this.capabilities.set(identity, restored);
|
|
244
|
+
this.capabilityIds.set(restored, identity);
|
|
245
|
+
this.nativeClosures.set(source, restored);
|
|
246
|
+
this.hostSources.set(restored, source);
|
|
247
|
+
}
|
|
248
|
+
validateHostCapabilities() {
|
|
249
|
+
for (const identity of this.requiredHostCapabilities) {
|
|
250
|
+
if (!this.capabilities.has(identity))
|
|
251
|
+
throw new UnresolvedReplayCapabilityError(identity);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
224
254
|
waitForCapability(id) {
|
|
225
255
|
if (this.capabilities.has(id))
|
|
226
256
|
return Promise.resolve();
|
|
@@ -338,7 +368,7 @@ function copyOutcome(outcome) {
|
|
|
338
368
|
? { status: "fulfilled", value: deepCopyToSandbox(outcome.value) }
|
|
339
369
|
: { status: "rejected", reason: deepCopyToSandbox(outcome.reason) };
|
|
340
370
|
}
|
|
341
|
-
function restoreReplayCalls(input, encodedOutcomes, callbackSizes) {
|
|
371
|
+
function restoreReplayCalls(input, encodedOutcomes, callbackSizes, requiredHostCapabilities = new Set()) {
|
|
342
372
|
validateSnapshotData(input);
|
|
343
373
|
if (input === null ||
|
|
344
374
|
typeof input !== "object" ||
|
|
@@ -364,7 +394,22 @@ function restoreReplayCalls(input, encodedOutcomes, callbackSizes) {
|
|
|
364
394
|
}));
|
|
365
395
|
}
|
|
366
396
|
}
|
|
367
|
-
const resolveCapability = (id) =>
|
|
397
|
+
const resolveCapability = (id) => {
|
|
398
|
+
if (!capabilities.has(id) && id.startsWith("host:")) {
|
|
399
|
+
const path = JSON.parse(id.slice(5));
|
|
400
|
+
if (!Array.isArray(path) ||
|
|
401
|
+
path.length < 2 ||
|
|
402
|
+
path.some((value) => typeof value !== "string"))
|
|
403
|
+
throw new TypeError("Invalid host capability identity.");
|
|
404
|
+
requiredHostCapabilities.add(id);
|
|
405
|
+
capabilities.set(id, createSandboxClosure({
|
|
406
|
+
call: () => {
|
|
407
|
+
throw new TypeError("Host capability has not been reconstructed.");
|
|
408
|
+
}
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
return capabilities.get(id);
|
|
412
|
+
};
|
|
368
413
|
return input.calls.map((entry, index) => {
|
|
369
414
|
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
|
370
415
|
throw new TypeError("Invalid replay call.");
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export type RunResources = {
|
|
3
|
+
signal: AbortSignal;
|
|
4
|
+
add(close: () => Promise<void>): void;
|
|
5
|
+
};
|
|
6
|
+
export declare const runResources: AsyncLocalStorage<RunResources>;
|
|
7
|
+
export declare function withRunResources<Result>(signal: AbortSignal | undefined, execute: () => Promise<Result>): Promise<Result>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export const runResources = new AsyncLocalStorage();
|
|
3
|
+
export async function withRunResources(signal, execute) {
|
|
4
|
+
const controller = new AbortController();
|
|
5
|
+
const cancel = () => controller.abort(signal?.reason);
|
|
6
|
+
const cleanups = new Set();
|
|
7
|
+
const resources = {
|
|
8
|
+
signal: controller.signal,
|
|
9
|
+
add(close) {
|
|
10
|
+
cleanups.add(close);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
14
|
+
if (signal?.aborted)
|
|
15
|
+
cancel();
|
|
16
|
+
let result;
|
|
17
|
+
let failure;
|
|
18
|
+
let errors = [];
|
|
19
|
+
try {
|
|
20
|
+
result = await runResources.run(resources, execute);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
failure = { reason: error };
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
signal?.removeEventListener("abort", cancel);
|
|
27
|
+
controller.abort(new Error("SafeJS run finished."));
|
|
28
|
+
const outcomes = await Promise.allSettled([...cleanups].map((close) => Promise.resolve().then(close)));
|
|
29
|
+
errors = outcomes.flatMap((outcome) => (outcome.status === "rejected" ? [outcome.reason] : []));
|
|
30
|
+
}
|
|
31
|
+
if (failure !== undefined)
|
|
32
|
+
throw failure.reason;
|
|
33
|
+
if (errors.length > 0)
|
|
34
|
+
throw new AggregateError(errors, "SafeJS resource cleanup failed.");
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { McpClient, type HttpTransportFetch, type StdioSpawn } from "../../../tiny-mcp-client/dist/index.js";
|
|
2
|
+
export type McpServerConfig = {
|
|
3
|
+
command: string;
|
|
4
|
+
args?: string[];
|
|
5
|
+
cwd?: string;
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
} | {
|
|
8
|
+
url: string;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
export type McpModuleOptions = {
|
|
12
|
+
servers: Record<string, McpServerConfig>;
|
|
13
|
+
requestTimeoutMs?: number;
|
|
14
|
+
closeTimeoutMs?: number;
|
|
15
|
+
maxToolPages?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
fetch?: HttpTransportFetch;
|
|
18
|
+
spawn?: StdioSpawn;
|
|
19
|
+
};
|
|
20
|
+
export type ManagedMcpConnection = {
|
|
21
|
+
ready: Promise<McpClient>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export declare function parseMcpConfig(source: string, directory: string): McpModuleOptions;
|
|
25
|
+
export declare function normalizeMcpOptions(value: unknown, keys?: string[]): McpModuleOptions;
|
|
26
|
+
export declare function connectMcpTransport(server: McpServerConfig, options: McpModuleOptions, signal?: AbortSignal): ManagedMcpConnection;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { HttpTransport, McpClient, StdioTransport } from "tiny-mcp-client";
|
|
4
|
+
export function parseMcpConfig(source, directory) {
|
|
5
|
+
const parsed = JSON.parse(source);
|
|
6
|
+
const options = normalizeMcpOptions(parsed, [
|
|
7
|
+
"servers",
|
|
8
|
+
"requestTimeoutMs",
|
|
9
|
+
"closeTimeoutMs",
|
|
10
|
+
"maxToolPages"
|
|
11
|
+
]);
|
|
12
|
+
for (const server of Object.values(options.servers)) {
|
|
13
|
+
if ("command" in server) {
|
|
14
|
+
server.cwd = path.resolve(directory, server.cwd ?? ".");
|
|
15
|
+
if (server.command.includes("/") || server.command.includes("\\")) {
|
|
16
|
+
server.command = path.resolve(directory, server.command);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return options;
|
|
21
|
+
}
|
|
22
|
+
export function normalizeMcpOptions(value, keys = [
|
|
23
|
+
"servers",
|
|
24
|
+
"requestTimeoutMs",
|
|
25
|
+
"closeTimeoutMs",
|
|
26
|
+
"maxToolPages",
|
|
27
|
+
"signal",
|
|
28
|
+
"fetch",
|
|
29
|
+
"spawn"
|
|
30
|
+
]) {
|
|
31
|
+
const options = readRecord(value, "MCP options", keys);
|
|
32
|
+
const servers = readRecord(options.servers, "MCP servers");
|
|
33
|
+
const normalized = Object.create(null);
|
|
34
|
+
for (const [name, input] of Object.entries(servers)) {
|
|
35
|
+
if (name.length === 0 || name.trim() !== name)
|
|
36
|
+
throw new TypeError("MCP server names must be non-empty without surrounding whitespace.");
|
|
37
|
+
const server = readRecord(input, `MCP server ${name}`);
|
|
38
|
+
if (Object.hasOwn(server, "command")) {
|
|
39
|
+
readRecord(server, `MCP server ${name}`, ["command", "args", "cwd", "env"]);
|
|
40
|
+
const command = readText(server.command, "MCP command");
|
|
41
|
+
if (server.args !== undefined &&
|
|
42
|
+
(!Array.isArray(server.args) ||
|
|
43
|
+
server.args.some((argument) => typeof argument !== "string")))
|
|
44
|
+
throw new TypeError("MCP args must be an array of strings.");
|
|
45
|
+
normalized[name] = {
|
|
46
|
+
command,
|
|
47
|
+
...(server.args === undefined ? {} : { args: [...server.args] }),
|
|
48
|
+
...(server.cwd === undefined ? {} : { cwd: readText(server.cwd, "MCP cwd") }),
|
|
49
|
+
env: readStrings(server.env ?? {}, "MCP environment")
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
readRecord(server, `MCP server ${name}`, ["url", "headers"]);
|
|
54
|
+
const url = new URL(readText(server.url, "MCP URL"));
|
|
55
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash)
|
|
56
|
+
throw new TypeError("MCP URL must use HTTP or HTTPS without credentials or a fragment.");
|
|
57
|
+
const headers = readStrings(server.headers ?? {}, "MCP headers");
|
|
58
|
+
new Headers(headers);
|
|
59
|
+
normalized[name] = { url: url.href, headers };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const name of ["fetch", "spawn"])
|
|
63
|
+
if (options[name] !== undefined && typeof options[name] !== "function")
|
|
64
|
+
throw new TypeError(`MCP ${name} must be a function.`);
|
|
65
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal))
|
|
66
|
+
throw new TypeError("MCP signal must be an AbortSignal.");
|
|
67
|
+
return {
|
|
68
|
+
servers: normalized,
|
|
69
|
+
requestTimeoutMs: readTimeout(options.requestTimeoutMs, 30_000),
|
|
70
|
+
closeTimeoutMs: readTimeout(options.closeTimeoutMs, 1_000),
|
|
71
|
+
maxToolPages: readTimeout(options.maxToolPages, 100),
|
|
72
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
73
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
74
|
+
...(options.spawn === undefined ? {} : { spawn: options.spawn })
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function readRecord(value, label, keys) {
|
|
78
|
+
if (typeof value !== "object" ||
|
|
79
|
+
value === null ||
|
|
80
|
+
Array.isArray(value) ||
|
|
81
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
82
|
+
throw new TypeError(`${label} must be a plain object.`);
|
|
83
|
+
const result = Object.create(null);
|
|
84
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
85
|
+
if (!("value" in descriptor) || (keys !== undefined && !keys.includes(key)))
|
|
86
|
+
throw new TypeError(`${label} contains unsupported field ${key}.`);
|
|
87
|
+
result[key] = descriptor.value;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function readText(value, label) {
|
|
92
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0"))
|
|
93
|
+
throw new TypeError(`${label} must be a non-empty string without NUL characters.`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function readStrings(value, label) {
|
|
97
|
+
const entries = readRecord(value, label);
|
|
98
|
+
for (const [name, entry] of Object.entries(entries))
|
|
99
|
+
if (typeof entry !== "string" || name.includes("\0") || entry.includes("\0"))
|
|
100
|
+
throw new TypeError(`${label} must contain strings without NUL characters.`);
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
function readTimeout(value, fallback) {
|
|
104
|
+
if (value === undefined)
|
|
105
|
+
return fallback;
|
|
106
|
+
if (typeof value !== "number" ||
|
|
107
|
+
!Number.isSafeInteger(value) ||
|
|
108
|
+
value <= 0 ||
|
|
109
|
+
value > 2_147_483_647)
|
|
110
|
+
throw new TypeError("MCP timeouts must be positive 32-bit integers.");
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
export function connectMcpTransport(server, options, signal) {
|
|
114
|
+
signal?.throwIfAborted();
|
|
115
|
+
options.signal?.throwIfAborted();
|
|
116
|
+
const client = new McpClient({
|
|
117
|
+
clientInfo: { name: "safejs", version: "1" },
|
|
118
|
+
requestTimeoutMs: options.requestTimeoutMs
|
|
119
|
+
});
|
|
120
|
+
let child;
|
|
121
|
+
const activeRequests = new Set();
|
|
122
|
+
const transport = "command" in server
|
|
123
|
+
? new StdioTransport({
|
|
124
|
+
...server,
|
|
125
|
+
spawn(command, args, spawnOptions) {
|
|
126
|
+
child =
|
|
127
|
+
options.spawn === undefined
|
|
128
|
+
? spawn(command, args, spawnOptions)
|
|
129
|
+
: options.spawn(command, args, spawnOptions);
|
|
130
|
+
return child;
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
: new HttpTransport({
|
|
134
|
+
...server,
|
|
135
|
+
async fetch(input, init) {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
activeRequests.add(controller);
|
|
138
|
+
const cancel = () => controller.abort(init?.signal?.reason);
|
|
139
|
+
init?.signal?.addEventListener("abort", cancel, { once: true });
|
|
140
|
+
if (init?.signal?.aborted)
|
|
141
|
+
cancel();
|
|
142
|
+
const timeout = setTimeout(() => controller.abort(new Error("MCP HTTP request timed out.")), init?.method === "DELETE" ? options.closeTimeoutMs : options.requestTimeoutMs);
|
|
143
|
+
const finish = () => {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
activeRequests.delete(controller);
|
|
146
|
+
init?.signal?.removeEventListener("abort", cancel);
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
const response = await (options.fetch ?? fetch)(input, {
|
|
150
|
+
...init,
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
redirect: "error"
|
|
153
|
+
});
|
|
154
|
+
if (response.body === null) {
|
|
155
|
+
finish();
|
|
156
|
+
return response;
|
|
157
|
+
}
|
|
158
|
+
const reader = response.body.getReader();
|
|
159
|
+
let stopReading;
|
|
160
|
+
const release = () => {
|
|
161
|
+
finish();
|
|
162
|
+
if (stopReading !== undefined)
|
|
163
|
+
controller.signal.removeEventListener("abort", stopReading);
|
|
164
|
+
};
|
|
165
|
+
const body = new ReadableStream({
|
|
166
|
+
start(stream) {
|
|
167
|
+
stopReading = () => {
|
|
168
|
+
void reader.cancel(controller.signal.reason).catch(() => undefined);
|
|
169
|
+
stream.error(controller.signal.reason);
|
|
170
|
+
release();
|
|
171
|
+
};
|
|
172
|
+
controller.signal.addEventListener("abort", stopReading, { once: true });
|
|
173
|
+
if (controller.signal.aborted)
|
|
174
|
+
stopReading();
|
|
175
|
+
},
|
|
176
|
+
async pull(stream) {
|
|
177
|
+
try {
|
|
178
|
+
const result = await reader.read();
|
|
179
|
+
if (controller.signal.aborted)
|
|
180
|
+
return;
|
|
181
|
+
if (result.done) {
|
|
182
|
+
release();
|
|
183
|
+
stream.close();
|
|
184
|
+
}
|
|
185
|
+
else
|
|
186
|
+
stream.enqueue(result.value);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
release();
|
|
190
|
+
stream.error(error);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
async cancel(reason) {
|
|
194
|
+
release();
|
|
195
|
+
await reader.cancel(reason);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return new Response(body, {
|
|
199
|
+
status: response.status,
|
|
200
|
+
statusText: response.statusText,
|
|
201
|
+
headers: response.headers
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
finish();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
let closing;
|
|
211
|
+
const cancel = () => {
|
|
212
|
+
void close().catch(() => undefined);
|
|
213
|
+
};
|
|
214
|
+
function close() {
|
|
215
|
+
closing ??= (async () => {
|
|
216
|
+
signal?.removeEventListener("abort", cancel);
|
|
217
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
218
|
+
for (const request of activeRequests)
|
|
219
|
+
request.abort(new Error("MCP connection closed."));
|
|
220
|
+
await client.close();
|
|
221
|
+
transport.dispose();
|
|
222
|
+
await new Promise((resolve, reject) => {
|
|
223
|
+
let forced;
|
|
224
|
+
const timeout = setTimeout(() => {
|
|
225
|
+
if (child !== undefined && child.exitCode === null && child.signalCode === null)
|
|
226
|
+
child.kill("SIGKILL");
|
|
227
|
+
forced = setTimeout(() => reject(new Error("MCP transport did not close.")), options.closeTimeoutMs);
|
|
228
|
+
}, options.closeTimeoutMs);
|
|
229
|
+
void transport.closed.then(() => {
|
|
230
|
+
clearTimeout(timeout);
|
|
231
|
+
clearTimeout(forced);
|
|
232
|
+
resolve();
|
|
233
|
+
}, (error) => {
|
|
234
|
+
clearTimeout(timeout);
|
|
235
|
+
clearTimeout(forced);
|
|
236
|
+
reject(error);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
})();
|
|
240
|
+
return closing;
|
|
241
|
+
}
|
|
242
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
243
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
244
|
+
const aborted = signal?.aborted || options.signal?.aborted;
|
|
245
|
+
if (aborted)
|
|
246
|
+
cancel();
|
|
247
|
+
const connecting = aborted
|
|
248
|
+
? Promise.reject(signal?.aborted ? signal.reason : options.signal?.reason)
|
|
249
|
+
: client.connect(transport);
|
|
250
|
+
const ready = connecting.then(() => client, async (error) => {
|
|
251
|
+
await close().catch(() => undefined);
|
|
252
|
+
throw signal?.aborted
|
|
253
|
+
? signal.reason
|
|
254
|
+
: options.signal?.aborted
|
|
255
|
+
? options.signal.reason
|
|
256
|
+
: error;
|
|
257
|
+
});
|
|
258
|
+
return { ready, close };
|
|
259
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type McpModuleOptions } from "./mcp-transport.js";
|
|
1
2
|
export type McpModuleServerHandle = {
|
|
2
3
|
command: string;
|
|
3
4
|
args?: string[];
|
|
@@ -42,8 +43,21 @@ type McpConnection = {
|
|
|
42
43
|
}>): Promise<McpModuleToolBatchResult[]>;
|
|
43
44
|
};
|
|
44
45
|
export type ConnectMcp = (server: McpModuleServerHandle) => Promise<McpConnection> | McpConnection;
|
|
45
|
-
export
|
|
46
|
+
export type McpNamedServerHandle = {
|
|
47
|
+
name: string;
|
|
48
|
+
};
|
|
49
|
+
export type ManagedMcpClient = McpModuleClient & {
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
export type ManagedMcpModule = {
|
|
53
|
+
servers: Record<string, ManagedMcpClient>;
|
|
54
|
+
server(name: string): McpNamedServerHandle;
|
|
55
|
+
client(handle: McpNamedServerHandle | string): Promise<ManagedMcpClient>;
|
|
56
|
+
};
|
|
57
|
+
type CustomMcpModule = {
|
|
46
58
|
server(server: McpModuleServerHandle): McpModuleServerHandle;
|
|
47
59
|
client(handle: McpModuleServerHandle): Promise<McpModuleClient>;
|
|
48
60
|
};
|
|
61
|
+
export declare function makeMcpModule(options: McpModuleOptions): ManagedMcpModule;
|
|
62
|
+
export declare function makeMcpModule(connectMcp: ConnectMcp): CustomMcpModule;
|
|
49
63
|
export {};
|
|
@@ -1,5 +1,112 @@
|
|
|
1
|
+
import { McpError } from "tiny-mcp-client";
|
|
2
|
+
import { hostErrorData } from "../error/shape.js";
|
|
3
|
+
import { declareHostOperation } from "../interp/host-bridge.js";
|
|
4
|
+
import { runResources } from "../interp/resources.js";
|
|
5
|
+
import { connectMcpTransport, normalizeMcpOptions } from "./mcp-transport.js";
|
|
1
6
|
const DEFAULT_TOOL_BATCH_CONCURRENCY = 4;
|
|
2
7
|
export function makeMcpModule(connectMcp) {
|
|
8
|
+
if (typeof connectMcp !== "function") {
|
|
9
|
+
const options = normalizeMcpOptions(connectMcp);
|
|
10
|
+
const servers = Object.create(null);
|
|
11
|
+
for (const [name, config] of Object.entries(options.servers)) {
|
|
12
|
+
const connections = new WeakMap();
|
|
13
|
+
const directScope = {};
|
|
14
|
+
const connection = () => {
|
|
15
|
+
const scope = runResources.getStore();
|
|
16
|
+
scope?.signal.throwIfAborted();
|
|
17
|
+
options.signal?.throwIfAborted();
|
|
18
|
+
const owner = scope ?? directScope;
|
|
19
|
+
let current = connections.get(owner);
|
|
20
|
+
if (current === undefined) {
|
|
21
|
+
current = connectMcpTransport(config, options, scope?.signal);
|
|
22
|
+
connections.set(owner, current);
|
|
23
|
+
scope?.add(current.close);
|
|
24
|
+
}
|
|
25
|
+
return current.ready;
|
|
26
|
+
};
|
|
27
|
+
const invoke = async (operation) => {
|
|
28
|
+
try {
|
|
29
|
+
const result = await operation(await connection());
|
|
30
|
+
runResources.getStore()?.signal.throwIfAborted();
|
|
31
|
+
options.signal?.throwIfAborted();
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
runResources.getStore()?.signal.throwIfAborted();
|
|
36
|
+
options.signal?.throwIfAborted();
|
|
37
|
+
if (error instanceof McpError) {
|
|
38
|
+
hostErrorData.set(error, {
|
|
39
|
+
code: error.code,
|
|
40
|
+
...(error.data === undefined ? {} : { data: error.data })
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
servers[name] = {
|
|
47
|
+
tools: declareHostOperation(async () => invoke(async (client) => {
|
|
48
|
+
const tools = [];
|
|
49
|
+
const cursors = new Set();
|
|
50
|
+
let pages = 0;
|
|
51
|
+
let cursor;
|
|
52
|
+
do {
|
|
53
|
+
const result = await client.listTools(cursor === undefined ? undefined : { cursor });
|
|
54
|
+
pages += 1;
|
|
55
|
+
tools.push(...normalizeToolsResult(result));
|
|
56
|
+
cursor = result.nextCursor;
|
|
57
|
+
if (cursor !== undefined) {
|
|
58
|
+
if (typeof cursor !== "string" || cursors.has(cursor))
|
|
59
|
+
throw new Error("MCP tools pagination did not advance.");
|
|
60
|
+
cursors.add(cursor);
|
|
61
|
+
if (pages >= options.maxToolPages)
|
|
62
|
+
throw new Error("MCP tools page limit exceeded.");
|
|
63
|
+
}
|
|
64
|
+
} while (cursor !== undefined);
|
|
65
|
+
return tools;
|
|
66
|
+
}), "re-issue"),
|
|
67
|
+
tool: declareHostOperation(async (toolName, args) => {
|
|
68
|
+
const request = createNullRecord({
|
|
69
|
+
name: readNonEmptyTrimmedString(toolName, "MCP tool name"),
|
|
70
|
+
...(args === undefined ? {} : { arguments: readToolArguments(args) })
|
|
71
|
+
});
|
|
72
|
+
return invoke((client) => client.callTool(request));
|
|
73
|
+
}, "read-side-effect"),
|
|
74
|
+
toolBatch: declareHostOperation(async (calls) => {
|
|
75
|
+
const normalized = readToolBatchCalls(calls);
|
|
76
|
+
if (normalized.length === 0)
|
|
77
|
+
return [];
|
|
78
|
+
return invoke((client) => executeToolBatch(client, normalized));
|
|
79
|
+
}, "read-side-effect"),
|
|
80
|
+
close: declareHostOperation(async () => {
|
|
81
|
+
const owner = runResources.getStore() ?? directScope;
|
|
82
|
+
const current = connections.get(owner);
|
|
83
|
+
if (current !== undefined) {
|
|
84
|
+
await current.close();
|
|
85
|
+
connections.delete(owner);
|
|
86
|
+
}
|
|
87
|
+
}, "re-issue")
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const readName = (value) => {
|
|
91
|
+
const name = readNonEmptyString(value, "MCP server name");
|
|
92
|
+
if (!Object.hasOwn(servers, name))
|
|
93
|
+
throw new Error(`MCP server '${name}' is not configured.`);
|
|
94
|
+
return name;
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
servers,
|
|
98
|
+
server(name) {
|
|
99
|
+
return createNullRecord({ name: readName(name) });
|
|
100
|
+
},
|
|
101
|
+
async client(handle) {
|
|
102
|
+
if (typeof handle === "string")
|
|
103
|
+
return servers[readName(handle)];
|
|
104
|
+
if (!isRecord(handle) || Object.keys(handle).length !== 1 || !Object.hasOwn(handle, "name"))
|
|
105
|
+
throw new TypeError("MCP client requires a named server handle.");
|
|
106
|
+
return servers[readName(getOwnProperty(handle, "name"))];
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
3
110
|
return {
|
|
4
111
|
server(server) {
|
|
5
112
|
return normalizeServerHandle(server);
|