@bevel-software/platform-mcp-core 0.11.1 → 0.12.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/src/results.ts CHANGED
@@ -1,5 +1,82 @@
1
1
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
2
 
3
+ /**
4
+ * Discriminator for {@link McpImageResult}. The slash + version suffix make it
5
+ * a value no ordinary tool result carries by accident — a domain object with a
6
+ * `kind` field holds things like `'image'` or `'file'`, never this string —
7
+ * so the result shaping below can act on the shape without ever mistaking
8
+ * caller data for it.
9
+ */
10
+ export const MCP_IMAGE_RESULT_KIND = 'bevel/mcp-image@v1';
11
+
12
+ /**
13
+ * The sentinel a tool HANDLER returns when its result is a picture, not JSON.
14
+ *
15
+ * Handlers normally return domain JSON that `toCallToolResult` stringifies
16
+ * into one text block. An image cannot ride that path — a multimodal client
17
+ * only SEES a picture delivered as a native MCP image content block — so a
18
+ * handler that wants the caller to see one returns this shape instead, and
19
+ * the MCP result shaping turns it into
20
+ * `content: [{type:'image', data, mimeType}, {type:'text', text: note}]`.
21
+ * The `note` keeps the transcript self-describing (path, size, dimensions);
22
+ * without it the result is the bare image block.
23
+ *
24
+ * The shape intentionally travels as plain JSON: it crosses the UTCP http hop
25
+ * (loopback REST → hosted proxy) unchanged, and only the final MCP surface
26
+ * turns it into content blocks.
27
+ */
28
+ export interface McpImageResult {
29
+ kind: typeof MCP_IMAGE_RESULT_KIND;
30
+ /** Base64-encoded image bytes (no data-URI prefix). */
31
+ data: string;
32
+ /** e.g. `image/png` — what the MCP image block advertises. */
33
+ mimeType: string;
34
+ /** One-line description (path + size/dimensions) emitted as a text block beside the image. */
35
+ note?: string;
36
+ }
37
+
38
+ /** Build a {@link McpImageResult} for a handler to return. */
39
+ export function mcpImageResult(data: string, mimeType: string, note?: string): McpImageResult {
40
+ return { kind: MCP_IMAGE_RESULT_KIND, data, mimeType, ...(note !== undefined ? { note } : {}) };
41
+ }
42
+
43
+ export function isMcpImageResult(value: unknown): value is McpImageResult {
44
+ if (typeof value !== 'object' || value === null) return false;
45
+ // The field reads are defensive: this guard runs on every candidate value
46
+ // (toCallToolResult, the scrub's probe), and a candidate can carry a
47
+ // THROWING getter on any of these names. Answering "not a sentinel" is the
48
+ // safe verdict — the value then rides the ordinary serialization fallback
49
+ // instead of turning a completed call into a handler failure.
50
+ try {
51
+ return (
52
+ (value as { kind?: unknown }).kind === MCP_IMAGE_RESULT_KIND &&
53
+ typeof (value as { data?: unknown }).data === 'string' &&
54
+ typeof (value as { mimeType?: unknown }).mimeType === 'string' &&
55
+ // A sentinel may have crossed a transport, so its shape is not this
56
+ // process's to assume: a non-string `note` would be accepted here and then
57
+ // emitted as a text block whose `text` is not a string — an invalid block.
58
+ ['undefined', 'string'].includes(typeof (value as { note?: unknown }).note)
59
+ );
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** A spec-shaped MCP image content block (`{type:'image', data, mimeType}`). */
66
+ function isMcpImageBlockObject(value: unknown): value is { type: 'image'; data: string; mimeType: string } {
67
+ if (typeof value !== 'object' || value === null) return false;
68
+ // Defensive like isMcpImageResult: a throwing getter means "not a block".
69
+ try {
70
+ return (
71
+ (value as { type?: unknown }).type === 'image' &&
72
+ typeof (value as { data?: unknown }).data === 'string' &&
73
+ typeof (value as { mimeType?: unknown }).mimeType === 'string'
74
+ );
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
3
80
  /**
4
81
  * Extract a human-meaningful failure message from a tool-call error. UTCP's
5
82
  * HTTP protocol surfaces a non-2xx as an axios-style error whose `.response.data`
@@ -92,7 +169,79 @@ function safeJsonText(value: unknown): string {
92
169
  }
93
170
  }
94
171
 
172
+ /**
173
+ * A note that survived a remote hop, whatever it decoded to.
174
+ *
175
+ * `@utcp/mcp` JSON-PARSES a text block's text, so a note that happens to read
176
+ * as JSON comes back as what it denotes — `42` for "42", null for "null" —
177
+ * not as a string. Insisting on strings meant such a result was not recognized
178
+ * as the image it is, and the fallback stringified the whole thing, base64 and
179
+ * all, into the transcript.
180
+ */
181
+ function noteText(value: unknown): string | undefined {
182
+ if (value === null || ['number', 'boolean'].includes(typeof value)) return String(value);
183
+ if (typeof value === 'string') return value;
184
+ // An OBJECT or an ARRAY: the note was JSON to begin with, and re-serializing
185
+ // it is the only faithful way back to the text block it left as. Refusing
186
+ // these sent the whole result — base64 included — through the fallback.
187
+ if (typeof value === 'object') {
188
+ try {
189
+ return JSON.stringify(value);
190
+ } catch {
191
+ return undefined; // cyclic: not a note this hop produced
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+
95
197
  export function toCallToolResult(value: unknown): CallToolResult {
198
+ // An image sentinel (see McpImageResult): the tool's result IS a picture.
199
+ // Emit a native image content block so a multimodal client renders it, plus
200
+ // the note as a text block so the transcript stays self-describing.
201
+ if (isMcpImageResult(value)) {
202
+ // Read every field ONCE, here: the guard above proved their shapes, but a
203
+ // getter is free to answer differently the next time — or to throw — and
204
+ // the block would then carry something the guard never validated.
205
+ const snapshot = { data: value.data, mimeType: value.mimeType, note: value.note };
206
+ if (typeof snapshot.data !== 'string' || typeof snapshot.mimeType !== 'string') {
207
+ return { content: [{ type: 'text', text: safeJsonText(value) }] };
208
+ }
209
+ return {
210
+ content: [
211
+ { type: 'image', data: snapshot.data, mimeType: snapshot.mimeType },
212
+ ...(typeof snapshot.note === 'string' ? [{ type: 'text' as const, text: snapshot.note }] : []),
213
+ ],
214
+ };
215
+ }
216
+ // The same image result AFTER a remote MCP hop. The local stdio server
217
+ // (hexis-mcp) reaches the deployment's MCP endpoint through @utcp/mcp, whose
218
+ // `_processMcpToolResult` unwraps a CallToolResult's `content` array: text
219
+ // blocks are JSON-parsed (our prose note comes back as a bare string; a note
220
+ // that was JSON to begin with comes back as the value it denotes — see
221
+ // `noteText`), other blocks pass through verbatim, and a single-entry list
222
+ // collapses to the entry itself. So the hosted proxy's `[image, text]`
223
+ // result arrives here as `[imageBlock, note]` — or, noteless, as the bare
224
+ // image block. Recognize both and reassemble the spec-shaped result instead
225
+ // of JSON-stringifying megabytes of base64 into a text block. ONLY those
226
+ // exact hop shapes qualify — a lone image block, or one image block plus
227
+ // one note — so a caller's ordinary array that merely holds an image-shaped
228
+ // object beside its own data still takes the stringify path below and keeps
229
+ // its structure.
230
+ if (isMcpImageBlockObject(value)) {
231
+ return { content: [value] };
232
+ }
233
+ if (
234
+ Array.isArray(value) &&
235
+ value.length === 2 &&
236
+ isMcpImageBlockObject(value[0]) &&
237
+ !isMcpImageBlockObject(value[1])
238
+ ) {
239
+ const note = noteText(value[1]);
240
+ if (note !== undefined) {
241
+ return { content: [value[0], { type: 'text', text: note }] };
242
+ }
243
+ }
244
+
96
245
  // Already in MCP agentic format — pass through untouched, but only when every
97
246
  // `content` entry is a real content block (has a string `type`).
98
247
  const content = (value as { content?: unknown })?.content;
@@ -103,6 +252,406 @@ export function toCallToolResult(value: unknown): CallToolResult {
103
252
  return { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
104
253
  }
105
254
 
255
+ /**
256
+ * Replace image payloads inside a `call_tool_chain` result with a short note.
257
+ *
258
+ * A chain's return value is JSON that gets STRINGIFIED into the transcript
259
+ * (or spilled), so an image result reaching it would either flood the context
260
+ * with base64 or burn a spill for bytes no one can see — a chain has no way to
261
+ * deliver a native image block. Policy (v1): images are returned directly,
262
+ * never through tool chains. This walk swaps every image sentinel — and every
263
+ * spec-shaped image content block, which is what the local server's remote
264
+ * MCP hop hands a chain (see `toCallToolResult`) — for
265
+ * `{ image_omitted: true, note }`, keeping whatever structure the chain built
266
+ * around it. The walk is ITERATIVE (explicit stack) and cycle-safe with no
267
+ * depth cutoff — nesting depth can never smuggle a payload past the scrub.
268
+ * Arrays and plain records are rebuilt; a NON-plain object (class instance,
269
+ * Map-like with enumerable props) is judged by its JSON shape — `toJSON()`
270
+ * output when it defines one, enumerable own properties otherwise — because
271
+ * that is exactly what `JSON.stringify` will emit downstream: when that shape
272
+ * holds an image the object is rebuilt as a plain sanitized copy, and when it
273
+ * does not the original reference is preserved untouched (RegExp, Map, Date
274
+ * and friends are never mangled).
275
+ *
276
+ * The one thing never preserved by reference is a USER `toJSON`. The scrubbed
277
+ * result is stringified into the transcript AFTER this returns, so a hook left
278
+ * live in it fires a second time there, unscrubbed: an impure one — clean when
279
+ * probed, an image payload on the next call — would slip base64 straight past
280
+ * the walk. Every object carrying one is therefore rebuilt from the single
281
+ * view this scrub cached for it, and nothing reachable in the returned value
282
+ * carries a hook the walk did not already resolve. Best-effort by design:
283
+ * chain code that EXTRACTS the base64 string itself escapes the walk, and then
284
+ * the ordinary max_output_size spill bounds the damage.
285
+ *
286
+ * `rootKey` is the JSON property name the SCRUBBED value will be serialized
287
+ * under afterwards — '' (the default) when it is stringified standalone, the
288
+ * envelope key (e.g. `call_tool_chain`'s 'result') when a caller embeds it —
289
+ * so a key-sensitive root `toJSON` answers this scrub with the same view the
290
+ * transcript serializer will ask it for.
291
+ */
292
+ export function omitImagePayloads(value: unknown, rootKey = ''): unknown {
293
+ const omittedNote = (what: string): { image_omitted: true; note: string } => ({
294
+ image_omitted: true,
295
+ note:
296
+ `${what} — images are returned directly to you as native MCP image content, not through ` +
297
+ '`call_tool_chain`. Call `read_file` on the image path as a DIRECT tool call (outside the chain) to see it.',
298
+ });
299
+
300
+ // Plain records (Object.prototype or null prototype) are always rebuilt.
301
+ // Anything else — Date, Map, Buffer, class instances, other JSON-friendly
302
+ // oddities — is preserved BY REFERENCE unless its JSON shape actually holds
303
+ // an image (see subtreeHasImage below): rebuilding would mangle it.
304
+ const isPlainRecord = (v: object): v is Record<string, unknown> => {
305
+ const proto: unknown = Object.getPrototypeOf(v);
306
+ return proto === Object.prototype || proto === null;
307
+ };
308
+
309
+ // The `toJSON` a value carries, or undefined. Read defensively: this runs on
310
+ // EVERY object the walks touch, and the property can be an accessor that
311
+ // throws. Answering "no hook" there is safe — JSON.stringify reads `toJSON`
312
+ // the same way and would throw too, so safeJsonText degrades and nothing
313
+ // reaches the transcript unscrubbed.
314
+ type JsonHook = (this: unknown, ...args: unknown[]) => unknown;
315
+ const toJsonHook = (v: object): JsonHook | undefined => {
316
+ try {
317
+ const hook: unknown = (v as { toJSON?: unknown }).toJSON;
318
+ return typeof hook === 'function' ? (hook as JsonHook) : undefined;
319
+ } catch {
320
+ return undefined;
321
+ }
322
+ };
323
+
324
+ // The ONE hook trusted to fire again after the scrub, so an ordinary `Date`
325
+ // keeps passing through by reference instead of collapsing to its ISO
326
+ // string. `Date.prototype.toJSON` returns null or the result of invoking the
327
+ // object's own `toISOString`, so when BOTH are the built-ins its result can
328
+ // only ever be null or a string — there is no object for an image to hide
329
+ // in, and no second call can differ in kind. (A non-Date receiver makes the
330
+ // built-in throw, which is likewise harmless.) Every other `toJSON` is user
331
+ // code and gets no such benefit of the doubt.
332
+ const isBuiltinDateHook = (v: object, hook: JsonHook): boolean => {
333
+ if (hook !== (Date.prototype.toJSON as unknown as JsonHook)) return false;
334
+ try {
335
+ return (v as { toISOString?: unknown }).toISOString === Date.prototype.toISOString;
336
+ } catch {
337
+ return false;
338
+ }
339
+ };
340
+
341
+ /** Does this object define an enumerable own property backed by a GETTER? */
342
+ const hasAccessor = (v: object): boolean => {
343
+ for (const key of Object.keys(v)) {
344
+ const d = Object.getOwnPropertyDescriptor(v, key);
345
+ if (d !== undefined && d.get !== undefined) return true;
346
+ }
347
+ return false;
348
+ };
349
+
350
+ /**
351
+ * The USER `toJSON` an object would be serialized through, or undefined.
352
+ *
353
+ * A user hook must never survive into the returned structure: the scrubbed
354
+ * result is stringified into the transcript AFTERWARDS, and a hook left live
355
+ * fires a SECOND time there — an impure one (clean when probed, an image
356
+ * payload on the next call) would hand the transcript base64 the scrub never
357
+ * saw. So every object carrying one is rebuilt from the single cached view
358
+ * below, and the rebuilt copy is a plain array/record with no hook attached.
359
+ */
360
+ const userToJson = (v: object): JsonHook | undefined => {
361
+ const hook = toJsonHook(v);
362
+ return hook !== undefined && !isBuiltinDateHook(v, hook) ? hook : undefined;
363
+ };
364
+
365
+ // A value's JSON view: what JSON.stringify would serialize for it — the
366
+ // toJSON() result when it defines one (a throwing toJSON falls back to the
367
+ // value itself, matching safeJsonText's degraded path), the value otherwise.
368
+ //
369
+ // `key` is the JSON property name the value is being serialized UNDER, and
370
+ // it is passed to the hook because JSON.stringify passes it: the spec calls
371
+ // `toJSON(key)` — '' at the root, the property name inside an object, the
372
+ // stringified index inside an array. Calling it with NO argument made a
373
+ // key-dependent hook return one thing to this scrub and a different thing
374
+ // to the transcript serializer that runs afterwards, which is precisely the
375
+ // divergence the scrub exists to close.
376
+ //
377
+ // JSON.stringify UNBOXES a primitive wrapper object (`new String(…)` /
378
+ // `new Number(…)` / `new Boolean(…)`) to its primitive before serializing.
379
+ // A boxed VIEW must get the same treatment: rebuilding `new String('abc')`
380
+ // from its enumerable keys would emit {"0":"a","1":"b","2":"c"} where JSON
381
+ // emits "abc".
382
+ const unboxed = (view: unknown): unknown => {
383
+ if (view instanceof String) return String(view);
384
+ if (view instanceof Number) return Number(view);
385
+ if (view instanceof Boolean) return view.valueOf();
386
+ return view;
387
+ };
388
+
389
+ // Cached per (object, key), not per object: the same object reachable under
390
+ // two different keys genuinely HAS two views, and one cache slot would let
391
+ // the second site emit the first site's answer. Within one (object, key)
392
+ // the hook still fires at most once, so the rebuild emits exactly the view
393
+ // it inspected even when the hook is impure.
394
+ const viewCache = new WeakMap<object, Map<string, unknown>>();
395
+ const jsonView = (v: object, key: string): unknown => {
396
+ let byKey = viewCache.get(v);
397
+ if (byKey === undefined) {
398
+ byKey = new Map<string, unknown>();
399
+ viewCache.set(v, byKey);
400
+ }
401
+ if (byKey.has(key)) return byKey.get(key);
402
+ let view: unknown = v;
403
+ const hook = userToJson(v);
404
+ if (hook !== undefined) {
405
+ try {
406
+ view = Reflect.apply(hook, v, [key]);
407
+ } catch {
408
+ /* keep the value itself */
409
+ }
410
+ }
411
+ byKey.set(key, view);
412
+ return view;
413
+ };
414
+
415
+ // Must the subtree reachable from `root` be rebuilt rather than preserved by
416
+ // reference? Two things force a rebuild, and the probe answers them in ONE
417
+ // walk because the only caller needs both: an image sentinel / spec-shaped
418
+ // image block anywhere in the shape, and any object carrying a user `toJSON`
419
+ // (which must not stay live in the output — see `userToJson`). A hook is
420
+ // reason enough on its own, so the probe never has to CALL one: it stops at
421
+ // the bearer, and an object without a hook IS its own JSON view.
422
+ //
423
+ // Iterative and cycle-safe (a visited set suffices for a boolean probe).
424
+ // Verdicts are memoized, but only the NEGATIVE one generalizes: a walk that
425
+ // finishes clean proves every object it visited clean too, since each one's
426
+ // reachable shape is a subset of what was just explored — so a nested object
427
+ // is probed once, not re-walked by every enclosing probe. A hit stops the
428
+ // walk early, which leaves the visited set half-explored and proves nothing
429
+ // about those objects (the image or hook may sit outside an inner object's
430
+ // own subtree), so only the root's verdict is recorded then.
431
+ const probeCache = new WeakMap<object, boolean>();
432
+ const subtreeNeedsRebuild = (root: object): boolean => {
433
+ const known = probeCache.get(root);
434
+ if (known !== undefined) return known;
435
+ const visited = new Set<object>();
436
+ const stack: unknown[] = [root];
437
+ let found = false;
438
+ while (stack.length > 0) {
439
+ const v = stack.pop();
440
+ if (v === null || typeof v !== 'object') continue;
441
+ if (isMcpImageResult(v) || isMcpImageBlockObject(v)) {
442
+ found = true;
443
+ break;
444
+ }
445
+ if (visited.has(v)) continue;
446
+ const cached = probeCache.get(v);
447
+ if (cached === true) {
448
+ found = true;
449
+ break;
450
+ }
451
+ visited.add(v);
452
+ if (cached === false) continue;
453
+ // An ACCESSOR is user code too, and it runs again every time the value is
454
+ // read: a getter may answer the probe with something clean and hand the
455
+ // serializer an image. Preserving such an object by reference would let
456
+ // that second answer past the scrub, so its subtree is rebuilt from the
457
+ // values read HERE, once.
458
+ if (hasAccessor(v)) {
459
+ found = true;
460
+ break;
461
+ }
462
+ if (userToJson(v) !== undefined) {
463
+ found = true;
464
+ break;
465
+ }
466
+ if (Array.isArray(v)) {
467
+ for (const entry of v) stack.push(entry);
468
+ continue;
469
+ }
470
+ for (const key of Object.keys(v)) stack.push((v as Record<string, unknown>)[key]);
471
+ }
472
+ probeCache.set(root, found);
473
+ if (!found) for (const v of visited) probeCache.set(v, false);
474
+ return found;
475
+ };
476
+
477
+ // `out[key] = …` would be a prototype-pollution hazard for keys like
478
+ // `__proto__`; defineProperty makes every rebuilt key an ordinary own data
479
+ // property (JSON.stringify then serializes it exactly like the original).
480
+ const defineKey = (out: Record<string, unknown>, key: string, val: unknown): void => {
481
+ Object.defineProperty(out, key, { value: val, enumerable: true, writable: true, configurable: true });
482
+ };
483
+
484
+ /**
485
+ * The own enumerable keys to rebuild a record from — every key
486
+ * `JSON.stringify` would visit EXCEPT a function-valued `toJSON`.
487
+ *
488
+ * A rebuilt record is a plain object, and `JSON.stringify` consults exactly
489
+ * one method on a plain object: `toJSON`. Copying that key across therefore
490
+ * re-arms the hook on the sanitized value, and the transcript serializer
491
+ * that runs after this scrub calls it — an impure hook (clean when the
492
+ * scrub probed it, an image payload on the next call) hands the transcript
493
+ * base64 the walk never saw. It is reachable through a VIEW: a `toJSON()`
494
+ * that returns `{ data: …, toJSON() { …image… } }` is rebuilt from that
495
+ * view's keys, and `toJSON` is one of them.
496
+ *
497
+ * No other function-valued own key can do this. `toISOString` matters only
498
+ * because `Date.prototype.toJSON` calls it, and a rebuilt plain object has
499
+ * `Object.prototype` — no inherited `toJSON` to reach it — so the copied
500
+ * function is simply an own property `JSON.stringify` SKIPS, as it skips
501
+ * every function value. Same for `valueOf`, `toString` and friends:
502
+ * stringify consults none of them on an object.
503
+ */
504
+ const rebuildKeys = (src: object): string[] =>
505
+ Object.keys(src).filter((k) => {
506
+ if (k !== 'toJSON') return true;
507
+ try {
508
+ return typeof (src as Record<string, unknown>)[k] !== 'function';
509
+ } catch {
510
+ return false; // a throwing accessor named toJSON: never copy it
511
+ }
512
+ });
513
+
514
+ // ITERATIVE depth-first rebuild: an explicit frame stack instead of
515
+ // recursion, so arbitrarily deep nesting cannot overflow the call stack —
516
+ // and, crucially, cannot ESCAPE the scrub the way a depth cutoff would let
517
+ // a deeply nested image reach the transcript. `active` tracks only the
518
+ // live descent (added on push, removed on pop), so real cycles become
519
+ // '[Circular]' while diamond shares rebuild normally.
520
+ type Frame =
521
+ | { kind: 'array'; src: readonly unknown[]; out: unknown[]; i: number; release: object[] }
522
+ | {
523
+ kind: 'record';
524
+ src: Record<string, unknown>;
525
+ out: Record<string, unknown>;
526
+ keys: string[];
527
+ i: number;
528
+ release: object[];
529
+ };
530
+
531
+ const active = new WeakSet<object>();
532
+
533
+ /**
534
+ * Resolve one value: a leaf to emit as-is, or a container frame to walk.
535
+ * `key` is the JSON property name `v` is serialized under — '' at the root,
536
+ * the property name in a record, the stringified index in an array — and is
537
+ * threaded through only so a `toJSON` hook receives the argument
538
+ * `JSON.stringify` would give it (see `jsonView`).
539
+ */
540
+ const resolve = (v: unknown, key: string): { leaf: unknown } | { frame: Frame } => {
541
+ if (v === null || typeof v !== 'object') return { leaf: v };
542
+ if (isMcpImageResult(v)) return { leaf: omittedNote(v.note ?? `an image (${v.mimeType})`) };
543
+ if (isMcpImageBlockObject(v)) return { leaf: omittedNote(`an image (${v.mimeType})`) };
544
+ if (active.has(v)) return { leaf: '[Circular]' };
545
+ // A user `toJSON` is handled FIRST — before the array/plain-record split,
546
+ // exactly as JSON.stringify applies it before looking at the value's
547
+ // shape. Such an object is ALWAYS rebuilt from its one cached view, never
548
+ // preserved by reference and never rebuilt from its own keys: leaving the
549
+ // hook live (or copying it across as a `toJSON` property of a rebuilt
550
+ // record) would let it fire again during transcript serialization and
551
+ // return a payload this scrub never inspected.
552
+ const hook = userToJson(v);
553
+ if (hook !== undefined) {
554
+ // Unboxing first mirrors stringify's own order: a hook returning a boxed
555
+ // primitive serializes as that primitive, never as a record of index keys.
556
+ const view = unboxed(jsonView(v, key));
557
+ // The view can BE the image shape — a toJSON() returning a sentinel or
558
+ // image block directly. Scrub it here: falling through would build a
559
+ // record frame from the view and copy its base64 `data` field, key by
560
+ // key, straight into the rebuilt result.
561
+ if (isMcpImageResult(view)) return { leaf: omittedNote(view.note ?? `an image (${view.mimeType})`) };
562
+ if (isMcpImageBlockObject(view)) return { leaf: omittedNote(`an image (${view.mimeType})`) };
563
+ if (view === null || typeof view !== 'object') return { leaf: view };
564
+ if (active.has(view)) return { leaf: '[Circular]' };
565
+ active.add(v);
566
+ const release: object[] = view === (v as unknown) ? [v] : [v, view];
567
+ if (view !== (v as unknown)) active.add(view);
568
+ if (Array.isArray(view)) {
569
+ return { frame: { kind: 'array', src: view, out: new Array<unknown>(view.length), i: 0, release } };
570
+ }
571
+ return {
572
+ frame: {
573
+ kind: 'record',
574
+ src: view as Record<string, unknown>,
575
+ out: {},
576
+ keys: rebuildKeys(view),
577
+ i: 0,
578
+ release,
579
+ },
580
+ };
581
+ }
582
+ if (Array.isArray(v)) {
583
+ active.add(v);
584
+ return { frame: { kind: 'array', src: v, out: new Array<unknown>(v.length), i: 0, release: [v] } };
585
+ }
586
+ if (isPlainRecord(v)) {
587
+ active.add(v);
588
+ return { frame: { kind: 'record', src: v, out: {}, keys: rebuildKeys(v), i: 0, release: [v] } };
589
+ }
590
+ // Non-plain object with no hook of its own: it IS its own JSON view, so it
591
+ // passes through untouched (by reference) unless the shape below it needs
592
+ // rebuilding — an image to scrub, or a nested object whose user `toJSON`
593
+ // must not stay live in the result. Only then is a plain sanitized copy
594
+ // built from its enumerable own properties, exactly what JSON.stringify
595
+ // would have emitted.
596
+ if (!subtreeNeedsRebuild(v)) return { leaf: v };
597
+ active.add(v);
598
+ return {
599
+ frame: {
600
+ kind: 'record',
601
+ src: v as Record<string, unknown>,
602
+ out: {},
603
+ keys: rebuildKeys(v),
604
+ i: 0,
605
+ release: [v],
606
+ },
607
+ };
608
+ };
609
+
610
+ // '' is the root key JSON.stringify uses for a standalone value (it
611
+ // serializes through a synthetic wrapper `{ '': value }`); a caller that
612
+ // embeds the scrubbed value under an envelope key passes that key instead
613
+ // (see `rootKey` above).
614
+ const seed = resolve(value, rootKey);
615
+ if ('leaf' in seed) return seed.leaf;
616
+ const stack: Frame[] = [seed.frame];
617
+ while (stack.length > 0) {
618
+ const top = stack[stack.length - 1];
619
+ if (top.kind === 'array') {
620
+ if (top.i >= top.src.length) {
621
+ for (const held of top.release) active.delete(held);
622
+ stack.pop();
623
+ continue;
624
+ }
625
+ const idx = top.i++;
626
+ // An array element's JSON key is its stringified index — what
627
+ // JSON.stringify hands the element's own `toJSON`.
628
+ const r = resolve(top.src[idx], String(idx));
629
+ if ('leaf' in r) {
630
+ top.out[idx] = r.leaf;
631
+ } else {
632
+ // The child's `out` fills in place as its frame drains.
633
+ top.out[idx] = r.frame.out;
634
+ stack.push(r.frame);
635
+ }
636
+ } else {
637
+ if (top.i >= top.keys.length) {
638
+ for (const held of top.release) active.delete(held);
639
+ stack.pop();
640
+ continue;
641
+ }
642
+ const key = top.keys[top.i++];
643
+ const r = resolve(top.src[key], key);
644
+ if ('leaf' in r) {
645
+ defineKey(top.out, key, r.leaf);
646
+ } else {
647
+ defineKey(top.out, key, r.frame.out);
648
+ stack.push(r.frame);
649
+ }
650
+ }
651
+ }
652
+ return seed.frame.out;
653
+ }
654
+
106
655
  export function renderProgress(chunk: unknown): string {
107
656
  const s = typeof chunk === 'string' ? chunk : safeJsonText(chunk);
108
657
  return s.length > 500 ? s.slice(0, 497) + '...' : s;