@astral-mcp/projection 0.2.0

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/index.mjs ADDED
@@ -0,0 +1,2929 @@
1
+ import { createRequire } from "node:module";
2
+ import { InMemoryTransport, McpServer, ProtocolError, ProtocolErrorCode, acceptedContent, inputRequired, inputResponse, isInputRequiredResult } from "@modelcontextprotocol/server";
3
+ import { z } from "zod";
4
+ import { Client } from "@modelcontextprotocol/client";
5
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
6
+ import { deflateRawSync, inflateRawSync } from "node:zlib";
7
+ import { getModuleLoader, getTypescriptSupport, loadQuickJs } from "@sebastianwessel/quickjs";
8
+ import { readFileSync, readdirSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ //#region src/errors.ts
11
+ /**
12
+ * Error carrying the structured `isError` body every failure returns (R25).
13
+ */
14
+ var ProjectionError = class extends Error {
15
+ phase;
16
+ diagnostics;
17
+ hint;
18
+ logs;
19
+ constructor(phase, message, options = {}) {
20
+ super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
21
+ this.name = "ProjectionError";
22
+ this.phase = phase;
23
+ this.diagnostics = options.diagnostics ?? [];
24
+ this.hint = options.hint ?? "";
25
+ this.logs = options.logs ?? [];
26
+ }
27
+ toStructured() {
28
+ return {
29
+ phase: this.phase,
30
+ message: this.message,
31
+ diagnostics: this.diagnostics,
32
+ hint: this.hint,
33
+ logs: this.logs
34
+ };
35
+ }
36
+ };
37
+ /** Convert any thrown value into a StructuredError, defaulting to `runtime` phase. */
38
+ function toStructuredError(err, logs = []) {
39
+ if (err instanceof ProjectionError) {
40
+ const structured = err.toStructured();
41
+ return {
42
+ ...structured,
43
+ logs: structured.logs.length > 0 ? structured.logs : logs
44
+ };
45
+ }
46
+ return {
47
+ phase: "runtime",
48
+ message: err instanceof Error ? err.message : String(err),
49
+ diagnostics: [],
50
+ hint: "",
51
+ logs
52
+ };
53
+ }
54
+ //#endregion
55
+ //#region src/introspector/introspector.ts
56
+ /**
57
+ * Introspector — connects to the wrapped server(s), reads `tools/list`,
58
+ * annotations, and `instructions`, and maintains the tool catalog (PRD §7,
59
+ * G1, R29, R30). Also implements {@link ToolExecutor}: all tool calls the
60
+ * machine host performs against a wrapped server go through here.
61
+ *
62
+ * Two source kinds are supported (R4):
63
+ *
64
+ * - **In-process instance**: an `McpServer` from `@modelcontextprotocol/server`.
65
+ * The introspector connects it to an in-memory transport pair and talks to
66
+ * it with a real MCP client, so introspection and execution exercise the
67
+ * exact same code path as a remote server. The instance must be dedicated
68
+ * to Projection (not already connected to another transport) — Projection is
69
+ * the facade; the wrapped server is never exposed directly (R1).
70
+ * - **Remote connection**: an already-connected SDK `Client`. The caller owns
71
+ * its lifecycle; `close()` will not touch it.
72
+ */
73
+ const CLIENT_INFO = {
74
+ name: "projection-introspector",
75
+ version: "0.1.0"
76
+ };
77
+ function isTextBlock(block) {
78
+ return typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string";
79
+ }
80
+ function mapAnnotations(annotations) {
81
+ if (annotations === void 0) return void 0;
82
+ const mapped = {};
83
+ if (annotations.title !== void 0) mapped.title = annotations.title;
84
+ if (annotations.readOnlyHint !== void 0) mapped.readOnlyHint = annotations.readOnlyHint;
85
+ if (annotations.destructiveHint !== void 0) mapped.destructiveHint = annotations.destructiveHint;
86
+ if (annotations.idempotentHint !== void 0) mapped.idempotentHint = annotations.idempotentHint;
87
+ return Object.keys(mapped).length > 0 ? mapped : void 0;
88
+ }
89
+ function toCatalogEntry(tool) {
90
+ const entry = {
91
+ name: tool.name,
92
+ inputSchema: tool.inputSchema
93
+ };
94
+ if (tool.description !== void 0) entry.description = tool.description;
95
+ if (tool.outputSchema !== void 0) entry.outputSchema = tool.outputSchema;
96
+ const annotations = mapAnnotations(tool.annotations);
97
+ if (annotations !== void 0) entry.annotations = annotations;
98
+ return entry;
99
+ }
100
+ /**
101
+ * Bounds on `tools/list` introspection. A wrapped server is untrusted (S5)
102
+ * and could return an endless chain of `nextCursor` pages, or a tool count
103
+ * large enough to blow up the generated artifacts; either would hang or
104
+ * exhaust the process before any workflow runs.
105
+ */
106
+ const MAX_TOOL_LIST_PAGES = 100;
107
+ const MAX_TOOLS = 2e3;
108
+ async function listAllTools(client, alias) {
109
+ const tools = [];
110
+ let cursor;
111
+ let pages = 0;
112
+ try {
113
+ do {
114
+ const page = await client.listTools(cursor !== void 0 ? { cursor } : void 0);
115
+ tools.push(...page.tools);
116
+ cursor = page.nextCursor;
117
+ pages += 1;
118
+ if (pages >= MAX_TOOL_LIST_PAGES || tools.length >= MAX_TOOLS) {
119
+ if (cursor !== void 0) throw new ProjectionError("protocol", `wrapped server "${alias}" exceeded introspection limits (${pages} pages, ${tools.length} tools)`, { hint: `Projection reads at most ${MAX_TOOL_LIST_PAGES} tools/list pages and ${MAX_TOOLS} tools. Wrap a server with a bounded tool surface.` });
120
+ break;
121
+ }
122
+ } while (cursor !== void 0);
123
+ } catch (err) {
124
+ if (err instanceof ProjectionError) throw err;
125
+ if (err instanceof ProtocolError && err.code === ProtocolErrorCode.MethodNotFound) return [];
126
+ if (err instanceof Error && /does not support tools/i.test(err.message)) return [];
127
+ throw new ProjectionError("protocol", `tools/list failed for wrapped server "${alias}": ${err instanceof Error ? err.message : String(err)}`, {
128
+ hint: "Check that the wrapped server is running, connected, and supports the tools capability.",
129
+ cause: err
130
+ });
131
+ }
132
+ return tools;
133
+ }
134
+ async function buildServerCatalog(alias, client) {
135
+ const tools = await listAllTools(client, alias);
136
+ const info = client.getServerVersion();
137
+ const catalog = {
138
+ alias,
139
+ identity: info !== void 0 ? `${info.name}@${info.version}` : alias,
140
+ tools: tools.map(toCatalogEntry)
141
+ };
142
+ const instructions = client.getInstructions();
143
+ if (instructions !== void 0) catalog.instructions = instructions;
144
+ return catalog;
145
+ }
146
+ /** Extract a one-line error message from an `isError` tool result's content. */
147
+ function errorTextFromContent(content) {
148
+ if (!Array.isArray(content)) return void 0;
149
+ const text = content.filter(isTextBlock).map((block) => block.text).join("\n").trim();
150
+ return text.length > 0 ? text : void 0;
151
+ }
152
+ /**
153
+ * Pick the value delivered to the machine as `TOOL_RESULT` payload:
154
+ * `structuredContent` when the tool declares one, otherwise the text of a
155
+ * single text block, otherwise the raw content array (`null` when empty).
156
+ */
157
+ function pickResultValue(result) {
158
+ if (result.structuredContent !== void 0) return result.structuredContent;
159
+ const content = Array.isArray(result.content) ? result.content : [];
160
+ const first = content[0];
161
+ if (content.length === 1 && isTextBlock(first)) return first.text;
162
+ if (content.length === 0) return null;
163
+ return content;
164
+ }
165
+ /**
166
+ * Holds one MCP client per wrapped-server alias, exposes the aggregated
167
+ * {@link ToolCatalog}, and executes tool calls (implements
168
+ * {@link ToolExecutor}). Construct with {@link createIntrospector}.
169
+ */
170
+ var Introspector = class Introspector {
171
+ #connections;
172
+ #catalog;
173
+ constructor(connections, catalog) {
174
+ this.#connections = connections;
175
+ this.#catalog = catalog;
176
+ }
177
+ static async create(sources) {
178
+ const aliases = Object.keys(sources);
179
+ if (aliases.length === 0) throw new ProjectionError("protocol", "No wrapped server was provided.", { hint: "Pass at least one server source, e.g. { default: myMcpServer }." });
180
+ const connections = /* @__PURE__ */ new Map();
181
+ const servers = /* @__PURE__ */ new Map();
182
+ const failed = (await Promise.allSettled(aliases.map(async (alias) => {
183
+ const source = sources[alias];
184
+ if (source === void 0) return;
185
+ const connection = await Introspector.#connect(alias, source);
186
+ connections.set(alias, connection);
187
+ servers.set(alias, await buildServerCatalog(alias, connection.client));
188
+ }))).find((result) => result.status === "rejected");
189
+ if (failed !== void 0) {
190
+ await Introspector.#closeConnections(connections);
191
+ throw failed.reason;
192
+ }
193
+ return new Introspector(connections, { servers });
194
+ }
195
+ static async #connect(alias, source) {
196
+ if (source instanceof McpServer) {
197
+ if (source.isConnected()) throw new ProjectionError("protocol", `Wrapped server "${alias}" is already connected to a transport.`, { hint: "Pass a dedicated McpServer instance — Projection owns the wrapped server's transport and exposes it only through the code-mode facade." });
198
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
199
+ const client = new Client(CLIENT_INFO, { capabilities: {} });
200
+ await source.connect(serverTransport);
201
+ await client.connect(clientTransport);
202
+ return {
203
+ client,
204
+ owned: true
205
+ };
206
+ }
207
+ return {
208
+ client: source,
209
+ owned: false
210
+ };
211
+ }
212
+ static async #closeConnections(connections) {
213
+ await Promise.all([...connections.values()].filter((connection) => connection.owned).map((connection) => connection.client.close().catch(() => void 0)));
214
+ }
215
+ /** The aggregated tool catalog, keyed by server alias (R30). */
216
+ get catalog() {
217
+ return this.#catalog;
218
+ }
219
+ /** Re-read `tools/list` from every wrapped server and rebuild the catalog. */
220
+ async refresh() {
221
+ const rebuilt = await Promise.all([...this.#connections].map(async ([alias, connection]) => [alias, await buildServerCatalog(alias, connection.client)]));
222
+ this.#catalog = { servers: new Map(rebuilt) };
223
+ return this.#catalog;
224
+ }
225
+ /**
226
+ * Execute a tool call against the wrapped server identified by `alias`,
227
+ * mapping the MCP result to a {@link ToolCallOutcome}:
228
+ *
229
+ * - success → `{ ok: true, result }` where `result` is the tool's
230
+ * `structuredContent` when present, else a single text block's text,
231
+ * else the raw content array.
232
+ * - `isError` result or transport/protocol failure → `{ ok: false, error }`.
233
+ *
234
+ * An unknown alias is a wiring bug, not a tool failure, and throws.
235
+ */
236
+ async callTool(alias, name, args) {
237
+ const connection = this.#connections.get(alias);
238
+ if (connection === void 0) throw new ProjectionError("protocol", `Unknown wrapped-server alias "${alias}".`, { hint: `Known aliases: ${[...this.#connections.keys()].join(", ")}. v1 wraps exactly one server under the "default" alias.` });
239
+ try {
240
+ const result = await connection.client.callTool({
241
+ name,
242
+ arguments: args
243
+ });
244
+ if (result.isError === true) return {
245
+ ok: false,
246
+ error: {
247
+ message: errorTextFromContent(result.content) ?? `Tool "${name}" returned an error result.`,
248
+ detail: result.structuredContent ?? result.content
249
+ }
250
+ };
251
+ return {
252
+ ok: true,
253
+ result: pickResultValue(result)
254
+ };
255
+ } catch (err) {
256
+ return {
257
+ ok: false,
258
+ error: {
259
+ message: `Tool call "${name}" failed: ${err instanceof Error ? err.message : String(err)}`,
260
+ detail: err instanceof Error ? { name: err.name } : err
261
+ }
262
+ };
263
+ }
264
+ }
265
+ /**
266
+ * Close connections the introspector created (in-process sources).
267
+ * Caller-provided remote clients are left untouched.
268
+ */
269
+ async close() {
270
+ await Introspector.#closeConnections(this.#connections);
271
+ this.#connections.clear();
272
+ }
273
+ };
274
+ /**
275
+ * Connect to the given wrapped servers and build their tool catalog.
276
+ *
277
+ * v1 usage: `createIntrospector({ [DEFAULT_SERVER_ALIAS]: serverOrClient })`.
278
+ */
279
+ async function createIntrospector(sources) {
280
+ return Introspector.create(sources);
281
+ }
282
+ //#endregion
283
+ //#region src/types.ts
284
+ const DEFAULT_SERVER_ALIAS = "default";
285
+ /**
286
+ * Name → entry index, built lazily and cached per catalog object. The
287
+ * catalog is immutable once introspected, so a `WeakMap` keyed on identity
288
+ * lets every consumer look a tool up in O(1) without the catalog having to
289
+ * carry (and every construction site having to build) the index itself.
290
+ */
291
+ const catalogIndex = /* @__PURE__ */ new WeakMap();
292
+ /**
293
+ * Look up a tool by name in a catalog, O(1) after the first call.
294
+ *
295
+ * Duplicate names resolve to the FIRST occurrence, matching `Array#find` and
296
+ * typegen's `dedupeToolNames` (S5): a hostile server declaring the same name
297
+ * twice must not be able to make the policy gate see a different entry (e.g.
298
+ * a later `readOnlyHint: true` copy) than the one typegen exposed.
299
+ */
300
+ function findTool(catalog, name) {
301
+ let index = catalogIndex.get(catalog);
302
+ if (index === void 0) {
303
+ index = /* @__PURE__ */ new Map();
304
+ for (const tool of catalog.tools) if (!index.has(tool.name)) index.set(tool.name, tool);
305
+ catalogIndex.set(catalog, index);
306
+ }
307
+ return index.get(name);
308
+ }
309
+ /** Narrow to a non-null, non-array object. */
310
+ function isRecord(value) {
311
+ return typeof value === "object" && value !== null && !Array.isArray(value);
312
+ }
313
+ const DEFAULT_CAPS = {
314
+ segmentWallClockMs: 3e4,
315
+ wasmMemoryBytes: 134217728,
316
+ maxCodeBytes: 131072,
317
+ maxInputBytes: 131072,
318
+ maxReadCallsPerSegment: 50,
319
+ maxRoundTrips: 8,
320
+ maxEventsPerSegment: 1e4,
321
+ maxResultBytes: 262144,
322
+ maxStateNodes: 100,
323
+ maxConsoleBytes: 65536
324
+ };
325
+ const MAX_CAPS = {
326
+ segmentWallClockMs: 6e4,
327
+ wasmMemoryBytes: 268435456,
328
+ maxCodeBytes: 262144,
329
+ maxInputBytes: 262144,
330
+ maxReadCallsPerSegment: 200,
331
+ maxRoundTrips: 16,
332
+ maxEventsPerSegment: 5e4,
333
+ maxResultBytes: 1048576,
334
+ maxStateNodes: 1e3,
335
+ maxConsoleBytes: 262144
336
+ };
337
+ /**
338
+ * Smallest value each cap may be configured down to.
339
+ *
340
+ * Caps are "configurable downward" (R12), but zero or negative would brick
341
+ * the flow rather than restrict it — a `maxRoundTrips` of 0 makes every
342
+ * gated workflow terminal, and a `segmentWallClockMs` of 0 fails every
343
+ * execution. Misconfiguration should not look like a library bug.
344
+ */
345
+ const MIN_CAPS = {
346
+ segmentWallClockMs: 100,
347
+ wasmMemoryBytes: 1048576,
348
+ maxCodeBytes: 1024,
349
+ maxInputBytes: 1024,
350
+ maxReadCallsPerSegment: 1,
351
+ maxRoundTrips: 1,
352
+ maxEventsPerSegment: 1,
353
+ maxResultBytes: 1024,
354
+ maxStateNodes: 1,
355
+ maxConsoleBytes: 1024
356
+ };
357
+ /** Clamp requested caps into [MIN_CAPS, MAX_CAPS] (R12). */
358
+ function clampCaps(requested) {
359
+ const caps = { ...DEFAULT_CAPS };
360
+ for (const key of Object.keys(caps)) {
361
+ const value = requested[key];
362
+ if (value !== void 0 && Number.isFinite(value)) caps[key] = Math.max(MIN_CAPS[key], Math.min(value, MAX_CAPS[key]));
363
+ }
364
+ return caps;
365
+ }
366
+ const BLOB_SCHEMA_VERSION = 1;
367
+ //#endregion
368
+ //#region src/blob/versions.ts
369
+ /**
370
+ * Version constants recorded into every blob (R20) and strict-matched on
371
+ * open (R21, Q3: no migration — mismatches are rejected).
372
+ *
373
+ * Hardcoded rather than imported from package.json at runtime: JSON import
374
+ * attributes complicate the tsdown/dist story for zero benefit, and both
375
+ * versions are pinned exactly (DECISIONS.md D2). A test in
376
+ * `test/blob/versions.test.ts` asserts these constants match the installed
377
+ * package.json versions, so a version bump that forgets to update them fails
378
+ * CI instead of silently minting blobs with a stale version stamp.
379
+ */
380
+ /** `@astral-mcp/projection` package version (must match packages/projection/package.json). */
381
+ const LIB_VERSION = "0.2.0";
382
+ /** Pinned XState version (must match the exact pin in package.json — D2). */
383
+ const XSTATE_VERSION = "5.32.5";
384
+ //#endregion
385
+ //#region src/blob/sealer.ts
386
+ /**
387
+ * Suspension blob sealer (PRD §7 "Blob sealer"; R19–R23, S2–S4).
388
+ *
389
+ * Packages a {@link BlobPayload} into an opaque, authenticated, encrypted,
390
+ * compressed envelope string that rides in the MRTR `requestState` field,
391
+ * and verifies/opens it on resume. Clients are honest-but-curious couriers
392
+ * (S3): they must neither read machine context (encryption) nor modify
393
+ * anything (authentication). Every failure mode fails closed with a
394
+ * ProjectionError; a payload is never returned partially verified.
395
+ *
396
+ * ## Envelope format (version `proj1`)
397
+ *
398
+ * ```
399
+ * proj1.<salt>.<iv>.<ciphertext>.<tag>
400
+ * ```
401
+ *
402
+ * Five dot-separated fields; all but the literal `proj1` prefix are
403
+ * unpadded base64url:
404
+ *
405
+ * - `salt` — 16 random bytes, HKDF salt for per-blob key derivation
406
+ * - `iv` — 12 random bytes, AES-GCM nonce
407
+ * - `ciphertext` — AES-256-GCM over deflateRaw(JSON(payload))
408
+ * - `tag` — 16-byte GCM authentication tag
409
+ *
410
+ * AAD is the UTF-8 bytes of the envelope header `proj1.<salt>.<iv>` (the
411
+ * exact base64url text as transmitted), so the version prefix and header
412
+ * fields are covered by the authentication tag. Tampering with any field
413
+ * fails closed: the prefix and header break AAD verification, the salt
414
+ * additionally derives a different key, and the iv/ciphertext/tag break GCM
415
+ * authentication directly.
416
+ *
417
+ * ## Crypto choices
418
+ *
419
+ * - **AES-256-GCM** (R19): AEAD, native in node:crypto.
420
+ * - **HKDF-SHA256** per-blob key derivation: the developer-supplied 32-byte
421
+ * key is treated as input keying material, never used directly as a
422
+ * cipher key. A fresh random 16-byte salt per blob means every blob is
423
+ * encrypted under a distinct derived key, so the 96-bit random GCM nonce
424
+ * can never repeat *under the same key* even across horizontally scaled
425
+ * instances sealing concurrently (nonce-reuse would be catastrophic for
426
+ * GCM). Fixed info string `@astral-mcp/projection/blob/v1` domain-separates the
427
+ * derivation.
428
+ * - **deflateRaw** compression (R23), not brotli: brotli's default quality
429
+ * is far too slow for the seal+verify+rehydrate p95 < 100 ms budget
430
+ * (PRD §10), and at speed-comparable quality settings its ratio edge on
431
+ * small (< 32 KB typical) JSON payloads is marginal. Raw deflate also
432
+ * omits the zlib header/adler32 checksum — GCM already guarantees
433
+ * integrity — saving a few bytes.
434
+ * - **Sync APIs throughout**: payloads are small (128 KB ceiling), so the
435
+ * sync node:crypto/node:zlib forms are faster than promise overhead and
436
+ * keep the API non-async.
437
+ */
438
+ /** Envelope format version prefix. Bump (proj2, …) on any wire-format change. */
439
+ const ENVELOPE_PREFIX = "proj1";
440
+ /** Default blob TTL: 15 minutes (R20). */
441
+ const DEFAULT_TTL_MS = 9e5;
442
+ /** Default sealed-blob size ceiling: 128 KB (R23). */
443
+ const DEFAULT_MAX_BLOB_BYTES = 131072;
444
+ const KEY_BYTES = 32;
445
+ const SALT_BYTES = 16;
446
+ const IV_BYTES = 12;
447
+ const TAG_BYTES = 16;
448
+ const HKDF_INFO = "@astral-mcp/projection/blob/v1";
449
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
450
+ function verificationError(message, hint) {
451
+ return new ProjectionError("blob_verification", message, { hint });
452
+ }
453
+ const HINT_CORRUPT = "The requestState blob is corrupt; retry with the exact requestState echoed from the input_required result, or re-run execute_machine to start a new workflow.";
454
+ const HINT_AUTH = "The blob failed authentication — it was modified in transit or sealed under a key this deployment does not accept. Re-run execute_machine to start a new workflow.";
455
+ const HINT_VERSION = "The server was upgraded since this workflow suspended; suspended workflows do not survive version changes. Re-run execute_machine to start a new workflow.";
456
+ const HINT_EXPIRED = "The approval window elapsed before the retry arrived. Re-run execute_machine and respond to approval prompts within the TTL.";
457
+ const HINT_CODE = "Retry the call with the byte-exact original code argument; a blob only resumes the code that produced it.";
458
+ const HINT_SERVER = "This blob was minted against a different wrapped server and cannot be resumed here. Re-run execute_machine against this server.";
459
+ function assertKey(key, label) {
460
+ if (!(key instanceof Uint8Array) || key.length !== KEY_BYTES) throw new ProjectionError("terminal", `blob sealer ${label} must be exactly ${KEY_BYTES} bytes`, { hint: "Provide a 32-byte key (e.g. crypto.randomBytes(32)) shared across all instances." });
461
+ }
462
+ function assertConfigKeys(config) {
463
+ assertKey(config.key, "key");
464
+ for (const prev of config.previousKeys ?? []) assertKey(prev, "previousKeys entry");
465
+ }
466
+ /** Derive the actual AES key from developer IKM via HKDF-SHA256 (R19). */
467
+ function deriveKey(ikm, salt) {
468
+ return Buffer.from(hkdfSync("sha256", ikm, salt, HKDF_INFO, KEY_BYTES));
469
+ }
470
+ function buildAad(saltPart, ivPart) {
471
+ return Buffer.from(`${ENVELOPE_PREFIX}.${saltPart}.${ivPart}`, "utf8");
472
+ }
473
+ function decodePart(part, field, expectedBytes) {
474
+ if (part.length === 0 || !BASE64URL.test(part)) throw verificationError(`envelope parse failed: ${field} is not valid base64url`, HINT_CORRUPT);
475
+ const bytes = Buffer.from(part, "base64url");
476
+ if (expectedBytes !== void 0 && bytes.length !== expectedBytes) throw verificationError(`envelope parse failed: ${field} has unexpected length`, HINT_CORRUPT);
477
+ return bytes;
478
+ }
479
+ function parseEnvelope(blob) {
480
+ const parts = blob.split(".");
481
+ const [prefix, saltPart, ivPart, ciphertextPart, tagPart] = parts;
482
+ if (parts.length !== 5 || prefix === void 0 || saltPart === void 0 || ivPart === void 0 || ciphertextPart === void 0 || tagPart === void 0) throw verificationError("envelope parse failed: expected 5 dot-separated fields", HINT_CORRUPT);
483
+ if (prefix !== "proj1") throw verificationError(`envelope parse failed: unsupported envelope version (expected "${ENVELOPE_PREFIX}")`, HINT_VERSION);
484
+ return {
485
+ salt: decodePart(saltPart, "salt", SALT_BYTES),
486
+ iv: decodePart(ivPart, "iv", IV_BYTES),
487
+ ciphertext: decodePart(ciphertextPart, "ciphertext"),
488
+ tag: decodePart(tagPart, "tag", TAG_BYTES),
489
+ aad: buildAad(saltPart, ivPart)
490
+ };
491
+ }
492
+ function decryptWith(envelope, ikm) {
493
+ const key = deriveKey(ikm, envelope.salt);
494
+ const decipher = createDecipheriv("aes-256-gcm", key, envelope.iv);
495
+ decipher.setAAD(envelope.aad);
496
+ decipher.setAuthTag(envelope.tag);
497
+ return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
498
+ }
499
+ /** Try the current key, then each previous key (R22 rotation grace). */
500
+ function decryptWithAnyKey(envelope, config) {
501
+ const candidates = [config.key, ...config.previousKeys ?? []];
502
+ for (const ikm of candidates) try {
503
+ return decryptWith(envelope, ikm);
504
+ } catch {}
505
+ throw verificationError("authentication failed: blob could not be decrypted and verified with any configured key", HINT_AUTH);
506
+ }
507
+ function malformed(field) {
508
+ return verificationError(`blob payload malformed: field "${field}" is missing or mistyped`, HINT_CORRUPT);
509
+ }
510
+ /**
511
+ * Structural check of everything except v/libv/xstatev (already exact-matched
512
+ * by the caller before this runs). The payload is authenticated, so failures
513
+ * here indicate a sealer bug or cross-version confusion — still fail closed.
514
+ */
515
+ function assertPayloadShape(payload) {
516
+ if (typeof payload["codeHash"] !== "string" || payload["codeHash"].length === 0) throw malformed("codeHash");
517
+ if (typeof payload["serverIdentity"] !== "string" || payload["serverIdentity"].length === 0) throw malformed("serverIdentity");
518
+ if (typeof payload["nonce"] !== "string" || payload["nonce"].length === 0) throw malformed("nonce");
519
+ if (typeof payload["iat"] !== "number" || !Number.isFinite(payload["iat"])) throw malformed("iat");
520
+ if (typeof payload["exp"] !== "number" || !Number.isFinite(payload["exp"])) throw malformed("exp");
521
+ if (typeof payload["trip"] !== "number" || !Number.isInteger(payload["trip"])) throw malformed("trip");
522
+ if (!("snapshot" in payload)) throw malformed("snapshot");
523
+ const pending = payload["pending"];
524
+ if (!isRecord(pending)) throw malformed("pending");
525
+ for (const [key, call] of Object.entries(pending)) if (!isRecord(call) || typeof call["id"] !== "string" || typeof call["name"] !== "string" || typeof call["stateName"] !== "string" || !isRecord(call["args"])) throw malformed(`pending["${key}"]`);
526
+ if (!Array.isArray(payload["audit"])) throw malformed("audit");
527
+ }
528
+ /** SHA-256 hex digest of a code argument (S4 binding — R20 `codeHash`). */
529
+ function hashCode(code) {
530
+ return createHash("sha256").update(code, "utf8").digest("hex");
531
+ }
532
+ /**
533
+ * Construct a fully populated {@link BlobPayload}: stamps the schema and
534
+ * library/XState versions, hashes the code, and fills nonce/iat/exp.
535
+ */
536
+ function buildBlobPayload(input) {
537
+ const iat = input.now ?? Date.now();
538
+ const ttlMs = input.ttlMs ?? 9e5;
539
+ return {
540
+ v: 1,
541
+ libv: LIB_VERSION,
542
+ xstatev: XSTATE_VERSION,
543
+ codeHash: hashCode(input.code),
544
+ serverIdentity: input.serverIdentity,
545
+ snapshot: input.snapshot,
546
+ pending: input.pending,
547
+ audit: input.audit ?? [],
548
+ trip: input.trip,
549
+ nonce: randomBytes(16).toString("base64url"),
550
+ iat,
551
+ exp: iat + ttlMs
552
+ };
553
+ }
554
+ /**
555
+ * Seal a payload into an opaque `proj1.` envelope string (R19, R23).
556
+ *
557
+ * JSON → deflateRaw → AES-256-GCM under an HKDF-SHA256 per-blob derived key,
558
+ * with the envelope header as AAD. Throws ProjectionError phase `terminal` if
559
+ * the sealed envelope exceeds `maxBlobBytes` (default 128 KB) or the config
560
+ * keys are malformed.
561
+ */
562
+ function sealBlob(payload, config) {
563
+ assertKey(config.key, "key");
564
+ const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload), "utf8"), { level: 9 });
565
+ const salt = randomBytes(SALT_BYTES);
566
+ const iv = randomBytes(IV_BYTES);
567
+ const saltPart = salt.toString("base64url");
568
+ const ivPart = iv.toString("base64url");
569
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(config.key, salt), iv);
570
+ cipher.setAAD(buildAad(saltPart, ivPart));
571
+ const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
572
+ const tag = cipher.getAuthTag();
573
+ const blob = `${ENVELOPE_PREFIX}.${saltPart}.${ivPart}.${ciphertext.toString("base64url")}.${tag.toString("base64url")}`;
574
+ const maxBlobBytes = config.maxBlobBytes ?? 131072;
575
+ const size = Buffer.byteLength(blob, "utf8");
576
+ if (size > maxBlobBytes) throw new ProjectionError("terminal", `sealed suspension blob is ${size} bytes, exceeding the ${maxBlobBytes}-byte ceiling`, { hint: "Machine context is too large to suspend. Keep large tool results out of context — store only the fields later states need — then re-run execute_machine." });
577
+ return blob;
578
+ }
579
+ /**
580
+ * Verify and open a sealed blob (R21 — check order is normative):
581
+ *
582
+ * 1. parse envelope + decrypt/authenticate (current key, then each
583
+ * `previousKeys` — R22 rotation grace)
584
+ * 2. schema version, then exact `libv`/`xstatev` match (Q3: strict, no
585
+ * migration)
586
+ * 3. expiry (`exp` strictly in the future relative to `now`)
587
+ * 4. `codeHash` equality against the re-sent code, and `serverIdentity`
588
+ * match
589
+ *
590
+ * Any failure throws ProjectionError phase `blob_verification` naming the
591
+ * failed check (never key material or plaintext). A payload is only
592
+ * returned once every check has passed.
593
+ */
594
+ function openBlob(blob, options) {
595
+ assertConfigKeys(options.config);
596
+ const maxBlobBytes = options.config.maxBlobBytes ?? 131072;
597
+ if (blob.length > maxBlobBytes * 2) throw verificationError(`blob rejected: ${blob.length} characters exceeds the acceptance limit for a ${maxBlobBytes}-byte ceiling`, HINT_CORRUPT);
598
+ const compressed = decryptWithAnyKey(parseEnvelope(blob), options.config);
599
+ let payload;
600
+ try {
601
+ payload = JSON.parse(inflateRawSync(compressed, { maxOutputLength: maxBlobBytes * 64 }).toString("utf8"));
602
+ } catch {
603
+ throw verificationError("blob payload is corrupt: decompression or JSON parse failed after authentication", HINT_CORRUPT);
604
+ }
605
+ if (!isRecord(payload)) throw verificationError("blob payload malformed: not a JSON object", HINT_CORRUPT);
606
+ if (payload["v"] !== 1) throw verificationError(`unknown blob schema version ${JSON.stringify(payload["v"])} (this library expects 1)`, HINT_VERSION);
607
+ if (payload["libv"] !== "0.2.0") throw verificationError(`library version mismatch: blob was sealed by @astral-mcp/projection ${JSON.stringify(payload["libv"])}, this instance runs ${LIB_VERSION}`, HINT_VERSION);
608
+ if (payload["xstatev"] !== "5.32.5") throw verificationError(`xstate version mismatch: blob snapshot was produced under xstate ${JSON.stringify(payload["xstatev"])}, this instance runs ${XSTATE_VERSION}`, HINT_VERSION);
609
+ assertPayloadShape(payload);
610
+ const verified = payload;
611
+ const now = options.now ?? Date.now();
612
+ if (now >= verified.exp) throw verificationError(`blob expired: exp was ${verified.exp} and it is now ${now}`, HINT_EXPIRED);
613
+ if (hashCode(options.code) !== verified.codeHash) throw verificationError("code hash mismatch: the retried call's code differs from the code that produced this blob", HINT_CODE);
614
+ if (options.serverIdentity !== verified.serverIdentity) throw verificationError("server identity mismatch: blob was minted against a different wrapped server", HINT_SERVER);
615
+ return verified;
616
+ }
617
+ //#endregion
618
+ //#region src/sandbox/logs.ts
619
+ const encoder = new TextEncoder();
620
+ /** UTF-8 byte length of a string (shared cached encoder). */
621
+ function utf8Length(value) {
622
+ return encoder.encode(value).length;
623
+ }
624
+ /** Truncate a string to at most `maxBytes` UTF-8 bytes, appending a marker. */
625
+ function truncateUtf8(value, maxBytes) {
626
+ if (utf8Length(value) <= maxBytes) return value;
627
+ const suffix = "…[truncated]";
628
+ let end = Math.min(value.length, maxBytes);
629
+ while (end > 0 && utf8Length(value.slice(0, end)) + utf8Length(suffix) > maxBytes) end = Math.floor(end / 2);
630
+ return value.slice(0, end) + suffix;
631
+ }
632
+ /** Best-effort single-string rendering of one console argument. */
633
+ function formatParam(param) {
634
+ if (typeof param === "string") return param;
635
+ if (param instanceof Error) return `${param.name}: ${param.message}`;
636
+ if (typeof param === "function") return "[function]";
637
+ try {
638
+ const json = JSON.stringify(param);
639
+ return json === void 0 ? String(param) : json;
640
+ } catch {
641
+ return String(param);
642
+ }
643
+ }
644
+ /** Marker prefix used in the synthetic truncation entry. */
645
+ const TRUNCATION_MARKER_PREFIX = "[@astral-mcp/projection] console output truncated:";
646
+ /**
647
+ * Collects console output for one execution segment, enforcing the
648
+ * `maxConsoleBytes` cap with drop-middle truncation.
649
+ */
650
+ var LogCollector = class {
651
+ maxBytes;
652
+ segment;
653
+ head = [];
654
+ tail = [];
655
+ headBytes = 0;
656
+ tailBytes = 0;
657
+ dropped = 0;
658
+ halfBudget;
659
+ startedAt = Date.now();
660
+ constructor(maxBytes, segment) {
661
+ this.maxBytes = maxBytes;
662
+ this.segment = segment;
663
+ this.halfBudget = Math.max(64, Math.floor(maxBytes / 2));
664
+ }
665
+ /** Record one console call. `params` are the marshaled console arguments. */
666
+ push(level, params) {
667
+ const message = truncateUtf8(params.map(formatParam).join(" "), this.halfBudget);
668
+ const cost = utf8Length(message);
669
+ const entry = {
670
+ level,
671
+ message,
672
+ ts: Date.now() - this.startedAt,
673
+ segment: this.segment
674
+ };
675
+ if (this.tail.length === 0 && this.headBytes + cost <= this.halfBudget) {
676
+ this.head.push(entry);
677
+ this.headBytes += cost;
678
+ return;
679
+ }
680
+ this.tail.push(entry);
681
+ this.tailBytes += cost;
682
+ while (this.tailBytes > this.halfBudget && this.tail.length > 1) {
683
+ const evicted = this.tail.shift();
684
+ if (evicted !== void 0) {
685
+ this.tailBytes -= utf8Length(evicted.message);
686
+ this.dropped += 1;
687
+ }
688
+ }
689
+ }
690
+ /**
691
+ * The captured entries in order. When entries were dropped, a synthetic
692
+ * `warn` marker entry sits between the kept head and tail.
693
+ */
694
+ snapshot() {
695
+ if (this.dropped === 0) return [...this.head, ...this.tail];
696
+ const marker = {
697
+ level: "warn",
698
+ message: `${TRUNCATION_MARKER_PREFIX} ${this.dropped} entries dropped (maxConsoleBytes=${this.maxBytes})`,
699
+ ts: this.tail[0]?.ts ?? Date.now() - this.startedAt,
700
+ segment: this.segment
701
+ };
702
+ return [
703
+ ...this.head,
704
+ marker,
705
+ ...this.tail
706
+ ];
707
+ }
708
+ };
709
+ //#endregion
710
+ //#region src/sandbox/mounts.ts
711
+ /**
712
+ * Module mounting and import resolution for the QuickJS sandbox (R8, R9, S1).
713
+ *
714
+ * The sandbox's virtual filesystem contains exactly what module resolution
715
+ * needs and nothing else:
716
+ *
717
+ * - `/src/machine.js` — the (transpiled) authored module
718
+ * - `/node_modules/xstate/*` — the pinned XState v5 ESM build
719
+ * - `/node_modules/@astral-mcp/tools/index.js` — the caller-supplied bridge
720
+ *
721
+ * Import resolution is allowlist-based: only `xstate` and `@astral-mcp/tools`
722
+ * resolve for authored code (plus the handful of pure-utility modules the
723
+ * QuickJS runtime itself boots with: `buffer`, `util`, `url` and the
724
+ * `@node_compatibility/*` shims — none of which perform I/O). Every other
725
+ * specifier — including `node:fs`, `node:child_process`, `http`, arbitrary
726
+ * package names — resolves to a sentinel path whose load fails with a
727
+ * structured, model-actionable error message.
728
+ */
729
+ /** Specifier the authored code uses to import XState (R8). */
730
+ const XSTATE_SPECIFIER = "xstate";
731
+ /** Specifier the authored code uses to import the generated tools bridge (R8). */
732
+ const TOOLS_SPECIFIER = "@astral-mcp/tools";
733
+ /** Sentinel path prefix produced by the normalizer for disallowed imports. */
734
+ const DENIED_PREFIX = "/__projection_denied__/";
735
+ /** Marker embedded in denied-import error messages; used for error classification. */
736
+ const DENIED_IMPORT_MARKER = "cannot be imported inside the sandbox";
737
+ /**
738
+ * Bare specifiers that resolve inside the sandbox. `xstate` and
739
+ * `@astral-mcp/tools` are the authoring contract (R8); the rest are pure
740
+ * utilities required by the QuickJS runtime's own node-compatibility
741
+ * bootstrap (`node:buffer`, `node:util`, `node:url` and the
742
+ * `@node_compatibility/*` shims). None of them expose I/O.
743
+ */
744
+ const ALLOWED_SPECIFIERS = /* @__PURE__ */ new Set([
745
+ XSTATE_SPECIFIER,
746
+ TOOLS_SPECIFIER,
747
+ "buffer",
748
+ "util",
749
+ "url",
750
+ "@node_compatibility/event-target",
751
+ "@node_compatibility/headers",
752
+ "@node_compatibility/request",
753
+ "@node_compatibility/response"
754
+ ]);
755
+ let cachedXStateMount;
756
+ /**
757
+ * Build the virtual `node_modules/xstate` directory from the pinned XState v5
758
+ * package installed on the host: the production ESM entry (`xstate.esm.js`,
759
+ * mounted additionally as `index.js`) plus its sibling ESM chunk files, which
760
+ * it references via relative imports. The chunks are self-contained (no bare
761
+ * imports), so nothing else needs to resolve.
762
+ */
763
+ function getXStateMount() {
764
+ if (cachedXStateMount === void 0) {
765
+ const require = createRequire(import.meta.url);
766
+ const distDir = join(dirname(require.resolve("xstate/package.json")), "dist");
767
+ const mount = {};
768
+ for (const file of readdirSync(distDir)) if (file.endsWith(".esm.js") && !file.includes("development")) mount[file] = readFileSync(join(distDir, file), "utf8");
769
+ const entry = mount["xstate.esm.js"];
770
+ if (entry === void 0) throw new Error("xstate package layout not recognized: dist/xstate.esm.js missing");
771
+ mount["index.js"] = entry;
772
+ cachedXStateMount = mount;
773
+ }
774
+ return cachedXStateMount;
775
+ }
776
+ /** Assemble the sandbox `node_modules` mount: xstate + the tools bridge. */
777
+ function buildNodeModulesMount(bridgeJs) {
778
+ return {
779
+ xstate: { ...getXStateMount() },
780
+ "@astral-mcp": { tools: { "index.js": bridgeJs } }
781
+ };
782
+ }
783
+ /**
784
+ * Allowlisting module path normalizer (R8/R9). Relative imports resolve
785
+ * against the importing module; allowlisted bare specifiers resolve into
786
+ * `/node_modules`; everything else maps to a denied sentinel path.
787
+ *
788
+ * `xstate` and `@astral-mcp/tools` map directly to their `index.js` files
789
+ * (rather than the package directory) so that the relative chunk imports
790
+ * inside them resolve against the package directory.
791
+ */
792
+ const modulePathNormalizer = (baseName, requestedName) => {
793
+ if (requestedName.startsWith(".")) {
794
+ const parts = baseName.split("/");
795
+ parts.pop();
796
+ return resolve(`/${parts.join("/")}`, requestedName);
797
+ }
798
+ const name = requestedName.replace(/^node:/, "");
799
+ if (name === "xstate") return "/node_modules/xstate/index.js";
800
+ if (name === "@astral-mcp/tools") return "/node_modules/@astral-mcp/tools/index.js";
801
+ if (ALLOWED_SPECIFIERS.has(name)) return join("/node_modules", name);
802
+ return `${DENIED_PREFIX}${requestedName}`;
803
+ };
804
+ /**
805
+ * Module loader wrapping the package default: turns denied sentinel paths
806
+ * into a structured, actionable error (R25); defers everything else to the
807
+ * default memfs-backed loader.
808
+ */
809
+ const getSandboxModuleLoader = (fs, runtimeOptions) => {
810
+ const defaultLoader = getModuleLoader(fs, runtimeOptions);
811
+ return (name, context) => {
812
+ if (name.startsWith(DENIED_PREFIX)) {
813
+ const specifier = name.slice(23);
814
+ return { error: /* @__PURE__ */ new Error(`'${specifier}' ${DENIED_IMPORT_MARKER}; only '${XSTATE_SPECIFIER}' and '${TOOLS_SPECIFIER}' are importable`) };
815
+ }
816
+ return defaultLoader(name, context);
817
+ };
818
+ };
819
+ //#endregion
820
+ //#region src/sandbox/session.ts
821
+ /**
822
+ * Sandbox session implementation. See ./index.ts for the boundary contract
823
+ * the machine host builds against.
824
+ */
825
+ /** Global object the boot script installs inside the VM. */
826
+ const SANDBOX_GLOBAL = "__projection";
827
+ /**
828
+ * Name of the host bridge function on the in-VM `env` object. The bridge
829
+ * module calls `env.__projection_toolCall(name, args)` and receives a promise.
830
+ */
831
+ const TOOL_CALL_BINDING = "__projection_toolCall";
832
+ let quickJsPromise;
833
+ let tsSupportPromise;
834
+ const QUICKJS_VARIANT = "@jitl/quickjs-ng-wasmfile-release-sync";
835
+ function getQuickJs() {
836
+ quickJsPromise ??= (async () => {
837
+ let variant;
838
+ try {
839
+ variant = await import(QUICKJS_VARIANT);
840
+ } catch (cause) {
841
+ quickJsPromise = void 0;
842
+ throw new ProjectionError("runtime", `QuickJS WASM variant '${QUICKJS_VARIANT}' is not installed`, {
843
+ hint: `Add ${QUICKJS_VARIANT} to the @astral-mcp/projection package dependencies`,
844
+ cause
845
+ });
846
+ }
847
+ return loadQuickJs(variant.default);
848
+ })();
849
+ return quickJsPromise;
850
+ }
851
+ /**
852
+ * Environment-class transpile failure: the TypeScript transpiler itself is
853
+ * missing or incompatible, as opposed to a syntax error in the source being
854
+ * transpiled. `createSandboxSession` lets these pass through unrewrapped so
855
+ * the environment diagnostics (and their hints) reach the consumer instead
856
+ * of being misreported as an invalid-bridgeSource integration bug.
857
+ */
858
+ var TranspilerUnavailableError = class extends ProjectionError {};
859
+ function getTsSupport() {
860
+ tsSupportPromise ??= (async () => {
861
+ const ts = await import("typescript");
862
+ if (typeof ts.transpile !== "function") throw new TranspilerUnavailableError("transpile", `the installed typescript@${ts.version ?? "unknown"} does not provide ts.transpile`, { hint: "@astral-mcp/projection requires typescript ^5 (TS 6+/7+ removed the transpile API). Pin typescript to ^5.7.0." });
863
+ const typescriptImportFile = typeof import.meta.resolve === "function" ? import.meta.resolve("typescript") : void 0;
864
+ return getTypescriptSupport(true, typescriptImportFile);
865
+ })().catch((cause) => {
866
+ tsSupportPromise = void 0;
867
+ if (cause instanceof ProjectionError) throw cause;
868
+ throw new TranspilerUnavailableError("transpile", "TypeScript transpiler is not available", {
869
+ hint: "Reinstall dependencies: @astral-mcp/projection depends on typescript ^5 for machine and bridge transpilation.",
870
+ cause
871
+ });
872
+ });
873
+ return tsSupportPromise;
874
+ }
875
+ function flattenDiagnosticMessage(messageText) {
876
+ if (typeof messageText === "string") return messageText;
877
+ const parts = [messageText.messageText];
878
+ for (const next of messageText.next ?? []) parts.push(flattenDiagnosticMessage(next.messageText));
879
+ return parts.join(" ");
880
+ }
881
+ /**
882
+ * Transpile the (invariant) tools bridge, memoized by source. Bounded so a
883
+ * process that wraps many distinct servers can't grow the cache unbounded.
884
+ */
885
+ const bridgeCache = /* @__PURE__ */ new Map();
886
+ const BRIDGE_CACHE_MAX = 32;
887
+ async function transpileBridge(source) {
888
+ const cached = bridgeCache.get(source);
889
+ if (cached !== void 0) return cached;
890
+ const js = await transpileTs(source, "/bridge/index.ts");
891
+ if (bridgeCache.size >= BRIDGE_CACHE_MAX) {
892
+ const oldest = bridgeCache.keys().next().value;
893
+ if (oldest !== void 0) bridgeCache.delete(oldest);
894
+ }
895
+ bridgeCache.set(source, js);
896
+ return js;
897
+ }
898
+ async function transpileTs(source, fileName) {
899
+ const support = await getTsSupport();
900
+ const diagnostics = [];
901
+ const js = support.transpileFile(source, void 0, fileName, diagnostics);
902
+ const errors = diagnostics.filter((d) => d.category === 1);
903
+ if (errors.length > 0) throw new ProjectionError("transpile", "TypeScript transpilation failed", {
904
+ diagnostics: errors.map((d) => {
905
+ const message = flattenDiagnosticMessage(d.messageText);
906
+ if (d.file !== void 0 && d.start !== void 0) {
907
+ const pos = d.file.getLineAndCharacterOfPosition(d.start);
908
+ return {
909
+ message,
910
+ line: pos.line + 1,
911
+ column: pos.character + 1
912
+ };
913
+ }
914
+ return { message };
915
+ }),
916
+ hint: "Fix the syntax error; TypeScript is transpiled without type checking, so only syntax matters"
917
+ });
918
+ return js;
919
+ }
920
+ function classifyEvalError(response, caps, logs) {
921
+ const { name, message, stack } = response.error;
922
+ const diagnostics = stack !== void 0 && stack.length > 0 ? [{ message: stack }] : [];
923
+ if (name === "ExecutionTimeout" || message === "interrupted" || message.includes("maximum allowed time limit")) return new ProjectionError("cap_exceeded", `Execution exceeded the wall-clock budget of ${caps.segmentWallClockMs} ms for this segment`, {
924
+ diagnostics,
925
+ hint: "Remove long or unbounded loops; heavy work must happen through tool calls, not in-machine computation",
926
+ logs
927
+ });
928
+ if (/out of memory/i.test(message)) return new ProjectionError("cap_exceeded", `Execution exceeded the memory limit of ${caps.wasmMemoryBytes} bytes`, {
929
+ diagnostics,
930
+ hint: "Keep machine context small; avoid accumulating large arrays or strings in the sandbox",
931
+ logs
932
+ });
933
+ if (message.includes("cannot be imported inside the sandbox") || message.includes("not installed or available")) return new ProjectionError("validate", message, {
934
+ diagnostics,
935
+ hint: `Only '${XSTATE_SPECIFIER}' and '${TOOLS_SPECIFIER}' can be imported; there is no filesystem or network access`,
936
+ logs
937
+ });
938
+ if (response.isSyntaxError === true || name === "SyntaxError") return new ProjectionError("validate", `${name}: ${message}`, {
939
+ diagnostics,
940
+ hint: "Fix the JavaScript syntax error shown in diagnostics",
941
+ logs
942
+ });
943
+ return new ProjectionError("runtime", `${name}: ${message}`, {
944
+ diagnostics,
945
+ hint: "Uncaught error inside the sandbox; see diagnostics and logs, correct the code, and retry",
946
+ logs
947
+ });
948
+ }
949
+ function deferred() {
950
+ let resolve;
951
+ let reject;
952
+ return {
953
+ promise: new Promise((res, rej) => {
954
+ resolve = res;
955
+ reject = rej;
956
+ }),
957
+ resolve,
958
+ reject
959
+ };
960
+ }
961
+ /** No-op sink for console methods that are captured nowhere (R31 covers five levels). */
962
+ const silent = () => {};
963
+ /**
964
+ * Create a sandbox session: boot a fresh QuickJS VM, mount xstate and the
965
+ * bridge, evaluate the authored module, and return a handle for driving it.
966
+ * See ./index.ts for the full boundary contract.
967
+ */
968
+ async function createSandboxSession(options) {
969
+ const caps = clampCaps(options.caps ?? {});
970
+ const segment = options.segment ?? 0;
971
+ const lang = options.lang ?? "js";
972
+ const codeBytes = utf8Length(options.code);
973
+ if (codeBytes > caps.maxCodeBytes) throw new ProjectionError("cap_exceeded", `Submitted code is ${codeBytes} bytes; the maximum is ${caps.maxCodeBytes} bytes`, { hint: "Shrink the machine: remove comments and dead states, and keep large data out of the source" });
974
+ const machineJs = lang === "ts" ? await transpileTs(options.code, "/src/machine.ts") : options.code;
975
+ let bridgeJs;
976
+ try {
977
+ bridgeJs = await transpileBridge(options.bridgeSource);
978
+ } catch (error) {
979
+ if (error instanceof TranspilerUnavailableError) throw error;
980
+ if (error instanceof ProjectionError && error.phase === "transpile") throw new ProjectionError("runtime", "The @astral-mcp/tools bridge module failed to transpile", {
981
+ diagnostics: error.diagnostics,
982
+ hint: "The bridgeSource passed to createSandboxSession is invalid; this is an integration bug, not a model error",
983
+ cause: error
984
+ });
985
+ throw error;
986
+ }
987
+ const logs = new LogCollector(caps.maxConsoleBytes, segment);
988
+ const capture = (level) => (...params) => {
989
+ logs.push(level, params);
990
+ };
991
+ const onToolCall = options.onToolCall;
992
+ const sandboxOptions = {
993
+ executionTimeout: caps.segmentWallClockMs,
994
+ memoryLimit: caps.wasmMemoryBytes,
995
+ mountFs: { src: { "machine.js": machineJs } },
996
+ nodeModules: buildNodeModulesMount(bridgeJs),
997
+ modulePathNormalizer,
998
+ getModuleLoader: getSandboxModuleLoader,
999
+ env: { [TOOL_CALL_BINDING]: (name, args) => {
1000
+ const call = {
1001
+ name: typeof name === "string" ? name : String(name),
1002
+ args: typeof args === "object" && args !== null && !Array.isArray(args) ? args : {}
1003
+ };
1004
+ return Promise.resolve(onToolCall(call));
1005
+ } },
1006
+ console: {
1007
+ log: capture("log"),
1008
+ info: capture("info"),
1009
+ warn: capture("warn"),
1010
+ error: capture("error"),
1011
+ debug: capture("debug"),
1012
+ trace: capture("debug"),
1013
+ assert: (condition, ...data) => {
1014
+ if (condition !== true) logs.push("error", ["Assertion failed:", ...data]);
1015
+ },
1016
+ dir: capture("log"),
1017
+ table: capture("log"),
1018
+ count: silent,
1019
+ countReset: silent,
1020
+ dirxml: silent,
1021
+ group: silent,
1022
+ groupCollapsed: silent,
1023
+ groupEnd: silent,
1024
+ time: silent,
1025
+ timeEnd: silent,
1026
+ timeLog: silent,
1027
+ clear: silent
1028
+ }
1029
+ };
1030
+ const runtime = await getQuickJs();
1031
+ const deadlineAt = Date.now() + caps.segmentWallClockMs;
1032
+ const ready = deferred();
1033
+ const hold = deferred();
1034
+ let disposed = false;
1035
+ const runPromise = runtime.runSandboxed(async ({ evalCode }) => {
1036
+ ready.resolve({ evalCode });
1037
+ await hold.promise;
1038
+ }, sandboxOptions);
1039
+ runPromise.catch(() => {});
1040
+ const { evalCode } = await Promise.race([ready.promise, runPromise.then(() => {
1041
+ throw new ProjectionError("runtime", "QuickJS sandbox terminated during startup", {
1042
+ hint: "This is a sandbox runtime bug; check the host logs",
1043
+ logs: logs.snapshot()
1044
+ });
1045
+ })]);
1046
+ const dispose = async () => {
1047
+ if (!disposed) {
1048
+ disposed = true;
1049
+ hold.resolve();
1050
+ await runPromise.catch(() => {});
1051
+ }
1052
+ };
1053
+ let queue = Promise.resolve();
1054
+ let evalCounter = 0;
1055
+ const runInVm = (source) => {
1056
+ const task = async () => {
1057
+ if (disposed) throw new ProjectionError("runtime", "Sandbox session already disposed", { hint: "Create a new session for each execution segment" });
1058
+ if (Date.now() >= deadlineAt) throw new ProjectionError("cap_exceeded", `Execution exceeded the wall-clock budget of ${caps.segmentWallClockMs} ms for this segment`, {
1059
+ hint: "The segment budget is spent; a fresh session (MRTR resume) gets a fresh budget",
1060
+ logs: logs.snapshot()
1061
+ });
1062
+ const filename = `/src/__projection_eval_${evalCounter++}__.js`;
1063
+ const result = await evalCode(source, filename);
1064
+ if (!result.ok) throw classifyEvalError(result, caps, logs.snapshot());
1065
+ return result.data;
1066
+ };
1067
+ const run = queue.then(task, task);
1068
+ queue = run.then(() => void 0, () => void 0);
1069
+ return run;
1070
+ };
1071
+ try {
1072
+ await runInVm(`delete globalThis.fetch; export default null;`);
1073
+ await runInVm(`import * as mod from './machine.js';
1074
+ globalThis.${SANDBOX_GLOBAL} = { module: mod, state: {} };
1075
+ export default null;`);
1076
+ } catch (error) {
1077
+ await dispose();
1078
+ throw error;
1079
+ }
1080
+ return {
1081
+ caps,
1082
+ segment,
1083
+ evaluate: runInVm,
1084
+ getLogs: () => logs.snapshot(),
1085
+ dispose
1086
+ };
1087
+ }
1088
+ //#endregion
1089
+ //#region src/policy/decision.ts
1090
+ /**
1091
+ * Policy decisions for tool calls (PRD R14, S5).
1092
+ *
1093
+ * Pure functions: annotation-derived defaults plus developer overrides.
1094
+ * Annotations come from a possibly third-party wrapped server and are
1095
+ * untrusted hints — they can only ever make the default *stricter* than an
1096
+ * explicit developer override, never looser. A developer override always
1097
+ * wins, in every combination.
1098
+ */
1099
+ const VALID_DECISIONS = /* @__PURE__ */ new Set([
1100
+ "auto",
1101
+ "ask",
1102
+ "deny"
1103
+ ]);
1104
+ /**
1105
+ * Decide the policy for a catalog tool (R14).
1106
+ *
1107
+ * - A developer override (`auto` | `ask` | `deny`) always wins.
1108
+ * - With no override, `annotations.readOnlyHint === true` yields `auto`;
1109
+ * an absent or `false` hint yields `ask` (S5: unannotated defaults to ask).
1110
+ * - An override entry with an unrecognized value (possible when the config
1111
+ * arrives untyped) fails safe to `ask` — it never grants `auto`.
1112
+ *
1113
+ * Only own properties of `overrides` are consulted, so tools named like
1114
+ * `Object.prototype` members (`toString`, `constructor`, ...) cannot pick up
1115
+ * inherited junk as a decision.
1116
+ */
1117
+ function decidePolicy(entry, overrides) {
1118
+ if (overrides !== void 0 && Object.hasOwn(overrides, entry.name)) {
1119
+ const override = overrides[entry.name];
1120
+ if (typeof override === "string" && VALID_DECISIONS.has(override)) return override;
1121
+ return "ask";
1122
+ }
1123
+ return entry.annotations?.readOnlyHint === true ? "auto" : "ask";
1124
+ }
1125
+ /**
1126
+ * Resolve a machine-requested tool call against the server catalog and
1127
+ * decide its policy.
1128
+ *
1129
+ * Returns the decision together with the matching catalog entry (the entry
1130
+ * is what elicitation copy must be built from — S6), or a structured
1131
+ * `validate`-phase {@link ProjectionError} when the machine names a tool the
1132
+ * wrapped server does not declare.
1133
+ */
1134
+ function resolveCallPolicy(call, catalog, overrides) {
1135
+ const entry = findTool(catalog, call.name);
1136
+ if (entry === void 0) return {
1137
+ ok: false,
1138
+ error: new ProjectionError("validate", `Unknown tool "${call.name}": the wrapped server does not declare it`, {
1139
+ diagnostics: [{ message: `Requested from machine state "${call.stateName}" (call id ${call.id})` }],
1140
+ hint: "Call discover_apis and use only the tool names listed in @astral-mcp/tools."
1141
+ })
1142
+ };
1143
+ return {
1144
+ ok: true,
1145
+ decision: decidePolicy(entry, overrides),
1146
+ entry
1147
+ };
1148
+ }
1149
+ /**
1150
+ * Canonical TOOL_DENIED reason for calls blocked by a `deny` policy
1151
+ * decision. A `deny` decision never produces an elicitation — the machine
1152
+ * host injects `TOOL_DENIED` with this reason instead.
1153
+ */
1154
+ const POLICY_DENIED_REASON = "denied by server policy";
1155
+ /**
1156
+ * Build the TOOL_DENIED reason string for a policy-denied call. With a tool
1157
+ * name it names the denied tool so the machine (and audit trail) can tell
1158
+ * which call was blocked.
1159
+ */
1160
+ function policyDeniedReason(toolName) {
1161
+ return toolName === void 0 ? POLICY_DENIED_REASON : `${POLICY_DENIED_REASON}: tool "${toolName}" is configured as deny`;
1162
+ }
1163
+ //#endregion
1164
+ //#region src/text-safety.ts
1165
+ /**
1166
+ * Rendering untrusted strings inertly (S5, S6).
1167
+ *
1168
+ * Tool names, descriptions, the wrapped server's identity, and machine state
1169
+ * names all flow from a possibly third-party server or a model-authored
1170
+ * machine. Wherever they are rendered into human-facing prompt copy or into
1171
+ * generated source, they must not be able to forge structure with control
1172
+ * characters (newlines, ESC for ANSI sequences) or reorder text with Unicode
1173
+ * bidi overrides. Centralizing the character class here keeps every call site
1174
+ * on the same, complete definition — an earlier divergence let generated
1175
+ * `.d.ts`/bridge comments miss the directional-isolate range that the
1176
+ * elicitation copy already stripped.
1177
+ */
1178
+ /**
1179
+ * C0 control characters (incl. ESC and, unless `allowNewline`, `\n`), DEL, C1
1180
+ * controls (which `JSON.stringify` does NOT escape), the bidi override marks
1181
+ * (LRM/RLM/LRE/RLE/PDF/LRO/RLO), and the directional isolates (LRI/RLI/FSI/
1182
+ * PDI).
1183
+ */
1184
+ const HOSTILE_INLINE = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
1185
+ const HOSTILE_BLOCK = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
1186
+ /**
1187
+ * Replace control and bidi characters with the replacement character.
1188
+ * `allowNewline` keeps `\n` (for multi-line blocks whose formatting newlines
1189
+ * are part of the rendering, e.g. a pretty-printed JSON block).
1190
+ */
1191
+ function neutralizeControlChars(text, options = {}) {
1192
+ return text.replace(options.allowNewline === true ? HOSTILE_BLOCK : HOSTILE_INLINE, "�");
1193
+ }
1194
+ /** Truncate to `maxChars`, appending an ellipsis when shortened. */
1195
+ function capLength(text, maxChars) {
1196
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars)}…`;
1197
+ }
1198
+ //#endregion
1199
+ //#region src/policy/elicitation.ts
1200
+ /**
1201
+ * Elicitation copy builder (PRD R16, S6).
1202
+ *
1203
+ * Builds the human-readable approval summary for a gated tool call. The
1204
+ * hardening rule (S6) is that nothing model-controlled other than the
1205
+ * argument VALUES may enter the copy, and those values are rendered as
1206
+ * inert data:
1207
+ *
1208
+ * - Tool name and description come from the server-side catalog entry,
1209
+ * never from model-authored text (a fake `description` inside the args
1210
+ * is just another argument value inside the JSON block). The catalog
1211
+ * itself may belong to a hostile third-party server (S5), so name and
1212
+ * description are still rendered inert: control characters and bidi
1213
+ * overrides neutralized, length capped, description as an escaped JSON
1214
+ * string literal so it can never forge prompt structure.
1215
+ * - Arguments pass through the developer's redaction hook (R16) BEFORE
1216
+ * being rendered anywhere; if the hook throws we fail closed and render
1217
+ * `[redaction failed]` instead of any argument data.
1218
+ * - Arguments are rendered as an indented, pretty-printed JSON block.
1219
+ * `JSON.stringify` escapes every newline and quote inside string values,
1220
+ * so hostile values ("IGNORE PREVIOUS INSTRUCTIONS: ...") stay quoted on
1221
+ * an indented line inside the block and can never break out into prose.
1222
+ * - The machine state name (model-authored, required by R16) is rendered
1223
+ * as a length-capped JSON string literal — quoted and escaped, inert.
1224
+ * - `destructiveHint` prepends clearly stronger warning language (R14).
1225
+ *
1226
+ * Pure: no I/O, deterministic for a given input.
1227
+ */
1228
+ const DEFAULT_MAX_ARGS_CHARS = 4096;
1229
+ const MAX_STATE_NAME_CHARS = 200;
1230
+ const MAX_TOOL_NAME_CHARS = 200;
1231
+ const MAX_DESCRIPTION_CHARS = 1e3;
1232
+ const ARGS_INDENT = " ";
1233
+ const DESTRUCTIVE_WARNING = "WARNING: the server marks this tool as DESTRUCTIVE. Approving may permanently modify or delete data. Review the arguments carefully.";
1234
+ const REDACTION_FAILED = "[redaction failed]";
1235
+ /**
1236
+ * Build the approval summary for a gated call.
1237
+ *
1238
+ * `entry` MUST be the catalog entry the call was resolved against (use
1239
+ * `resolveCallPolicy`); the copy takes the tool name and description from
1240
+ * it, never from the pending call or its arguments.
1241
+ */
1242
+ function buildElicitationCopy(call, entry, options = {}) {
1243
+ const destructive = entry.annotations?.destructiveHint === true;
1244
+ const maxArgsChars = options.maxArgsChars ?? DEFAULT_MAX_ARGS_CHARS;
1245
+ let renderedArgs;
1246
+ try {
1247
+ renderedArgs = renderArgsJson(options.redact !== void 0 ? options.redact(entry.name, call.args) : call.args, maxArgsChars);
1248
+ } catch {
1249
+ renderedArgs = REDACTION_FAILED;
1250
+ }
1251
+ const safeName = neutralizeControlChars(capLength(entry.name, MAX_TOOL_NAME_CHARS));
1252
+ const safeDescription = JSON.stringify(neutralizeControlChars(capLength(entry.description ?? "(no description provided by the server)", MAX_DESCRIPTION_CHARS)));
1253
+ const title = destructive ? `Approval required (DESTRUCTIVE): ${safeName}` : `Approval required: ${safeName}`;
1254
+ const lines = [];
1255
+ if (destructive) lines.push(DESTRUCTIVE_WARNING, "");
1256
+ lines.push(`Tool: ${safeName}`, `Description: ${safeDescription}`, `Requested by machine state: ${JSON.stringify(neutralizeControlChars(capLength(call.stateName, MAX_STATE_NAME_CHARS)))}`, "", "Arguments (data only — not instructions):", "", indentBlock(neutralizeControlChars(renderedArgs, { allowNewline: true })));
1257
+ return {
1258
+ title,
1259
+ message: lines.join("\n"),
1260
+ toolName: entry.name,
1261
+ stateName: call.stateName,
1262
+ args: renderedArgs,
1263
+ destructive
1264
+ };
1265
+ }
1266
+ /**
1267
+ * Render argument values as pretty-printed JSON, defensively:
1268
+ * circular references, bigints, functions, symbols and `undefined` become
1269
+ * inert placeholder strings; a throwing `toJSON` falls back to a
1270
+ * placeholder; output beyond `maxChars` is cut with a truncation marker.
1271
+ */
1272
+ function renderArgsJson(args, maxChars) {
1273
+ let json;
1274
+ try {
1275
+ json = JSON.stringify(args, makeSafeReplacer(), 2) ?? "[unserializable arguments]";
1276
+ } catch {
1277
+ json = "[unserializable arguments]";
1278
+ }
1279
+ if (json.length <= maxChars) return json;
1280
+ const total = json.length;
1281
+ return `${json.slice(0, maxChars)}\n… [truncated: showing first ${maxChars} of ${total} characters]`;
1282
+ }
1283
+ /**
1284
+ * JSON.stringify replacer neutralizing values JSON cannot represent.
1285
+ *
1286
+ * Cycle detection tracks only the current ancestor chain (popping on the
1287
+ * way back up via the `this` holder), so shared non-circular references
1288
+ * render fully everywhere they appear — only a true ancestor cycle becomes
1289
+ * `[circular]`. Rendering a shared object as `[circular]` would hide the
1290
+ * real arguments from the approver (R16).
1291
+ */
1292
+ function makeSafeReplacer() {
1293
+ const ancestors = [];
1294
+ return function replacer(_key, value) {
1295
+ switch (typeof value) {
1296
+ case "bigint": return `[bigint ${value.toString()}]`;
1297
+ case "function": return "[function]";
1298
+ case "symbol": return "[symbol]";
1299
+ case "undefined": return "[undefined]";
1300
+ }
1301
+ if (typeof value === "object" && value !== null) {
1302
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) ancestors.pop();
1303
+ if (ancestors.includes(value)) return "[circular]";
1304
+ ancestors.push(value);
1305
+ }
1306
+ return value;
1307
+ };
1308
+ }
1309
+ /** Prefix every line so the block reads as data, not prose. */
1310
+ function indentBlock(text) {
1311
+ return text.split("\n").map((line) => ARGS_INDENT + line).join("\n");
1312
+ }
1313
+ //#endregion
1314
+ //#region src/machine-host/contract.ts
1315
+ /**
1316
+ * The authoring contract (PRD R5–R8) — the exact surface an LLM-authored
1317
+ * machine module may use, and the in-VM code implementing it.
1318
+ *
1319
+ * The module source here is appended to the typegen-generated bridge and
1320
+ * mounted together at `@astral-mcp/tools`, so an authored module writes:
1321
+ *
1322
+ * ```ts
1323
+ * import { setup } from 'xstate';
1324
+ * import { toolCall } from '@astral-mcp/tools';
1325
+ * ```
1326
+ *
1327
+ * `toolCall` is the ONLY supported way to reach a tool (R7). It returns an
1328
+ * ordinary XState action function, so the machine never models a tool call
1329
+ * as an invoked promise actor — restoring a snapshot restarts invoked
1330
+ * actors, which would re-execute an already-approved write.
1331
+ */
1332
+ /**
1333
+ * Prelude + `toolCall` implementation, appended to the generated bridge.
1334
+ *
1335
+ * `toolCall` pushes a request onto an in-VM queue that the host drains after
1336
+ * the actor settles. It is deliberately synchronous and promise-free: the
1337
+ * machine records *that* it wants a call, and the host — never the machine —
1338
+ * performs the I/O and injects the result as an event (R7, S1).
1339
+ *
1340
+ * The generated bridge's `tools.<name>(args)` functions reach the host
1341
+ * through the sandbox's `env` binding; the shim below wires the global the
1342
+ * bridge expects to that binding. Calling `tools.<name>()` directly also
1343
+ * registers a gated request, but the returned promise is never resolved
1344
+ * within the segment — awaiting it is the banned pattern. Author with
1345
+ * `toolCall`.
1346
+ */
1347
+ const TOOL_CALL_MODULE_SOURCE = `
1348
+ // --- Projection machine-host prelude -----------------------------------
1349
+ // Bind the generated bridge's host hook to the sandbox env binding.
1350
+ if (typeof globalThis.__projection_toolcall !== 'function') {
1351
+ globalThis.__projection_toolcall = function (name, args) {
1352
+ return env.__projection_toolCall(name, args);
1353
+ };
1354
+ }
1355
+
1356
+ function __projQueue() {
1357
+ const state = globalThis.__projection.state;
1358
+ if (!state.queue) state.queue = [];
1359
+ return state.queue;
1360
+ }
1361
+
1362
+ let __projSeq = 0;
1363
+
1364
+ /**
1365
+ * Request a tool call from a machine action.
1366
+ *
1367
+ * entry: toolCall('write_file', ({ context }) => ({ path: context.path }))
1368
+ *
1369
+ * @param name Tool name exactly as catalogued.
1370
+ * @param args Plain object, or a function of the XState action arguments
1371
+ * ({ context, event }) returning one.
1372
+ * @param options Optional { id } to correlate the resulting event; a
1373
+ * stable id is generated when omitted.
1374
+ */
1375
+ export function toolCall(name, args, options) {
1376
+ if (typeof name !== 'string' || name.length === 0) {
1377
+ throw new Error('toolCall(name, args): name must be a non-empty tool name string');
1378
+ }
1379
+ return function projectionToolCall(actionArgs) {
1380
+ let resolved;
1381
+ try {
1382
+ resolved = typeof args === 'function' ? args(actionArgs) : args;
1383
+ } catch (err) {
1384
+ throw new Error('toolCall("' + name + '") argument function threw: ' + String(err));
1385
+ }
1386
+ if (resolved === undefined || resolved === null) resolved = {};
1387
+ if (typeof resolved !== 'object' || Array.isArray(resolved)) {
1388
+ throw new Error('toolCall("' + name + '") arguments must resolve to a plain object');
1389
+ }
1390
+ __projSeq += 1;
1391
+ const id = options && options.id !== undefined ? String(options.id) : name + '#' + __projSeq;
1392
+ __projQueue().push({ id: id, name: name, args: resolved });
1393
+ };
1394
+ }
1395
+
1396
+ /**
1397
+ * Guard matching the event for one specific call id.
1398
+ *
1399
+ * XState delivers an event to every region that can handle it, so a machine
1400
+ * with two calls outstanding at once (parallel regions) MUST guard each
1401
+ * waiting state — otherwise one region consumes the other's result and the
1402
+ * machine records an approved write against the wrong branch.
1403
+ *
1404
+ * on: { TOOL_RESULT: { guard: whenCall('a'), target: 'done' } }
1405
+ */
1406
+ export function whenCall(id) {
1407
+ const wanted = String(id);
1408
+ return function projectionWhenCall(guardArgs) {
1409
+ const event = guardArgs && guardArgs.event;
1410
+ return Boolean(event) && event.callId === wanted;
1411
+ };
1412
+ }
1413
+ `;
1414
+ /**
1415
+ * Human-readable authoring contract. `discover_apis` publishes this inline
1416
+ * (R3) so a model can author a valid machine without fetching resources.
1417
+ */
1418
+ const AUTHORING_CONTRACT = `Projection authoring contract (execute_machine)
1419
+
1420
+ Write ONE module whose only top-level statements are imports and a single
1421
+ default-exported XState v5 machine. The module is re-evaluated on every
1422
+ approval round trip, so top-level side effects are rejected.
1423
+
1424
+ import { setup } from 'xstate';
1425
+ import { toolCall } from '@astral-mcp/tools';
1426
+
1427
+ export default setup({}).createMachine({ /* ... */ });
1428
+
1429
+ Rules:
1430
+ 1. Default-export a machine built with XState v5 setup(...).createMachine(...).
1431
+ XState v4 Machine(...) is rejected.
1432
+ 2. Machine context must be JSON-serializable at all times. No Map, Set, Date,
1433
+ class instances, functions, undefined, bigint or symbols in context.
1434
+ 3. Request tools with the toolCall action. NEVER wrap a tool call in
1435
+ fromPromise/invoke — restored snapshots restart invoked actors, which
1436
+ would re-execute an approved write. Machines using invoke are rejected.
1437
+ 4. After a toolCall, wait in a state for one of these events:
1438
+ TOOL_RESULT { callId, result } the call succeeded
1439
+ TOOL_ERROR { callId, error } the call failed
1440
+ TOOL_DENIED { callId, reason } the user declined, or policy denies it
1441
+ Denial is not an abort: handle TOOL_DENIED and continue or finish.
1442
+ IMPORTANT: if your machine has more than one call outstanding at the same
1443
+ time (parallel regions), give each toolCall an explicit id and guard every
1444
+ waiting state with whenCall(id) — events reach every region that can
1445
+ handle them, so an unguarded region will consume another call's result:
1446
+
1447
+ import { toolCall, whenCall } from '@astral-mcp/tools';
1448
+ entry: toolCall('write_file', () => ({ path: '/a' }), { id: 'a' }),
1449
+ on: { TOOL_RESULT: { guard: whenCall('a'), target: 'done' } },
1450
+ 5. Read-only tools run immediately. Every other tool pauses the machine for
1451
+ user approval; the arguments captured at that moment are the arguments
1452
+ that execute.
1453
+ 6. Only 'xstate' and '@astral-mcp/tools' resolve as imports. There is no
1454
+ filesystem, no network, no fetch. All working state lives in context.
1455
+ 7. Finish by reaching a final state. Use the machine's output for the result.
1456
+
1457
+ Example — happy path with a read-only call:
1458
+
1459
+ import { setup, assign } from 'xstate';
1460
+ import { toolCall } from '@astral-mcp/tools';
1461
+
1462
+ export default setup({}).createMachine({
1463
+ id: 'readNotes',
1464
+ context: ({ input }) => ({ path: input?.path ?? '/notes.txt', text: null }),
1465
+ initial: 'reading',
1466
+ states: {
1467
+ reading: {
1468
+ entry: toolCall('read_file', ({ context }) => ({ path: context.path })),
1469
+ on: {
1470
+ TOOL_RESULT: { target: 'done', actions: assign({ text: ({ event }) => event.result }) },
1471
+ TOOL_ERROR: 'failed',
1472
+ },
1473
+ },
1474
+ done: { type: 'final' },
1475
+ failed: { type: 'final' },
1476
+ },
1477
+ output: ({ context }) => ({ text: context.text }),
1478
+ });
1479
+
1480
+ Example — handling a denied approval:
1481
+
1482
+ import { setup, assign } from 'xstate';
1483
+ import { toolCall } from '@astral-mcp/tools';
1484
+
1485
+ export default setup({}).createMachine({
1486
+ id: 'writeNotes',
1487
+ context: { status: 'pending' },
1488
+ initial: 'writing',
1489
+ states: {
1490
+ writing: {
1491
+ entry: toolCall('write_file', () => ({ path: '/notes.txt', content: 'hello' })),
1492
+ on: {
1493
+ TOOL_RESULT: { target: 'done', actions: assign({ status: 'written' }) },
1494
+ TOOL_DENIED: { target: 'done', actions: assign({ status: 'skipped' }) },
1495
+ TOOL_ERROR: { target: 'done', actions: assign({ status: 'failed' }) },
1496
+ },
1497
+ },
1498
+ done: { type: 'final' },
1499
+ },
1500
+ output: ({ context }) => ({ status: context.status }),
1501
+ });
1502
+ `;
1503
+ //#endregion
1504
+ //#region src/machine-host/drivers.ts
1505
+ /**
1506
+ * Driver module sources evaluated inside the sandbox (PRD R24, R5–R7).
1507
+ *
1508
+ * The sandbox evaluates an ESM module and marshals its default export back
1509
+ * to the host, so each host↔VM step is a small module built here. Values
1510
+ * interpolated into these templates are always `JSON.stringify`d — never
1511
+ * raw text — so nothing from the model or the wrapped server can extend the
1512
+ * driver's own code.
1513
+ */
1514
+ /**
1515
+ * Static validation of the default-exported machine (R24).
1516
+ *
1517
+ * Runs without starting an actor: entry actions must not fire during
1518
+ * validation, or a `toolCall` in the initial state would register a request
1519
+ * before policy has been consulted. `createActor(m).getSnapshot()` yields
1520
+ * the initial snapshot with actions resolved but not executed.
1521
+ *
1522
+ * The caller's `input` is passed to the probe actor exactly as the
1523
+ * bootstrap will pass it: a machine whose context factory dereferences
1524
+ * `input` (`context: ({ input }) => ({ path: input.path })`) must validate
1525
+ * against the input it will actually run with, not against `undefined`.
1526
+ */
1527
+ function validationDriver(maxStateNodes, input) {
1528
+ const probeActor = input === void 0 ? "createActor(machine)" : `createActor(machine, { input: ${JSON.stringify(input)} })`;
1529
+ return `
1530
+ import { createActor } from 'xstate';
1531
+
1532
+ const g = globalThis.__projection;
1533
+ const machine = g.module ? g.module.default : undefined;
1534
+ const fail = (code, detail) => ({ ok: false, code: code, detail: detail === undefined ? null : detail });
1535
+
1536
+ function run() {
1537
+ if (machine === undefined || machine === null) {
1538
+ return fail('no_default_export');
1539
+ }
1540
+ if (typeof machine !== 'object' && typeof machine !== 'function') {
1541
+ return fail('default_export_not_machine', typeof machine);
1542
+ }
1543
+ // XState v5 machines carry __xstatenode and a root state node; a v4
1544
+ // Machine(...) result and every non-machine value lack one or the other.
1545
+ const isV5 =
1546
+ machine.__xstatenode === true &&
1547
+ machine.root !== undefined &&
1548
+ typeof machine.getPersistedSnapshot === 'function';
1549
+ if (!isV5) {
1550
+ const looksV4 =
1551
+ machine.__xstatenode === true ||
1552
+ typeof machine.withConfig === 'function' ||
1553
+ typeof machine.withContext === 'function';
1554
+ return fail(looksV4 ? 'xstate_v4_machine' : 'default_export_not_machine');
1555
+ }
1556
+
1557
+ // (a) State-node count: a tree walk of the instantiated definition (R12).
1558
+ let nodes = 0;
1559
+ const walkNodes = (node) => {
1560
+ nodes += 1;
1561
+ if (nodes > ${JSON.stringify(maxStateNodes)}) return;
1562
+ const states = node.states;
1563
+ if (states) for (const key of Object.keys(states)) walkNodes(states[key]);
1564
+ };
1565
+ walkNodes(machine.root);
1566
+ if (nodes > ${JSON.stringify(maxStateNodes)}) {
1567
+ return fail('too_many_state_nodes', nodes);
1568
+ }
1569
+
1570
+ // (b) Banned invoked actors (R7). Any invoke at all is rejected: a
1571
+ // restored snapshot restarts invoked actors, so an invoked tool call
1572
+ // would re-execute an approved write. Walk the raw config, which is the
1573
+ // authored shape before XState normalizes it.
1574
+ const seen = new Set();
1575
+ let invokeAt = null;
1576
+ const walkConfig = (value, path) => {
1577
+ if (invokeAt !== null) return;
1578
+ if (value === null || typeof value !== 'object') return;
1579
+ if (seen.has(value)) return;
1580
+ seen.add(value);
1581
+ if (!Array.isArray(value) && value.invoke !== undefined) {
1582
+ invokeAt = path.length > 0 ? path : '(root)';
1583
+ return;
1584
+ }
1585
+ for (const key of Object.keys(value)) {
1586
+ walkConfig(value[key], path.length > 0 ? path + '.' + key : key);
1587
+ }
1588
+ };
1589
+ walkConfig(machine.config, '');
1590
+ if (invokeAt !== null) {
1591
+ return fail('invoke_banned', invokeAt);
1592
+ }
1593
+
1594
+ // (c) Serializability probe (R6). JSON.stringify silently drops functions
1595
+ // and flattens Map/Set, so walk for non-JSON values instead of relying on
1596
+ // a throw, then round-trip to catch anything left (e.g. cycles).
1597
+ let snapshot;
1598
+ try {
1599
+ snapshot = ${probeActor}.getSnapshot();
1600
+ } catch (err) {
1601
+ return fail('machine_init_threw', String(err && err.message ? err.message : err));
1602
+ }
1603
+ const badPath = findNonSerializable(snapshot.context, 'context', new Set());
1604
+ if (badPath !== null) {
1605
+ return fail('context_not_serializable', badPath);
1606
+ }
1607
+ try {
1608
+ JSON.parse(JSON.stringify(snapshot.context));
1609
+ } catch (err) {
1610
+ return fail('context_not_serializable', 'context (' + String(err && err.message ? err.message : err) + ')');
1611
+ }
1612
+ return { ok: true, stateNodes: nodes };
1613
+ }
1614
+
1615
+ function findNonSerializable(value, path, seen) {
1616
+ if (value === null) return null;
1617
+ const t = typeof value;
1618
+ if (t === 'string' || t === 'number' || t === 'boolean') {
1619
+ return t === 'number' && !Number.isFinite(value) ? path + ' (' + String(value) + ')' : null;
1620
+ }
1621
+ if (t === 'undefined') return path + ' (undefined)';
1622
+ if (t === 'function') return path + ' (function)';
1623
+ if (t === 'symbol') return path + ' (symbol)';
1624
+ if (t === 'bigint') return path + ' (bigint)';
1625
+ if (t !== 'object') return path + ' (' + t + ')';
1626
+ if (seen.has(value)) return path + ' (circular reference)';
1627
+ seen.add(value);
1628
+ if (Array.isArray(value)) {
1629
+ for (let i = 0; i < value.length; i += 1) {
1630
+ const bad = findNonSerializable(value[i], path + '[' + i + ']', seen);
1631
+ if (bad !== null) return bad;
1632
+ }
1633
+ return null;
1634
+ }
1635
+ const proto = Object.getPrototypeOf(value);
1636
+ if (proto !== Object.prototype && proto !== null) {
1637
+ const name = value.constructor && value.constructor.name ? value.constructor.name : 'non-plain object';
1638
+ return path + ' (' + name + ')';
1639
+ }
1640
+ for (const key of Object.keys(value)) {
1641
+ const bad = findNonSerializable(value[key], path + '.' + key, seen);
1642
+ if (bad !== null) return bad;
1643
+ }
1644
+ return null;
1645
+ }
1646
+
1647
+ export default run();
1648
+ `;
1649
+ }
1650
+ /** Read the request queue without starting the actor (top-level effects, R5). */
1651
+ const TOP_LEVEL_EFFECT_PROBE = `
1652
+ const state = globalThis.__projection.state;
1653
+ export default { queued: state.queue ? state.queue.length : 0 };
1654
+ `;
1655
+ /**
1656
+ * Create (or restore) the actor and settle it, returning the drained state.
1657
+ *
1658
+ * `input` and `snapshot` are interpolated as JSON literals. A restored
1659
+ * actor resumes exactly where the snapshot left off (R21).
1660
+ */
1661
+ function bootstrapDriver(input, snapshot) {
1662
+ return `
1663
+ import { createActor } from 'xstate';
1664
+
1665
+ const g = globalThis.__projection;
1666
+ const machine = g.module.default;
1667
+ if (!g.state.queue) g.state.queue = [];
1668
+
1669
+ ${DRAIN_HELPER}
1670
+
1671
+ const actor = ${snapshot !== void 0 ? `createActor(machine, { snapshot: ${JSON.stringify(snapshot)} })` : input === void 0 ? "createActor(machine)" : `createActor(machine, { input: ${JSON.stringify(input)} })`};
1672
+ g.state.actor = actor;
1673
+ g.state.drain = drain;
1674
+ actor.start();
1675
+
1676
+ export default drain();
1677
+ `;
1678
+ }
1679
+ /** Send host events into the running actor and return the drained state. */
1680
+ function sendDriver(events) {
1681
+ return `
1682
+ const g = globalThis.__projection;
1683
+ const events = ${JSON.stringify(events)};
1684
+ for (const event of events) {
1685
+ g.state.actor.send(event);
1686
+ }
1687
+ export default g.state.drain();
1688
+ `;
1689
+ }
1690
+ /** Return the persisted snapshot for sealing into the blob (R20). */
1691
+ const SNAPSHOT_DRIVER = `
1692
+ const g = globalThis.__projection;
1693
+ export default { snapshot: g.state.actor.getPersistedSnapshot() };
1694
+ `;
1695
+ /**
1696
+ * Collect everything the host needs after the actor settles: the queued
1697
+ * tool requests (cleared as they are read), the machine status, and the
1698
+ * final output when it has reached a final state.
1699
+ */
1700
+ const DRAIN_HELPER = `
1701
+ function drain() {
1702
+ const snapshot = g.state.actor.getSnapshot();
1703
+ const queue = g.state.queue;
1704
+ const requests = queue.splice(0, queue.length);
1705
+ return {
1706
+ status: snapshot.status,
1707
+ value: snapshot.value === undefined ? null : snapshot.value,
1708
+ output: snapshot.output === undefined ? null : snapshot.output,
1709
+ error: snapshot.error === undefined ? null : String(snapshot.error),
1710
+ requests: requests,
1711
+ };
1712
+ }
1713
+ `;
1714
+ //#endregion
1715
+ //#region src/machine-host/host.ts
1716
+ /**
1717
+ * Machine host — evaluates the authored module in the sandbox, enforces the
1718
+ * authoring contract, drives the actor, and turns tool requests into
1719
+ * host-side execution or an approval suspension (PRD §7 "Machine host").
1720
+ */
1721
+ /**
1722
+ * Run one execution segment to completion, suspension, or error.
1723
+ *
1724
+ * A segment is one MRTR round trip: fresh sandbox, fresh wall-clock budget,
1725
+ * fresh logs. On the first segment the module is validated (R24) before the
1726
+ * actor starts; on a resume the module is re-evaluated from the re-sent code
1727
+ * and the actor is restored from the sealed snapshot.
1728
+ *
1729
+ * Note: `runSegment` interpolates `input` into driver source without a size
1730
+ * check of its own — `executeMachine` enforces `maxInputBytes` before calling
1731
+ * it. Callers invoking `runSegment` directly must bound `input` themselves.
1732
+ */
1733
+ async function runSegment(options) {
1734
+ const caps = clampCaps(options.caps ?? {});
1735
+ const segment = options.segment ?? 0;
1736
+ const audit = [...options.resume?.audit ?? []];
1737
+ let session;
1738
+ const hookRequests = [];
1739
+ let hookSeq = 0;
1740
+ try {
1741
+ session = await createSandboxSession({
1742
+ code: options.code,
1743
+ ...options.lang !== void 0 ? { lang: options.lang } : {},
1744
+ caps,
1745
+ bridgeSource: `${options.bridgeSource}\n${TOOL_CALL_MODULE_SOURCE}`,
1746
+ segment,
1747
+ onToolCall: (call) => {
1748
+ hookSeq += 1;
1749
+ hookRequests.push({
1750
+ id: `${call.name}@${hookSeq}`,
1751
+ name: call.name,
1752
+ args: call.args
1753
+ });
1754
+ return null;
1755
+ }
1756
+ });
1757
+ if (((await session.evaluate(TOP_LEVEL_EFFECT_PROBE)).queued ?? 0) > 0 || hookRequests.length > 0) throw new ProjectionError("validate", "the submitted module requests a tool call at top level", { hint: "Move tool calls into machine states: the module is re-evaluated on every approval round trip, so top-level effects would repeat. Use toolCall(...) as a state entry action." });
1758
+ if (options.resume === void 0) await validate(session, caps, options.input);
1759
+ return await new SegmentRunner({
1760
+ session,
1761
+ caps,
1762
+ segment,
1763
+ catalog: options.catalog,
1764
+ executor: options.executor,
1765
+ ...options.policy !== void 0 ? { policy: options.policy } : {},
1766
+ audit,
1767
+ hookRequests,
1768
+ trip: options.resume?.trip ?? 0
1769
+ }).run(options);
1770
+ } catch (err) {
1771
+ return {
1772
+ status: "error",
1773
+ error: toStructuredError(err, session?.getLogs() ?? [])
1774
+ };
1775
+ } finally {
1776
+ await session?.dispose();
1777
+ }
1778
+ }
1779
+ async function validate(session, caps, input) {
1780
+ const result = await session.evaluate(validationDriver(caps.maxStateNodes, input));
1781
+ if (result.ok) return;
1782
+ throw validationError(result, caps);
1783
+ }
1784
+ const VALIDATION_SPECS = {
1785
+ no_default_export: {
1786
+ phase: "validate",
1787
+ message: () => "the submitted module has no default export",
1788
+ hint: () => "Default-export a machine: `export default setup({}).createMachine({ ... })`. See discover_apis."
1789
+ },
1790
+ xstate_v4_machine: {
1791
+ phase: "validate",
1792
+ message: () => "the default export is an XState v4 machine, not a v5 machine",
1793
+ hint: () => "Rewrite for XState v5: `import { setup } from \"xstate\"` then `setup({}).createMachine({ ... })` — v4 Machine(...) is not supported."
1794
+ },
1795
+ default_export_not_machine: {
1796
+ phase: "validate",
1797
+ message: (detail) => `the default export is not an XState v5 machine${detail ? ` (got ${detail})` : ""}`,
1798
+ hint: () => "Default-export the machine itself, not an actor or a factory: `export default setup({}).createMachine({ ... })`."
1799
+ },
1800
+ too_many_state_nodes: {
1801
+ phase: "cap_exceeded",
1802
+ message: (detail, caps) => `the machine has ${detail} state nodes, exceeding the limit of ${caps.maxStateNodes}`,
1803
+ hint: (caps) => `Simplify the machine to at most ${caps.maxStateNodes} state nodes, or split the work across separate execute_machine calls.`
1804
+ },
1805
+ invoke_banned: {
1806
+ phase: "validate",
1807
+ message: (detail) => `the machine uses invoke (at ${detail || "the machine root"}), which is not allowed`,
1808
+ hint: () => "Tool calls must use the toolCall action, not fromPromise/invoke: restored snapshots restart invoked actors and would re-execute approved writes. See discover_apis."
1809
+ },
1810
+ context_not_serializable: {
1811
+ phase: "validate",
1812
+ message: (detail) => `machine context is not JSON-serializable at ${detail}`,
1813
+ hint: () => "Keep context to plain JSON values — no Map, Set, Date, class instances, functions or undefined. Store a string or number instead."
1814
+ },
1815
+ machine_init_threw: {
1816
+ phase: "validate",
1817
+ message: (detail) => `creating the machine actor threw: ${detail}`,
1818
+ hint: () => "Fix the error thrown while building the machine context or initial state."
1819
+ }
1820
+ };
1821
+ function validationError(result, caps) {
1822
+ const detail = result.detail === null ? "" : String(result.detail);
1823
+ const spec = VALIDATION_SPECS[result.code];
1824
+ if (spec === void 0) return new ProjectionError("validate", `machine validation failed: ${result.code}`, { hint: "Review the authoring contract from discover_apis and resubmit." });
1825
+ return new ProjectionError(spec.phase, spec.message(detail, caps), { hint: spec.hint(caps) });
1826
+ }
1827
+ var SegmentRunner = class {
1828
+ deps;
1829
+ readCalls = 0;
1830
+ eventsProcessed = 0;
1831
+ constructor(deps) {
1832
+ this.deps = deps;
1833
+ }
1834
+ async run(options) {
1835
+ const { session } = this.deps;
1836
+ const resume = options.resume;
1837
+ let drained;
1838
+ if (resume === void 0) drained = await session.evaluate(bootstrapDriver(options.input, void 0));
1839
+ else {
1840
+ drained = await session.evaluate(bootstrapDriver(void 0, resume.snapshot));
1841
+ const events = await this.applyVerdicts(resume.pending, resume.verdicts);
1842
+ drained = await this.send(events);
1843
+ }
1844
+ const gatedBatch = [];
1845
+ for (;;) {
1846
+ if (drained.status === "error") throw new ProjectionError("runtime", `the machine errored: ${drained.error ?? "unknown"}`, { hint: "Handle the failure in the machine (e.g. a TOOL_ERROR transition) or fix the action that threw." });
1847
+ const requests = this.collectRequests(drained);
1848
+ if (requests.length === 0) {
1849
+ if (gatedBatch.length > 0) return await this.suspend(gatedBatch);
1850
+ if (drained.status === "done" || drained.status === "stopped") return this.complete(drained);
1851
+ throw new ProjectionError("runtime", `the machine is stuck in state ${JSON.stringify(drained.value)} with no pending tool call and no final state`, { hint: "Every non-final state must either request a tool call or transition on an event the host sends. Reach a final state to return a result." });
1852
+ }
1853
+ const { events, gated } = await this.classify(requests, drained.value);
1854
+ gatedBatch.push(...gated);
1855
+ if (events.length === 0) return await this.suspend(gatedBatch);
1856
+ drained = await this.send(events);
1857
+ }
1858
+ }
1859
+ /** Merge queued `toolCall` requests with any that came via the bridge hook. */
1860
+ collectRequests(drained) {
1861
+ const hook = this.deps.hookRequests.splice(0, this.deps.hookRequests.length);
1862
+ return [...drained.requests, ...hook];
1863
+ }
1864
+ /**
1865
+ * Apply the policy to each request: run read-only calls now, deny denied
1866
+ * ones now, and set gated ones aside for approval.
1867
+ */
1868
+ async classify(requests, stateValue) {
1869
+ const events = [];
1870
+ const gated = [];
1871
+ const stateName = describeState(stateValue);
1872
+ for (const request of requests) {
1873
+ const call = {
1874
+ id: request.id,
1875
+ name: request.name,
1876
+ args: request.args,
1877
+ stateName
1878
+ };
1879
+ const resolved = resolveCallPolicy(call, this.deps.catalog, this.deps.policy);
1880
+ if (!resolved.ok) throw resolved.error;
1881
+ if (resolved.decision === "deny") {
1882
+ const reason = policyDeniedReason(request.name);
1883
+ events.push({
1884
+ type: "TOOL_DENIED",
1885
+ callId: request.id,
1886
+ reason
1887
+ });
1888
+ this.deps.audit.push({
1889
+ callId: request.id,
1890
+ tool: request.name,
1891
+ args: request.args,
1892
+ decision: "denied_by_policy",
1893
+ reason
1894
+ });
1895
+ continue;
1896
+ }
1897
+ if (resolved.decision === "ask") {
1898
+ gated.push(call);
1899
+ continue;
1900
+ }
1901
+ this.readCalls += 1;
1902
+ if (this.readCalls > this.deps.caps.maxReadCallsPerSegment) throw new ProjectionError("cap_exceeded", `the machine made more than ${this.deps.caps.maxReadCallsPerSegment} read-only tool calls in one segment`, { hint: `Batch the work or reduce the number of read-only calls to at most ${this.deps.caps.maxReadCallsPerSegment} per segment.` });
1903
+ events.push(await this.execute(request, "auto"));
1904
+ }
1905
+ return {
1906
+ events,
1907
+ gated
1908
+ };
1909
+ }
1910
+ /** Execute one call against the wrapped server and build its event. */
1911
+ async execute(request, decision) {
1912
+ const outcome = await this.deps.executor.callTool(this.deps.catalog.alias, request.name, request.args);
1913
+ if (outcome.ok) {
1914
+ this.deps.audit.push({
1915
+ callId: request.id,
1916
+ tool: request.name,
1917
+ args: request.args,
1918
+ decision
1919
+ });
1920
+ return {
1921
+ type: "TOOL_RESULT",
1922
+ callId: request.id,
1923
+ result: outcome.result
1924
+ };
1925
+ }
1926
+ this.deps.audit.push({
1927
+ callId: request.id,
1928
+ tool: request.name,
1929
+ args: request.args,
1930
+ decision: "error",
1931
+ reason: outcome.error.message
1932
+ });
1933
+ return {
1934
+ type: "TOOL_ERROR",
1935
+ callId: request.id,
1936
+ error: outcome.error
1937
+ };
1938
+ }
1939
+ /**
1940
+ * Turn the client's verdicts into events (R17).
1941
+ *
1942
+ * The arguments executed are the ones sealed in `pending` — the approval
1943
+ * authorized those exact arguments, not whatever the restored machine
1944
+ * might ask for now (S2).
1945
+ */
1946
+ async applyVerdicts(pending, verdicts) {
1947
+ const events = [];
1948
+ for (const [key, call] of Object.entries(pending)) {
1949
+ const verdict = Object.hasOwn(verdicts, key) ? verdicts[key] : void 0;
1950
+ if (verdict === void 0 || verdict.kind === "declined") {
1951
+ const reason = verdict?.kind === "declined" ? verdict.reason : void 0;
1952
+ events.push({
1953
+ type: "TOOL_DENIED",
1954
+ callId: call.id,
1955
+ ...reason !== void 0 ? { reason } : {}
1956
+ });
1957
+ this.deps.audit.push({
1958
+ callId: call.id,
1959
+ tool: call.name,
1960
+ args: call.args,
1961
+ decision: "declined",
1962
+ ...reason !== void 0 ? { reason } : {}
1963
+ });
1964
+ continue;
1965
+ }
1966
+ events.push(await this.execute({
1967
+ id: call.id,
1968
+ name: call.name,
1969
+ args: call.args
1970
+ }, "approved"));
1971
+ }
1972
+ return events;
1973
+ }
1974
+ /** Send events into the actor, counting them against the segment cap. */
1975
+ async send(events) {
1976
+ this.eventsProcessed += events.length;
1977
+ if (this.eventsProcessed > this.deps.caps.maxEventsPerSegment) throw new ProjectionError("cap_exceeded", `the machine processed more than ${this.deps.caps.maxEventsPerSegment} events in one segment`, { hint: `Reduce the number of transitions; at most ${this.deps.caps.maxEventsPerSegment} events run per segment.` });
1978
+ return await this.deps.session.evaluate(sendDriver(events));
1979
+ }
1980
+ /**
1981
+ * Suspend for approval, sealing the exact calls that were requested.
1982
+ *
1983
+ * Every event the machine could act on has already been delivered by the
1984
+ * settle loop, so the in-VM request queue is empty and the snapshot taken
1985
+ * here is complete.
1986
+ */
1987
+ async suspend(gated) {
1988
+ const { snapshot } = await this.deps.session.evaluate(SNAPSHOT_DRIVER);
1989
+ const pending = {};
1990
+ for (const call of gated) pending[call.id] = call;
1991
+ return {
1992
+ status: "suspended",
1993
+ pending,
1994
+ snapshot,
1995
+ trip: this.deps.trip + 1,
1996
+ audit: this.deps.audit,
1997
+ logs: this.logs()
1998
+ };
1999
+ }
2000
+ complete(drained) {
2001
+ const output = drained.output;
2002
+ const size = Buffer.byteLength(JSON.stringify(output ?? null), "utf8");
2003
+ if (size > this.deps.caps.maxResultBytes) throw new ProjectionError("cap_exceeded", `the final result is ${size} bytes, exceeding the limit of ${this.deps.caps.maxResultBytes}`, { hint: `Return a summary instead of the full data: the result must be at most ${this.deps.caps.maxResultBytes} bytes.` });
2004
+ return {
2005
+ status: "completed",
2006
+ output,
2007
+ audit: this.deps.audit,
2008
+ logs: this.logs()
2009
+ };
2010
+ }
2011
+ logs() {
2012
+ return this.deps.session.getLogs();
2013
+ }
2014
+ };
2015
+ /** Render an XState state value as a readable path for elicitation copy (R16). */
2016
+ function describeState(value) {
2017
+ if (typeof value === "string") return value;
2018
+ if (value === null || value === void 0) return "(unknown)";
2019
+ const parts = [];
2020
+ const walk = (node, prefix) => {
2021
+ if (typeof node === "string") {
2022
+ parts.push(prefix.length > 0 ? `${prefix}.${node}` : node);
2023
+ return;
2024
+ }
2025
+ if (typeof node === "object" && node !== null) for (const [key, child] of Object.entries(node)) walk(child, prefix.length > 0 ? `${prefix}.${key}` : key);
2026
+ };
2027
+ walk(value, "");
2028
+ return parts.length > 0 ? parts.join(" | ") : JSON.stringify(value);
2029
+ }
2030
+ //#endregion
2031
+ //#region src/mrtr/protocol.ts
2032
+ /**
2033
+ * The JSON Schema an approval elicitation asks the client to collect, typed
2034
+ * as the SDK's restricted elicitation schema so `inputRequired.elicit()`
2035
+ * accepts it without a cast.
2036
+ *
2037
+ * Projection-specific: MRTR carries whatever schema the server asks for, and
2038
+ * an approval prompt whose only payload is an optional decline reason is our
2039
+ * product decision (R16/R17), not a protocol shape.
2040
+ */
2041
+ const APPROVAL_SCHEMA = {
2042
+ type: "object",
2043
+ properties: { reason: {
2044
+ type: "string",
2045
+ description: "Optional note shown to the model when declining."
2046
+ } },
2047
+ additionalProperties: false
2048
+ };
2049
+ /**
2050
+ * Protocol revision that introduced MRTR.
2051
+ *
2052
+ * The SDK has no exported constant for it: `LATEST_PROTOCOL_VERSION` and
2053
+ * `SUPPORTED_PROTOCOL_VERSIONS` cover the *legacy* (`initialize`-negotiated)
2054
+ * family only — `2025-11-25` and earlier. The 2026-07-28 era is selected by
2055
+ * `server/discover`, and the SDK models it as the `ProtocolEra` value
2056
+ * `'modern'` rather than as a version string. We keep the literal purely so
2057
+ * error copy can name the revision a client must speak (R28).
2058
+ */
2059
+ const MRTR_PROTOCOL_REVISION = "2026-07-28";
2060
+ /**
2061
+ * Whether the era a request is being served on can carry MRTR (R28).
2062
+ *
2063
+ * The SDK binds an era to the *serving instance*, not to a per-request
2064
+ * header: `createMcpHandler` / `serveStdio` hand the era to the server
2065
+ * factory as `McpRequestContext.era`, and a hand-connected `McpServer` is
2066
+ * always `'legacy'`. So this takes the era, not a version string — there is
2067
+ * no `extra.protocolVersion` to read, and reading one was the bug this
2068
+ * migration fixes (docs/DECISIONS.md D1).
2069
+ */
2070
+ function supportsMrtr(era) {
2071
+ return era === "modern";
2072
+ }
2073
+ //#endregion
2074
+ //#region src/mrtr/adapter.ts
2075
+ /**
2076
+ * MRTR adapter — maps segment outcomes to `tools/call` results and parses
2077
+ * retries back into verdicts (PRD §7 "MRTR adapter"; R15, R17, R18, R27,
2078
+ * R28, S2).
2079
+ */
2080
+ /**
2081
+ * Run `execute_machine` for one `tools/call`, whether it is a first attempt
2082
+ * or an MRTR retry.
2083
+ *
2084
+ * Statelessness (R27): everything needed to resume rides in `requestState`,
2085
+ * so any instance can serve any retry. Nothing is held between calls.
2086
+ */
2087
+ async function executeMachine(args, deps) {
2088
+ const caps = clampCaps(deps.caps ?? {});
2089
+ try {
2090
+ assertCodeSize(args.code, caps);
2091
+ assertInputSize(args.input, caps);
2092
+ const resume = args.requestState !== void 0 ? openResume(args, deps, caps) : void 0;
2093
+ return toToolCallResult(await runSegment({
2094
+ code: args.code,
2095
+ ...args.lang !== void 0 ? { lang: args.lang } : {},
2096
+ ...args.input !== void 0 ? { input: args.input } : {},
2097
+ catalog: deps.catalog,
2098
+ bridgeSource: deps.bridgeSource,
2099
+ executor: deps.executor,
2100
+ caps,
2101
+ ...deps.policy !== void 0 ? { policy: deps.policy } : {},
2102
+ segment: resume?.trip ?? 0,
2103
+ ...resume !== void 0 ? { resume } : {}
2104
+ }), args, deps, caps);
2105
+ } catch (err) {
2106
+ return errorResult(toStructuredError(err));
2107
+ }
2108
+ }
2109
+ /**
2110
+ * Verify the echoed blob and turn `inputResponses` into verdicts.
2111
+ *
2112
+ * The blob sealer performs the full R21 check chain (authenticate, version,
2113
+ * expiry, codeHash, server identity) and throws on any failure, so nothing
2114
+ * gated executes from an unverified blob.
2115
+ */
2116
+ function openResume(args, deps, caps) {
2117
+ const payload = openBlob(args.requestState, {
2118
+ code: args.code,
2119
+ serverIdentity: deps.catalog.identity,
2120
+ config: deps.blob
2121
+ });
2122
+ if (payload.trip > caps.maxRoundTrips) throw new ProjectionError("terminal", `this workflow reached the limit of ${caps.maxRoundTrips} approval round trips`, { hint: `Restructure the machine to need at most ${caps.maxRoundTrips} approval round trips, for example by batching writes into fewer states.` });
2123
+ const verdicts = {};
2124
+ for (const key of Object.keys(payload.pending)) {
2125
+ const view = inputResponse(args.inputResponses, key);
2126
+ if (view.kind !== "elicit" || view.action !== "accept") {
2127
+ const reason = view.kind === "elicit" ? readReason(view.content) : void 0;
2128
+ verdicts[key] = reason !== void 0 ? {
2129
+ kind: "declined",
2130
+ reason
2131
+ } : { kind: "declined" };
2132
+ continue;
2133
+ }
2134
+ verdicts[key] = { kind: "approved" };
2135
+ }
2136
+ return {
2137
+ snapshot: payload.snapshot,
2138
+ pending: payload.pending,
2139
+ verdicts,
2140
+ audit: payload.audit,
2141
+ trip: payload.trip
2142
+ };
2143
+ }
2144
+ function readReason(content) {
2145
+ const reason = content?.["reason"];
2146
+ return typeof reason === "string" && reason.length > 0 ? reason : void 0;
2147
+ }
2148
+ function toToolCallResult(outcome, args, deps, caps) {
2149
+ switch (outcome.status) {
2150
+ case "completed": return {
2151
+ content: [{
2152
+ type: "text",
2153
+ text: JSON.stringify(outcome.output ?? null, null, 2)
2154
+ }, ...logBlocks(outcome.logs)],
2155
+ structuredContent: {
2156
+ output: outcome.output ?? null,
2157
+ approvals: outcome.audit,
2158
+ logs: outcome.logs
2159
+ }
2160
+ };
2161
+ case "suspended": return suspendResult(outcome, args, deps, caps);
2162
+ case "error": return errorResult(outcome.error);
2163
+ }
2164
+ }
2165
+ function suspendResult(outcome, args, deps, caps) {
2166
+ const fail = (phase, message, hint) => errorResult({
2167
+ phase,
2168
+ message,
2169
+ diagnostics: [],
2170
+ hint,
2171
+ logs: outcome.logs
2172
+ });
2173
+ if (!supportsMrtr(deps.protocolEra)) return fail("protocol", `this machine needs approval for a non-read-only tool call, which requires MCP protocol revision ${MRTR_PROTOCOL_REVISION} or later (this request is being served on the ${deps.protocolEra ?? "legacy"} protocol era)`, "Author a machine that only calls read-only tools, or connect with a client that negotiates the 2026-07-28 revision (SDK v2: `versionNegotiation: { mode: \"auto\" }`).");
2174
+ if (outcome.trip > caps.maxRoundTrips) return fail("terminal", `this workflow reached the limit of ${caps.maxRoundTrips} approval round trips`, `Restructure the machine to need at most ${caps.maxRoundTrips} approval round trips.`);
2175
+ const inputRequests = {};
2176
+ for (const [key, call] of Object.entries(outcome.pending)) {
2177
+ const entry = findTool(deps.catalog, call.name);
2178
+ if (entry === void 0) return fail("validate", `the machine requested approval for unknown tool "${call.name}"`, "Call only tools listed by discover_apis.");
2179
+ const copy = buildElicitationCopy(call, entry, deps.redact !== void 0 ? { redact: deps.redact } : {});
2180
+ inputRequests[key] = inputRequired.elicit({
2181
+ message: `${copy.title}\n\n${copy.message}`,
2182
+ requestedSchema: APPROVAL_SCHEMA
2183
+ });
2184
+ }
2185
+ const payload = buildBlobPayload({
2186
+ code: args.code,
2187
+ serverIdentity: deps.catalog.identity,
2188
+ snapshot: outcome.snapshot,
2189
+ pending: outcome.pending,
2190
+ audit: outcome.audit,
2191
+ trip: outcome.trip,
2192
+ ...deps.blob.ttlMs !== void 0 ? { ttlMs: deps.blob.ttlMs } : {}
2193
+ });
2194
+ let requestState;
2195
+ try {
2196
+ requestState = sealBlob(payload, deps.blob);
2197
+ } catch (err) {
2198
+ return errorResult({
2199
+ ...toStructuredError(err, outcome.logs),
2200
+ logs: outcome.logs
2201
+ });
2202
+ }
2203
+ return inputRequired({
2204
+ inputRequests,
2205
+ requestState
2206
+ });
2207
+ }
2208
+ /** Every failure is a normal result with `isError: true` and a structured body (R25). */
2209
+ function errorResult(error) {
2210
+ return {
2211
+ isError: true,
2212
+ content: [{
2213
+ type: "text",
2214
+ text: JSON.stringify(error, null, 2)
2215
+ }],
2216
+ structuredContent: { ...error }
2217
+ };
2218
+ }
2219
+ function logBlocks(logs) {
2220
+ if (logs.length === 0) return [];
2221
+ return [{
2222
+ type: "text",
2223
+ text: `Console output:\n${logs.map((log) => `[${log.level}] ${log.message}`).join("\n")}`
2224
+ }];
2225
+ }
2226
+ function assertCodeSize(code, caps) {
2227
+ const size = Buffer.byteLength(code, "utf8");
2228
+ if (size > caps.maxCodeBytes) throw new ProjectionError("cap_exceeded", `submitted code is ${size} bytes, exceeding the limit of ${caps.maxCodeBytes}`, { hint: `Shorten the machine to at most ${caps.maxCodeBytes} bytes.` });
2229
+ }
2230
+ /**
2231
+ * The input is interpolated into the validation and bootstrap driver sources
2232
+ * as a JSON literal, so its serialized size — not the wire size of the raw
2233
+ * value — is what the sandbox actually pays for. Bounding it here yields a
2234
+ * structured cap_exceeded error instead of an opaque WASM memory failure.
2235
+ */
2236
+ function assertInputSize(input, caps) {
2237
+ if (input === void 0) return;
2238
+ let json;
2239
+ try {
2240
+ json = JSON.stringify(input);
2241
+ } catch (err) {
2242
+ throw new ProjectionError("validate", "machine input is not JSON-serializable", {
2243
+ hint: "Remove circular references from the input; it must serialize to plain JSON.",
2244
+ cause: err
2245
+ });
2246
+ }
2247
+ if (json === void 0) return;
2248
+ const size = Buffer.byteLength(json, "utf8");
2249
+ if (size > caps.maxInputBytes) throw new ProjectionError("cap_exceeded", `submitted input serializes to ${size} bytes, exceeding the limit of ${caps.maxInputBytes}`, { hint: `Shrink the machine input to at most ${caps.maxInputBytes} bytes of JSON.` });
2250
+ }
2251
+ //#endregion
2252
+ //#region src/mrtr/discover.ts
2253
+ /**
2254
+ * `discover_apis` — the tool that teaches the model to author a machine
2255
+ * (PRD G6, R3, R29).
2256
+ *
2257
+ * Returns a compact inline overview that is sufficient on its own, plus a
2258
+ * `resource_link` per artifact so a model can pull full contents only when
2259
+ * it wants them. Clients with weak resource support still function.
2260
+ */
2261
+ /** Stable URIs the server serves via `resources/read` (R3). */
2262
+ const RESOURCE_URIS = {
2263
+ toolTypes: "projection://types/tools.d.ts",
2264
+ contract: "projection://docs/authoring-contract.md",
2265
+ happyPath: "projection://examples/happy-path.ts",
2266
+ denialFallback: "projection://examples/denial-fallback.ts"
2267
+ };
2268
+ const EXAMPLE_HAPPY_PATH = `import { setup, assign } from 'xstate';
2269
+ import { toolCall } from '@astral-mcp/tools';
2270
+
2271
+ // Read a file and return its contents. Read-only calls run immediately,
2272
+ // so this whole machine completes in one round trip.
2273
+ export default setup({}).createMachine({
2274
+ id: 'readNotes',
2275
+ context: ({ input }) => ({ path: input?.path ?? '/notes.txt', text: null }),
2276
+ initial: 'reading',
2277
+ states: {
2278
+ reading: {
2279
+ entry: toolCall('read_file', ({ context }) => ({ path: context.path })),
2280
+ on: {
2281
+ TOOL_RESULT: {
2282
+ target: 'done',
2283
+ actions: assign({ text: ({ event }) => event.result }),
2284
+ },
2285
+ TOOL_ERROR: {
2286
+ target: 'failed',
2287
+ actions: assign({ text: ({ event }) => \`error: \${event.error.message}\` }),
2288
+ },
2289
+ },
2290
+ },
2291
+ done: { type: 'final' },
2292
+ failed: { type: 'final' },
2293
+ },
2294
+ output: ({ context }) => ({ text: context.text }),
2295
+ });
2296
+ `;
2297
+ const EXAMPLE_DENIAL_FALLBACK = `import { setup, assign } from 'xstate';
2298
+ import { toolCall } from '@astral-mcp/tools';
2299
+
2300
+ // Write a file, but keep going if the user declines. TOOL_DENIED is an
2301
+ // ordinary transition — denial is never an automatic abort.
2302
+ export default setup({}).createMachine({
2303
+ id: 'writeWithFallback',
2304
+ context: { status: 'pending', note: null },
2305
+ initial: 'writing',
2306
+ states: {
2307
+ writing: {
2308
+ entry: toolCall('write_file', () => ({ path: '/notes.txt', content: 'hello' })),
2309
+ on: {
2310
+ TOOL_RESULT: {
2311
+ target: 'summarizing',
2312
+ actions: assign({ status: 'written' }),
2313
+ },
2314
+ TOOL_DENIED: {
2315
+ // The user said no: record it and carry on with the read-only
2316
+ // part of the work instead of failing the whole workflow.
2317
+ target: 'summarizing',
2318
+ actions: assign({
2319
+ status: 'skipped',
2320
+ note: ({ event }) => event.reason ?? 'declined by user',
2321
+ }),
2322
+ },
2323
+ TOOL_ERROR: {
2324
+ target: 'summarizing',
2325
+ actions: assign({ status: 'failed' }),
2326
+ },
2327
+ },
2328
+ },
2329
+ summarizing: {
2330
+ entry: toolCall('list_dir', () => ({ path: '/' })),
2331
+ on: {
2332
+ TOOL_RESULT: { target: 'done' },
2333
+ TOOL_ERROR: { target: 'done' },
2334
+ },
2335
+ },
2336
+ done: { type: 'final' },
2337
+ },
2338
+ output: ({ context }) => ({ status: context.status, note: context.note }),
2339
+ });
2340
+ `;
2341
+ /** Build the resources Projection serves for a wrapped server (R3). */
2342
+ function buildResources(catalog, dts) {
2343
+ return [
2344
+ {
2345
+ uri: RESOURCE_URIS.toolTypes,
2346
+ name: "tools.d.ts",
2347
+ description: `TypeScript definitions for the tools of ${catalog.identity}.`,
2348
+ mimeType: "text/plain",
2349
+ text: dts
2350
+ },
2351
+ {
2352
+ uri: RESOURCE_URIS.contract,
2353
+ name: "authoring-contract.md",
2354
+ description: "Rules an authored machine must follow.",
2355
+ mimeType: "text/markdown",
2356
+ text: AUTHORING_CONTRACT
2357
+ },
2358
+ {
2359
+ uri: RESOURCE_URIS.happyPath,
2360
+ name: "happy-path.ts",
2361
+ description: "Example machine: read-only work, completes in one call.",
2362
+ mimeType: "text/plain",
2363
+ text: EXAMPLE_HAPPY_PATH
2364
+ },
2365
+ {
2366
+ uri: RESOURCE_URIS.denialFallback,
2367
+ name: "denial-fallback.ts",
2368
+ description: "Example machine: continues gracefully when approval is declined.",
2369
+ mimeType: "text/plain",
2370
+ text: EXAMPLE_DENIAL_FALLBACK
2371
+ }
2372
+ ];
2373
+ }
2374
+ /**
2375
+ * The `discover_apis` result: an inline overview sufficient to author a
2376
+ * simple machine unaided, plus resource links for the full artifacts.
2377
+ */
2378
+ function discoverApis(catalog, overview) {
2379
+ const inline = [
2380
+ `Projection code mode over "${catalog.identity}".`,
2381
+ "",
2382
+ `XState version: ${XSTATE_VERSION} (pinned). Importable modules: 'xstate', '@astral-mcp/tools'. Nothing else resolves; there is no network and no filesystem.`,
2383
+ "",
2384
+ overview,
2385
+ "",
2386
+ AUTHORING_CONTRACT
2387
+ ].join("\n");
2388
+ const links = buildResources(catalog, "").map((resource) => ({
2389
+ type: "resource_link",
2390
+ uri: resource.uri,
2391
+ name: resource.name,
2392
+ description: resource.description,
2393
+ mimeType: resource.mimeType
2394
+ }));
2395
+ return { content: [{
2396
+ type: "text",
2397
+ text: inline
2398
+ }, ...links] };
2399
+ }
2400
+ /**
2401
+ * The wrapped server's `instructions`, followed by the code-mode preamble
2402
+ * (R29). The preamble is library copy the integrating developer can replace.
2403
+ */
2404
+ const DEFAULT_CODE_MODE_PREAMBLE = `This server runs in code mode: instead of calling its tools directly, author an XState v5 state machine and run it with execute_machine, which orchestrates the tool calls for you. Any tool that is not read-only pauses the machine and asks the user to approve that specific action before it runs. Call discover_apis first for the tool types, the authoring contract, and worked examples.`;
2405
+ function buildInstructions(catalog, preamble) {
2406
+ const wrapped = catalog.instructions?.trim();
2407
+ const codeMode = preamble ?? DEFAULT_CODE_MODE_PREAMBLE;
2408
+ return wrapped !== void 0 && wrapped.length > 0 ? `${wrapped}\n\n${codeMode}` : codeMode;
2409
+ }
2410
+ //#endregion
2411
+ //#region src/typegen/json-schema.ts
2412
+ /**
2413
+ * Hand-rolled JSON Schema → TypeScript type-text converter (R4).
2414
+ *
2415
+ * Deliberately a subset: it covers the constructs MCP tool schemas actually
2416
+ * use (objects/properties/required, primitive types, enum/const, arrays and
2417
+ * tuples, anyOf/oneOf unions, allOf intersections, in-document $ref,
2418
+ * additionalProperties, nested objects, property descriptions as doc
2419
+ * comments). Anything it does not understand degrades to `unknown` — never
2420
+ * an exception — because generated types are model guidance, not a
2421
+ * correctness gate (G5: no host-side type checking in the hot path).
2422
+ */
2423
+ const MAX_DEPTH = 24;
2424
+ /** Render a JSON literal as a TS literal type; non-primitives become `unknown`. */
2425
+ function literalType(value) {
2426
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
2427
+ return "unknown";
2428
+ }
2429
+ /** Resolve an in-document JSON pointer like `#/$defs/Foo`. */
2430
+ function resolvePointer(root, pointer) {
2431
+ if (!pointer.startsWith("#")) return void 0;
2432
+ const path = pointer.slice(1);
2433
+ if (path === "") return root;
2434
+ if (!path.startsWith("/")) return void 0;
2435
+ let current = root;
2436
+ for (const rawSegment of path.slice(1).split("/")) {
2437
+ const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~");
2438
+ if (Array.isArray(current)) {
2439
+ const index = Number(segment);
2440
+ current = Number.isInteger(index) ? current[index] : void 0;
2441
+ } else if (isRecord(current)) current = current[segment];
2442
+ else return;
2443
+ if (current === void 0) return void 0;
2444
+ }
2445
+ return current;
2446
+ }
2447
+ /** Wrap a type in parentheses when it is a top-level union/intersection. */
2448
+ function parenthesize(type) {
2449
+ return /[|&]/.test(type) ? `(${type})` : type;
2450
+ }
2451
+ const BARE_KEY_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
2452
+ /** Quote an object key unless it is a valid bare property name. */
2453
+ function propertyKey(name) {
2454
+ return BARE_KEY_RE.test(name) ? name : JSON.stringify(name);
2455
+ }
2456
+ /** Format a description as a doc comment block at the given indent. */
2457
+ function docComment(description, indent) {
2458
+ const lines = description.replaceAll("*/", "*\\/").trimEnd().split("\n");
2459
+ if (lines.length === 1) return `${indent}/** ${lines[0]} */`;
2460
+ return [
2461
+ `${indent}/**`,
2462
+ ...lines.map((line) => `${indent} * ${line}`.trimEnd()),
2463
+ `${indent} */`
2464
+ ].join("\n");
2465
+ }
2466
+ function renderEnum(values) {
2467
+ if (values.length === 0) return "never";
2468
+ return values.map(literalType).join(" | ");
2469
+ }
2470
+ function renderArray(schema, ctx) {
2471
+ const prefixItems = Array.isArray(schema["prefixItems"]) ? schema["prefixItems"] : Array.isArray(schema["items"]) ? schema["items"] : void 0;
2472
+ if (prefixItems !== void 0) return `[${prefixItems.map((item) => render(item, deeper(ctx))).join(", ")}]`;
2473
+ const items = schema["items"];
2474
+ if (items === void 0) return "unknown[]";
2475
+ const itemType = render(items, deeper(ctx));
2476
+ return /[|&]/.test(itemType) || itemType.includes("\n") ? `Array<${itemType}>` : `${itemType}[]`;
2477
+ }
2478
+ function renderObject(schema, ctx) {
2479
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : void 0;
2480
+ const additional = schema["additionalProperties"];
2481
+ const additionalType = isRecord(additional) ? render(additional, deeper(ctx)) : additional === false ? void 0 : "unknown";
2482
+ if (properties === void 0 || Object.keys(properties).length === 0) {
2483
+ if (additional === false) return "Record<string, never>";
2484
+ return `Record<string, ${additionalType ?? "unknown"}>`;
2485
+ }
2486
+ const required = new Set(Array.isArray(schema["required"]) ? schema["required"].filter((name) => typeof name === "string") : []);
2487
+ const childIndent = `${ctx.indent} `;
2488
+ const lines = ["{"];
2489
+ for (const [name, propSchema] of Object.entries(properties)) {
2490
+ const description = isRecord(propSchema) ? propSchema["description"] : void 0;
2491
+ if (typeof description === "string" && description.length > 0) lines.push(docComment(description, childIndent));
2492
+ const optional = required.has(name) ? "" : "?";
2493
+ const type = render(propSchema, {
2494
+ ...deeper(ctx),
2495
+ indent: childIndent
2496
+ });
2497
+ lines.push(`${childIndent}${propertyKey(name)}${optional}: ${type};`);
2498
+ }
2499
+ lines.push(`${ctx.indent}}`);
2500
+ const objectType = lines.join("\n");
2501
+ return isRecord(additional) ? `${objectType} & Record<string, ${additionalType ?? "unknown"}>` : objectType;
2502
+ }
2503
+ function renderByType(type, schema, ctx) {
2504
+ switch (type) {
2505
+ case "string": return "string";
2506
+ case "number":
2507
+ case "integer": return "number";
2508
+ case "boolean": return "boolean";
2509
+ case "null": return "null";
2510
+ case "array": return renderArray(schema, ctx);
2511
+ case "object": return renderObject(schema, ctx);
2512
+ default: return "unknown";
2513
+ }
2514
+ }
2515
+ function deeper(ctx) {
2516
+ return {
2517
+ ...ctx,
2518
+ depth: ctx.depth + 1
2519
+ };
2520
+ }
2521
+ function render(schema, ctx) {
2522
+ if (schema === true) return "unknown";
2523
+ if (schema === false) return "never";
2524
+ if (!isRecord(schema)) return "unknown";
2525
+ if (ctx.depth > MAX_DEPTH) return "unknown";
2526
+ const ref = schema["$ref"];
2527
+ if (typeof ref === "string") {
2528
+ if (ctx.refStack.includes(ref)) return "unknown";
2529
+ const resolved = resolvePointer(ctx.root, ref);
2530
+ if (resolved === void 0) return "unknown";
2531
+ return render(resolved, {
2532
+ ...deeper(ctx),
2533
+ refStack: [...ctx.refStack, ref]
2534
+ });
2535
+ }
2536
+ const enumValues = schema["enum"];
2537
+ if (Array.isArray(enumValues)) return renderEnum(enumValues);
2538
+ if ("const" in schema) return literalType(schema["const"]);
2539
+ const parts = [];
2540
+ const allOf = schema["allOf"];
2541
+ if (Array.isArray(allOf) && allOf.length > 0) parts.push(...allOf.map((member) => render(member, deeper(ctx))));
2542
+ const unionMembers = Array.isArray(schema["anyOf"]) ? schema["anyOf"] : Array.isArray(schema["oneOf"]) ? schema["oneOf"] : void 0;
2543
+ if (unionMembers !== void 0 && unionMembers.length > 0) parts.push(unionMembers.map((member) => render(member, deeper(ctx))).join(" | "));
2544
+ const type = schema["type"];
2545
+ if (Array.isArray(type)) {
2546
+ const members = type.filter((entry) => typeof entry === "string").map((entry) => renderByType(entry, schema, ctx));
2547
+ if (members.length > 0) parts.push(members.join(" | "));
2548
+ } else if (typeof type === "string") parts.push(renderByType(type, schema, ctx));
2549
+ else if (schema["properties"] !== void 0 || schema["additionalProperties"] !== void 0 || schema["required"] !== void 0) parts.push(renderObject(schema, ctx));
2550
+ else if (schema["items"] !== void 0 || schema["prefixItems"] !== void 0) parts.push(renderArray(schema, ctx));
2551
+ if (parts.length === 0) return "unknown";
2552
+ if (parts.length === 1) return parts[0] ?? "unknown";
2553
+ return parts.map(parenthesize).join(" & ");
2554
+ }
2555
+ /**
2556
+ * Convert a JSON Schema into TypeScript type text.
2557
+ *
2558
+ * @param schema - The schema (also used as the $ref resolution root).
2559
+ * @param indent - Indentation prefix of the line the type is emitted on;
2560
+ * nested object literals indent relative to it.
2561
+ */
2562
+ function schemaToTsType(schema, indent = "") {
2563
+ return render(schema, {
2564
+ root: schema,
2565
+ depth: 0,
2566
+ refStack: [],
2567
+ indent
2568
+ });
2569
+ }
2570
+ /**
2571
+ * True when the schema requires at least one argument property — used to
2572
+ * decide whether the generated tool function's `args` parameter is optional.
2573
+ */
2574
+ function schemaRequiresArgs(schema) {
2575
+ return isRecord(schema) && Array.isArray(schema["required"]) && schema["required"].length > 0;
2576
+ }
2577
+ //#endregion
2578
+ //#region src/typegen/generate.ts
2579
+ /**
2580
+ * Typegen — produces the `@astral-mcp/tools` artifacts from a ServerCatalog
2581
+ * (PRD G6, R3, R4, R30):
2582
+ *
2583
+ * 1. `.d.ts` text describing the namespaced `tools` API surface,
2584
+ * 2. the runtime bridge module source mounted at `@astral-mcp/tools` inside
2585
+ * the QuickJS sandbox, and
2586
+ * 3. a compact human-readable overview block for `discover_apis`.
2587
+ */
2588
+ /**
2589
+ * Module specifier authored machines import the tools surface from (R4).
2590
+ * The sandbox component mounts {@link GeneratedToolTypes.bridgeSource} here.
2591
+ */
2592
+ const TOOLS_MODULE_SPECIFIER = "@astral-mcp/tools";
2593
+ /**
2594
+ * Name of the global the sandbox host MUST inject before authored code runs.
2595
+ *
2596
+ * Contract (for the sandbox / machine-host components):
2597
+ *
2598
+ * ```js
2599
+ * globalThis.__projection_toolcall = (name, args) => <host-defined value>
2600
+ * ```
2601
+ *
2602
+ * - `name` is the wrapped server's tool name exactly as catalogued.
2603
+ * - `args` is a plain JSON-serializable object (never `undefined`; the
2604
+ * bridge substitutes `{}` for a missing argument).
2605
+ * - The bridge performs no I/O itself and returns the hook's value
2606
+ * verbatim. Per R7, authored machines never consume that value as a
2607
+ * promise inside invoked actors — the library-supplied `toolCall` action
2608
+ * is the intended caller, and results come back as TOOL_RESULT /
2609
+ * TOOL_ERROR / TOOL_DENIED events injected by the host.
2610
+ * - If the hook is missing, every tool function throws, keeping the bridge
2611
+ * inert outside a properly initialized sandbox (S1).
2612
+ */
2613
+ const TOOL_CALL_HOST_GLOBAL = "__projection_toolcall";
2614
+ const PASCAL_FALLBACK = "Tool";
2615
+ /**
2616
+ * Longest catalog-supplied identity rendered into generated artifacts.
2617
+ * A hostile wrapped server controls this string (S5).
2618
+ */
2619
+ const MAX_IDENTITY_CHARS = 200;
2620
+ /**
2621
+ * Render an untrusted catalog string for safe inclusion in a `//` line
2622
+ * comment of generated source.
2623
+ *
2624
+ * The bridge source is EVALUATED (mounted at `@astral-mcp/tools` and imported
2625
+ * by every machine that calls a tool), so a newline in a wrapped server's
2626
+ * name or version would terminate the comment and inject executable
2627
+ * statements into the bridge — a hostile server would then run code inside
2628
+ * the sandbox on every `execute_machine`, regardless of what the model
2629
+ * authored (S1/S5). The same string lands in the `.d.ts` header the model
2630
+ * reads as authoritative guidance.
2631
+ *
2632
+ * `JSON.stringify` escapes newlines and quotes; the control/bidi pass covers
2633
+ * the separators JSON leaves raw, and the length cap bounds the artifact.
2634
+ */
2635
+ function commentSafe(text) {
2636
+ return JSON.stringify(neutralizeControlChars(capLength(text, MAX_IDENTITY_CHARS)));
2637
+ }
2638
+ /** Derive a unique PascalCase type-name base from a tool name. */
2639
+ function pascalBase(toolName, taken) {
2640
+ const joined = toolName.split(/[^A-Za-z0-9]+/).filter((segment) => segment.length > 0).map((segment) => (segment[0] ?? "").toUpperCase() + segment.slice(1)).join("");
2641
+ let base = /^[A-Za-z]/.test(joined) ? joined : `${PASCAL_FALLBACK}${joined}`;
2642
+ if (base.length === 0) base = PASCAL_FALLBACK;
2643
+ if (taken.has(base)) {
2644
+ let counter = 2;
2645
+ while (taken.has(`${base}${counter}`)) counter += 1;
2646
+ base = `${base}${counter}`;
2647
+ }
2648
+ taken.add(base);
2649
+ return base;
2650
+ }
2651
+ function approvalNote(tool) {
2652
+ if (tool.annotations?.readOnlyHint === true) return "Read-only: executes immediately, no user approval required.";
2653
+ if (tool.annotations?.destructiveHint === true) return "DESTRUCTIVE write: pauses the machine for user approval before executing.";
2654
+ return "Write (or unannotated): pauses the machine for user approval before executing.";
2655
+ }
2656
+ function toolDoc(tool) {
2657
+ const lines = [];
2658
+ if (tool.description !== void 0 && tool.description.length > 0) lines.push(tool.description.trim(), "");
2659
+ lines.push(approvalNote(tool));
2660
+ return lines.join("\n");
2661
+ }
2662
+ function generateDts(server) {
2663
+ const taken = /* @__PURE__ */ new Set();
2664
+ const names = /* @__PURE__ */ new Map();
2665
+ const sections = [];
2666
+ sections.push([
2667
+ `// Generated by Projection from wrapped server ${commentSafe(server.identity)}. Do not edit.`,
2668
+ "//",
2669
+ "// Import inside an authored machine module as:",
2670
+ `// import { tools } from '${TOOLS_MODULE_SPECIFIER}';`,
2671
+ "//",
2672
+ "// Tool functions are the transport used by the library-supplied",
2673
+ "// `toolCall` action. Do NOT wrap them in fromPromise / invoked actors",
2674
+ "// (banned pattern R7) — emit toolCall(name, args) and handle the",
2675
+ "// TOOL_RESULT / TOOL_ERROR / TOOL_DENIED events instead."
2676
+ ].join("\n"));
2677
+ for (const tool of server.tools) {
2678
+ const base = pascalBase(tool.name, taken);
2679
+ const typeNames = {
2680
+ input: `${base}Input`,
2681
+ output: tool.outputSchema !== void 0 ? `${base}Output` : void 0
2682
+ };
2683
+ names.set(tool.name, typeNames);
2684
+ sections.push(`${docComment(`Input for tool "${tool.name}".`, "")}\nexport type ${typeNames.input} = ${schemaToTsType(tool.inputSchema)};`);
2685
+ if (typeNames.output !== void 0) sections.push(`${docComment(`Output of tool "${tool.name}".`, "")}\nexport type ${typeNames.output} = ${schemaToTsType(tool.outputSchema)};`);
2686
+ }
2687
+ const memberLines = [];
2688
+ for (const tool of server.tools) {
2689
+ const typeNames = names.get(tool.name);
2690
+ memberLines.push(docComment(toolDoc(tool), " "));
2691
+ const optional = schemaRequiresArgs(tool.inputSchema) ? "" : "?";
2692
+ const returnType = typeNames.output ?? "unknown";
2693
+ memberLines.push(` ${propertyKey(tool.name)}: (args${optional}: ${typeNames.input}) => Promise<${returnType}>;`);
2694
+ }
2695
+ const surfaceDoc = docComment(`Namespaced tool surface of wrapped server ${commentSafe(server.identity)}.`, "");
2696
+ const body = memberLines.length > 0 ? `export declare const tools: {\n${memberLines.join("\n")}\n};` : "export declare const tools: Record<string, never>;";
2697
+ sections.push(`${surfaceDoc}\n${body}\n\nexport default tools;`);
2698
+ return `${sections.join("\n\n")}\n`;
2699
+ }
2700
+ function generateBridgeSource(server) {
2701
+ const memberLines = server.tools.map((tool) => `tools[${JSON.stringify(tool.name)}] = bindTool(${JSON.stringify(tool.name)});`);
2702
+ return [
2703
+ `// ${TOOLS_MODULE_SPECIFIER} — runtime bridge generated by Projection`,
2704
+ `// from wrapped server ${commentSafe(server.identity)}. Do not edit.`,
2705
+ "//",
2706
+ "// Sandbox-host contract:",
2707
+ "// Before authored code runs, the sandbox host MUST inject:",
2708
+ `// globalThis.${TOOL_CALL_HOST_GLOBAL} = (name, args) => <host-defined>`,
2709
+ "// `name` is the catalogued tool name, `args` a plain JSON-serializable",
2710
+ "// object (the bridge substitutes {} when called without arguments).",
2711
+ "// This module performs no I/O itself; the injected hook is the only",
2712
+ "// channel out of the sandbox (S1), and its return value is handed back",
2713
+ "// verbatim to the caller — the library-supplied `toolCall` action.",
2714
+ "",
2715
+ "function bindTool(name) {",
2716
+ " return function (args) {",
2717
+ ` const hook = globalThis[${JSON.stringify(TOOL_CALL_HOST_GLOBAL)}];`,
2718
+ " if (typeof hook !== 'function') {",
2719
+ " throw new Error(",
2720
+ ` 'Projection tools bridge: missing host hook globalThis.${TOOL_CALL_HOST_GLOBAL}. ' +`,
2721
+ " 'The sandbox host must inject it before authored code runs.',",
2722
+ " );",
2723
+ " }",
2724
+ " return hook(name, args === undefined ? {} : args);",
2725
+ " };",
2726
+ "}",
2727
+ "",
2728
+ "const tools = Object.create(null);",
2729
+ ...memberLines,
2730
+ "Object.freeze(tools);",
2731
+ "",
2732
+ "export { tools };",
2733
+ "export default tools;",
2734
+ ""
2735
+ ].join("\n");
2736
+ }
2737
+ function overviewFlags(tool) {
2738
+ if (tool.annotations?.readOnlyHint === true) return "[read-only]";
2739
+ if (tool.annotations?.destructiveHint === true) return "[approval, destructive]";
2740
+ return "[approval]";
2741
+ }
2742
+ function firstLine(text) {
2743
+ if (text === void 0) return "(no description)";
2744
+ const line = (text.split("\n")[0] ?? "").trim();
2745
+ if (line.length === 0) return "(no description)";
2746
+ return line.length > 100 ? `${line.slice(0, 97)}...` : line;
2747
+ }
2748
+ function generateOverview(server) {
2749
+ const lines = [`Tools (server: ${server.identity}):`];
2750
+ if (server.tools.length === 0) lines.push(" (none)");
2751
+ for (const tool of server.tools) lines.push(`- ${tool.name} ${overviewFlags(tool)} — ${firstLine(tool.description)}`);
2752
+ return `${lines.join("\n")}\n`;
2753
+ }
2754
+ function resolveSingleServer(catalog) {
2755
+ if ("servers" in catalog) {
2756
+ if (catalog.servers.size !== 1) throw new ProjectionError("protocol", `Typegen requires exactly one wrapped server; catalog has ${catalog.servers.size}.`, { hint: "v1 wraps a single server (multi-server namespacing is a stretch goal, PRD 12.2). Pass a single-entry catalog or a ServerCatalog directly." });
2757
+ const server = catalog.servers.values().next().value;
2758
+ if (server === void 0) throw new ProjectionError("protocol", "Tool catalog contains no servers.", { hint: "Build the catalog with createIntrospector before generating types." });
2759
+ return server;
2760
+ }
2761
+ return catalog;
2762
+ }
2763
+ /**
2764
+ * Generate the `@astral-mcp/tools` artifacts for a wrapped server.
2765
+ *
2766
+ * Accepts either the full aliased {@link ToolCatalog} (which must contain
2767
+ * exactly one server in v1, per R30's single-instance instantiation) or a
2768
+ * {@link ServerCatalog} directly.
2769
+ */
2770
+ function generateToolTypes(catalog) {
2771
+ const server = dedupeToolNames(resolveSingleServer(catalog));
2772
+ return {
2773
+ dts: generateDts(server),
2774
+ bridgeSource: generateBridgeSource(server),
2775
+ overview: generateOverview(server)
2776
+ };
2777
+ }
2778
+ /**
2779
+ * Drop tools whose name repeats an earlier one, first occurrence wins.
2780
+ *
2781
+ * A wrapped server may return the same tool name twice (S5: the catalog is
2782
+ * untrusted). Emitting it twice would produce a duplicate member in the
2783
+ * `.d.ts` object type — invalid TypeScript — and a silently last-wins
2784
+ * shadowed key in the bridge, so the types and the runtime would disagree
2785
+ * about which schema the tool takes. Collapsing to the first occurrence
2786
+ * matches what `callTool` reaches on the wrapped server.
2787
+ */
2788
+ function dedupeToolNames(server) {
2789
+ const seen = /* @__PURE__ */ new Set();
2790
+ const tools = server.tools.filter((tool) => {
2791
+ if (seen.has(tool.name)) return false;
2792
+ seen.add(tool.name);
2793
+ return true;
2794
+ });
2795
+ return tools.length === server.tools.length ? server : {
2796
+ ...server,
2797
+ tools
2798
+ };
2799
+ }
2800
+ //#endregion
2801
+ //#region src/wrap.ts
2802
+ const EXECUTE_MACHINE_DESCRIPTION = `Run an LLM-authored XState v5 state machine that orchestrates this server's tools.
2803
+
2804
+ Author a module that default-exports a machine built with XState v5 setup(...).createMachine(...), and request tools with the toolCall action from '@astral-mcp/tools'. Read-only tools run immediately; every other tool pauses the machine and asks the user to approve that exact call before it runs, so handle TOOL_RESULT, TOOL_ERROR and TOOL_DENIED. Machine context must stay JSON-serializable, and invoke/fromPromise is rejected.
2805
+
2806
+ Call discover_apis first for the tool types, the full authoring contract, and worked examples.`;
2807
+ const DISCOVER_APIS_DESCRIPTION = `Return this server's tool type definitions, the machine authoring contract, and worked examples. Call this before authoring a machine for execute_machine.`;
2808
+ /**
2809
+ * Wrap an MCP server (an in-process instance or a connected client) into a
2810
+ * code-mode facade.
2811
+ */
2812
+ async function wrapMcpServer(source, options) {
2813
+ const introspector = await createIntrospector({ [DEFAULT_SERVER_ALIAS]: source });
2814
+ const catalog = introspector.catalog.servers.get(DEFAULT_SERVER_ALIAS);
2815
+ if (catalog === void 0) throw new ProjectionError("protocol", "introspection produced no catalog for the wrapped server", { hint: "Check that the wrapped server initialized successfully." });
2816
+ const { dts, bridgeSource, overview } = generateToolTypes(catalog);
2817
+ return new Facade({
2818
+ introspector,
2819
+ catalog,
2820
+ dts,
2821
+ bridgeSource,
2822
+ overview,
2823
+ resources: buildResources(catalog, dts),
2824
+ instructions: buildInstructions(catalog, options.codeModePreamble),
2825
+ options
2826
+ });
2827
+ }
2828
+ var Facade = class {
2829
+ catalog;
2830
+ instructions;
2831
+ dts;
2832
+ resources;
2833
+ serverFactory;
2834
+ deps;
2835
+ constructor(deps) {
2836
+ this.deps = deps;
2837
+ this.catalog = deps.catalog;
2838
+ this.instructions = deps.instructions;
2839
+ this.dts = deps.dts;
2840
+ this.resources = deps.resources;
2841
+ this.serverFactory = (ctx) => this.createServer(ctx);
2842
+ }
2843
+ executeMachine(args, era) {
2844
+ const { options } = this.deps;
2845
+ return executeMachine(args, {
2846
+ catalog: this.deps.catalog,
2847
+ bridgeSource: this.deps.bridgeSource,
2848
+ executor: this.deps.introspector,
2849
+ blob: options.blob,
2850
+ ...options.caps !== void 0 ? { caps: options.caps } : {},
2851
+ ...options.policy !== void 0 ? { policy: options.policy } : {},
2852
+ ...options.redact !== void 0 ? { redact: options.redact } : {},
2853
+ ...era !== void 0 ? { protocolEra: era } : {}
2854
+ });
2855
+ }
2856
+ discoverApis() {
2857
+ return discoverApis(this.deps.catalog, this.deps.overview);
2858
+ }
2859
+ /**
2860
+ * R29: `Server` accepts `instructions` only in its constructor, so the
2861
+ * code-mode preamble can be delivered from here and nowhere else. This is
2862
+ * also where the era arrives, which is why the factory shape is the
2863
+ * library's recommended entry point.
2864
+ */
2865
+ createServer(ctx) {
2866
+ const server = new McpServer(this.deps.options.serverInfo ?? {
2867
+ name: `projection-${this.deps.catalog.alias}`,
2868
+ version: "0.1.0"
2869
+ }, { instructions: this.instructions });
2870
+ this.registerOn(server, ctx);
2871
+ return server;
2872
+ }
2873
+ registerOn(server, options) {
2874
+ const era = options?.era;
2875
+ server.registerTool("execute_machine", {
2876
+ title: "Execute a state machine",
2877
+ description: EXECUTE_MACHINE_DESCRIPTION,
2878
+ inputSchema: EXECUTE_MACHINE_INPUT_SCHEMA
2879
+ }, async (args, ctx) => await this.executeMachine({
2880
+ code: args.code,
2881
+ ...args.lang !== void 0 ? { lang: args.lang } : {},
2882
+ ...args.input !== void 0 ? { input: args.input } : {},
2883
+ ...maybeRequestState(ctx.mcpReq.requestState()),
2884
+ ...ctx.mcpReq.inputResponses !== void 0 ? { inputResponses: ctx.mcpReq.inputResponses } : {}
2885
+ }, era));
2886
+ server.registerTool("discover_apis", {
2887
+ title: "Discover the tool API",
2888
+ description: DISCOVER_APIS_DESCRIPTION,
2889
+ inputSchema: z.object({})
2890
+ }, () => this.discoverApis());
2891
+ for (const resource of this.resources) server.registerResource(resource.name, resource.uri, {
2892
+ description: resource.description,
2893
+ mimeType: resource.mimeType
2894
+ }, () => ({ contents: [{
2895
+ uri: resource.uri,
2896
+ mimeType: resource.mimeType,
2897
+ text: resource.text
2898
+ }] }));
2899
+ }
2900
+ close() {
2901
+ return this.deps.introspector.close();
2902
+ }
2903
+ };
2904
+ /**
2905
+ * `ctx.mcpReq.requestState()` is typed as a caller-asserted cast, so it is
2906
+ * read as `unknown` and narrowed here: without a configured
2907
+ * `ServerOptions.requestState.verify` hook the SDK hands back the raw wire
2908
+ * string, and anything else means someone wired a decoding hook Projection
2909
+ * does not own.
2910
+ */
2911
+ function maybeRequestState(state) {
2912
+ return typeof state === "string" ? { requestState: state } : {};
2913
+ }
2914
+ /**
2915
+ * `execute_machine` arguments (R2).
2916
+ *
2917
+ * The MRTR retry fields are deliberately absent: `requestState` and
2918
+ * `inputResponses` are siblings of `arguments` in a `tools/call`, not
2919
+ * members of it, and the SDK surfaces them on the handler context.
2920
+ */
2921
+ const EXECUTE_MACHINE_INPUT_SCHEMA = z.object({
2922
+ code: z.string().describe("The machine module source: a default-exported XState v5 machine."),
2923
+ lang: z.enum(["ts", "js"]).optional().describe("Language of `code`. Defaults to TypeScript-compatible parsing."),
2924
+ input: z.unknown().optional().describe("Passed to the machine as its `input`.")
2925
+ });
2926
+ //#endregion
2927
+ export { APPROVAL_SCHEMA, AUTHORING_CONTRACT, BLOB_SCHEMA_VERSION, DEFAULT_CAPS, DEFAULT_CODE_MODE_PREAMBLE, DEFAULT_MAX_BLOB_BYTES, DEFAULT_SERVER_ALIAS, DEFAULT_TTL_MS, ENVELOPE_PREFIX, EXAMPLE_DENIAL_FALLBACK, EXAMPLE_HAPPY_PATH, Introspector, LIB_VERSION, MAX_CAPS, MRTR_PROTOCOL_REVISION, POLICY_DENIED_REASON, ProjectionError, RESOURCE_URIS, TOOLS_MODULE_SPECIFIER, TOOL_CALL_HOST_GLOBAL, XSTATE_VERSION, acceptedContent, buildBlobPayload, buildElicitationCopy, buildInstructions, buildResources, capLength, clampCaps, createIntrospector, createSandboxSession, decidePolicy, discoverApis, executeMachine, findTool, generateToolTypes, hashCode, inputRequired, inputResponse, isInputRequiredResult, isRecord, neutralizeControlChars, openBlob, policyDeniedReason, resolveCallPolicy, runSegment, schemaRequiresArgs, schemaToTsType, sealBlob, supportsMrtr, toStructuredError, wrapMcpServer };
2928
+
2929
+ //# sourceMappingURL=index.mjs.map