@ggui-ai/mcp-server 0.6.3 → 0.8.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.
Files changed (42) hide show
  1. package/dist/api-renders-routes.d.ts.map +1 -1
  2. package/dist/api-renders-routes.js +9 -1
  3. package/dist/browser-cors.d.ts +29 -0
  4. package/dist/browser-cors.d.ts.map +1 -0
  5. package/dist/browser-cors.js +64 -0
  6. package/dist/build-mcp.d.ts.map +1 -1
  7. package/dist/build-mcp.js +5 -1
  8. package/dist/code-store-fs.d.ts +3 -0
  9. package/dist/code-store-fs.d.ts.map +1 -1
  10. package/dist/code-store-fs.js +27 -3
  11. package/dist/console-session-routes.d.ts +10 -5
  12. package/dist/console-session-routes.d.ts.map +1 -1
  13. package/dist/console-session-routes.js +10 -5
  14. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  15. package/dist/ggui-session-channel/outbound.js +12 -0
  16. package/dist/ggui-session-channel/socket-router.d.ts +33 -0
  17. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  18. package/dist/ggui-session-channel/socket-router.js +155 -0
  19. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  20. package/dist/ggui-session-channel/subscribe.js +43 -2
  21. package/dist/ggui-session-channel.d.ts +114 -0
  22. package/dist/ggui-session-channel.d.ts.map +1 -1
  23. package/dist/ggui-session-channel.js +77 -2
  24. package/dist/health-routes.d.ts +3 -0
  25. package/dist/health-routes.d.ts.map +1 -1
  26. package/dist/health-routes.js +11 -1
  27. package/dist/mcp-apps-outbound.d.ts +236 -31
  28. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  29. package/dist/mcp-apps-outbound.js +940 -261
  30. package/dist/origin-validation.d.ts +120 -0
  31. package/dist/origin-validation.d.ts.map +1 -0
  32. package/dist/origin-validation.js +199 -0
  33. package/dist/render-read-gate.d.ts +50 -0
  34. package/dist/render-read-gate.d.ts.map +1 -0
  35. package/dist/render-read-gate.js +36 -0
  36. package/dist/runtime-bundle-route.d.ts +15 -0
  37. package/dist/runtime-bundle-route.d.ts.map +1 -1
  38. package/dist/runtime-bundle-route.js +20 -4
  39. package/dist/server.d.ts +227 -9
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +292 -28
  42. package/package.json +12 -12
@@ -31,12 +31,14 @@
31
31
  * build target — per the design lock, the shell is served by the
32
32
  * same `@ggui-ai/mcp-server` instance that mints the bootstrap.
33
33
  */
34
- import { deriveBundleOrigins, deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, findBlueprintExact, } from "@ggui-ai/mcp-server-handlers/renders";
35
- import { deriveContextDefault, isRecord } from "@ggui-ai/protocol";
36
- import { GGUI_RENDER_RESOURCE_MIME, GGUI_RENDER_RESOURCE_URI, GGUI_RENDER_SHELL_SURFACE, MCP_APPS_UI_CAPABILITY, MCP_APP_BOOTSTRAP_FAILED_TYPE, asGguiRenderBootstrap, deriveContextName, gguiShellHtml, toMcpAppEnvelope, } from "@ggui-ai/protocol/integrations/mcp-apps";
34
+ import { deriveBundleOrigins, deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, filterDescriptorsToContract, findBlueprintExact, } from "@ggui-ai/mcp-server-handlers/renders";
35
+ import { RESOURCE_NOT_FOUND_MESSAGE, deriveContextDefault, isRecord, resolveAppGadgets, resourceReadErrorToJsonRpc, } from "@ggui-ai/protocol";
36
+ import { GGUI_RENDER_RESOURCE_MIME, GGUI_RENDER_RESOURCE_URI, GGUI_RENDER_SHELL_SURFACE, MCP_APPS_UI_CAPABILITY, MCP_APP_BOOTSTRAP_FAILED_TYPE, asGguiRenderBootstrap, deriveContextName, escapeInlineScript, gguiShellHtml, toMcpAppEnvelope, } from "@ggui-ai/protocol/integrations/mcp-apps";
37
37
  import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
38
38
  import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
39
39
  import { createHash } from "node:crypto";
40
+ import { renderReadAllowed } from "./render-read-gate.js";
41
+ import { DEFAULT_BUILDER_APP_ID } from "./auth.js";
40
42
  /**
41
43
  * Thin-shell body served from `ui://ggui/render` (C8 pivot).
42
44
  *
@@ -314,7 +316,7 @@ postRpc('ui/initialize',{
314
316
  // Value-resolution only — no `--ggui-*` token added or renamed. The
315
317
  // constant itself lives with the protocol host-helper (the shared
316
318
  // self-contained-shell assembler paints the same surface); imported
317
- // above and reused here for the thin postMessage shell + loading shell.
319
+ // above and reused here for the thin postMessage shell.
318
320
  // `#ggui-root` here is LOAD-BEARING for the shell script (NOT a React
319
321
  // mount target): the inline script grabs it as `rootEl` for the
320
322
  // pre-mount overlays ("Initializing…", "Waiting for tool result…",
@@ -371,6 +373,70 @@ export const GGUI_RENDER_SHELL_HTML = `<!doctype html>
371
373
  export const GGUI_RENDER_SHELL_SCRIPT_HASH = `'sha256-${createHash("sha256")
372
374
  .update(GGUI_RENDER_SHELL_SCRIPT_BODY)
373
375
  .digest("base64")}'`;
376
+ /**
377
+ * Bootstrap `<script>` body of the INLINE-RUNTIME shell (see
378
+ * {@link buildInlineRenderShellHtml}). Runs BEFORE the (large) inline
379
+ * runtime module parses and does exactly two things:
380
+ *
381
+ * 1. Installs the `window.__GGUI_PENDING_TOOL_RESULTS__` buffer the
382
+ * iframe-runtime's autostart drains (`readPendingToolResults`
383
+ * contract: an array whose elements are the RAW
384
+ * `ui/notifications/tool-result` JSON-RPC `params` values, arrival
385
+ * order, capped so a long-lived host session cannot grow it
386
+ * unboundedly).
387
+ * 2. Sends the `ui/initialize` → `ui/notifications/initialized`
388
+ * preflight. Load-bearing against a mutual 30s stall: spec hosts
389
+ * gate tool-result delivery BEHIND the handshake, while the
390
+ * runtime's autostart waits for a tool-result before its own
391
+ * handshake runs. The runtime repeats `ui/initialize` when it
392
+ * boots; MCP Apps hosts handle the repeat idempotently (same
393
+ * preflight pattern as the thin shell above).
394
+ *
395
+ * No overlay, no meta inspection, no runtime loading — the runtime is
396
+ * inline in the same document and owns everything else.
397
+ */
398
+ const GGUI_INLINE_SHELL_BUFFER_SCRIPT_BODY = `
399
+ (function(){'use strict';
400
+ var buf=window.__GGUI_PENDING_TOOL_RESULTS__=window.__GGUI_PENDING_TOOL_RESULTS__||[];
401
+ window.addEventListener('message',function(ev){
402
+ var m=ev&&ev.data;
403
+ if(!m||m.jsonrpc!=='2.0'||m.method!=='ui/notifications/tool-result')return;
404
+ buf.push(m.params);
405
+ if(buf.length>8)buf.splice(0,buf.length-8);
406
+ });
407
+ try{
408
+ window.parent.postMessage({jsonrpc:'2.0',id:'ggui-inline-preflight',method:'ui/initialize',params:{appCapabilities:{},appInfo:{name:'ggui-render',version:'1.0.0'},protocolVersion:'2026-01-26'}},'*');
409
+ window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}},'*');
410
+ }catch(e){}
411
+ })();
412
+ `;
413
+ /**
414
+ * Build the INLINE-RUNTIME static shell: a standalone document carrying
415
+ * the iframe-runtime bundle in its own bytes instead of an external
416
+ * `<script src>` tag. For MCP Apps hosts whose iframe CSP forbids
417
+ * external `script-src` while permitting inline scripts — the thin
418
+ * postMessage shell can never load its runtime there, so the shell IS
419
+ * the runtime.
420
+ *
421
+ * Per-render state does NOT live here (same posture as the thin
422
+ * shell): the host delivers it via `ui/notifications/tool-result`,
423
+ * caught either by the buffer script (pre-parse arrivals) or by the
424
+ * runtime's own autostart listener. Live-channel / codeUrl fetches are
425
+ * unavailable under the CSP this shell targets; delivered meta is
426
+ * expected to carry the fetch-free channels (inline `codeB64`, inline
427
+ * `propsJson`).
428
+ *
429
+ * Served per-mount via `installMcpAppsOutbound({ shellHtml })` — the
430
+ * module-level thin-shell constants (and their pinned CSP hash) are
431
+ * deliberately untouched.
432
+ */
433
+ export function buildInlineRenderShellHtml(runtimeSource) {
434
+ return `<!doctype html>
435
+ <html lang="en" style="height:100%;background-color:${GGUI_RENDER_SHELL_SURFACE}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light dark"><title>ggui render</title></head>
436
+ <body style="margin:0;height:100%;min-height:480px;background-color:${GGUI_RENDER_SHELL_SURFACE}"><div id="ggui-root" data-ggui-shell="inline" style="height:100%;min-height:480px"></div>
437
+ <script>${GGUI_INLINE_SHELL_BUFFER_SCRIPT_BODY}</script>
438
+ <script type="module" data-ggui-runtime="inline">${escapeInlineScript(runtimeSource)}</script></body></html>`;
439
+ }
374
440
  /**
375
441
  * Register `ui://ggui/render` as a readable resource on an `McpServer`.
376
442
  *
@@ -439,40 +505,21 @@ runtimeUrl) {
439
505
  return undefined;
440
506
  }
441
507
  }
442
- export function registerGguiRenderResource(server, shellHtml = GGUI_RENDER_SHELL_HTML, publicBaseUrl) {
443
- let cspMeta;
444
- if (publicBaseUrl) {
445
- try {
446
- const parsed = new URL(publicBaseUrl);
447
- const origin = parsed.origin;
448
- // CSP `connect-src` does NOT cross-translate between `https://`
449
- // and `wss://` — they're independent URL schemes for the
450
- // browser's URL-match algorithm. Declaring ONLY the HTTPS
451
- // origin will leave WebSocket subscribes (`wss://<same-host>/ws`)
452
- // blocked by hosts that compose strict CSPs from this
453
- // `connectDomains` list (claude.ai's iframe is the live
454
- // diagnosis case). Declare BOTH schemes so the same physical
455
- // origin is reachable via HTTPS (`/api/bootstrap`, `/_ggui/
456
- // iframe-runtime.js`) AND wss (live-channel subscribe).
457
- const wsScheme = parsed.protocol === "https:" ? "wss:" : "ws:";
458
- const wsOrigin = `${wsScheme}//${parsed.host}`;
459
- cspMeta = {
460
- ui: {
461
- csp: {
462
- connectDomains: [origin, wsOrigin],
463
- resourceDomains: [origin],
464
- },
465
- },
466
- };
467
- }
468
- catch {
469
- // Malformed `publicBaseUrl` — leave `_meta.ui.csp` off rather
470
- // than emitting a broken declaration. The host falls back to its
471
- // restrictive default and operators get the same observable
472
- // failure they'd get from any other malformed URL setting.
473
- cspMeta = undefined;
474
- }
475
- }
508
+ export function registerGguiRenderResource(server, shellHtml = GGUI_RENDER_SHELL_HTML, publicBaseUrl,
509
+ /**
510
+ * Absolute runtime-bundle URL used as the CSP-declaration fallback
511
+ * when `publicBaseUrl` is absent — same posture as the per-render
512
+ * template registration ({@link buildCspMeta}'s second parameter).
513
+ * Deployments that publish an absolute `runtime.url` but
514
+ * deliberately do NOT set `publicBaseUrl` (it also feeds
515
+ * Origin/Host enforcement and OAuth) still get
516
+ * `_meta.ui.csp.{connectDomains,resourceDomains}` on the static
517
+ * resource read; before this fallback those reads carried no
518
+ * declaration at all and spec-compliant hosts applied the
519
+ * restrictive default (`connect-src 'none'`).
520
+ */
521
+ runtimeUrl) {
522
+ const cspMeta = buildCspMeta(publicBaseUrl, runtimeUrl);
476
523
  // `registerAppResource` (from `@modelcontextprotocol/ext-apps/server`)
477
524
  // defaults `mimeType` to `RESOURCE_MIME_TYPE` — the same
478
525
  // `text/html;profile=mcp-app` value `GGUI_RENDER_RESOURCE_MIME`
@@ -547,12 +594,13 @@ export function buildSelfContainedShell(opts) {
547
594
  // picks per its priority order.
548
595
  const isSystem = typeof opts.systemKind === "string" && opts.systemKind.length > 0;
549
596
  const hasCodeUrl = typeof opts.codeUrl === "string" && opts.codeUrl.length > 0;
597
+ const hasCodeB64 = typeof opts.codeB64 === "string" && opts.codeB64.length > 0;
550
598
  const hasLive = typeof opts.wsUrl === "string" &&
551
599
  opts.wsUrl.length > 0 &&
552
600
  typeof opts.token === "string" &&
553
601
  opts.token.length > 0;
554
- if (!isSystem && !hasCodeUrl && !hasLive) {
555
- throw new Error("buildSelfContainedShell: at least one of `codeUrl`, `systemKind`, or live-mode (`wsUrl` + `token`) must be set");
602
+ if (!isSystem && !hasCodeUrl && !hasCodeB64 && !hasLive) {
603
+ throw new Error("buildSelfContainedShell: at least one of `codeUrl`, `codeB64`, `systemKind`, or live-mode (`wsUrl` + `token`) must be set");
556
604
  }
557
605
  // Build the single render slice (Phase B: ai.ggui/render collapsed
558
606
  // the prior ai.ggui/session + ai.ggui/stack-item pair into one flat
@@ -613,6 +661,7 @@ export function buildSelfContainedShell(opts) {
613
661
  ...(opts.codeHash !== undefined ? { codeHash: opts.codeHash } : {}),
614
662
  }
615
663
  : {}),
664
+ ...(!isSystem && hasCodeB64 ? { codeB64: opts.codeB64 } : {}),
616
665
  ...(opts.propsJson !== undefined ? { propsJson: opts.propsJson } : {}),
617
666
  ...(opts.contextSlots !== undefined && opts.contextSlots.length > 0
618
667
  ? { contextSlots: opts.contextSlots }
@@ -647,32 +696,104 @@ export function buildSelfContainedShell(opts) {
647
696
  return gguiShellHtml(bootstrap, { background: "surface" });
648
697
  }
649
698
  /**
650
- * Minimal "loading" HTML served when a per-render resource is fetched
651
- * for a render whose visible-bits surface has no componentCode yet
652
- * (placeholder, generation in flight). Renders a tiny status surface
653
- * so hosts that pin lifecycle selectors don't see a blank document.
699
+ * A `resources/read` on a render locator that cannot return a mount.
654
700
  *
655
- * Hosts SHOULD re-fetch when they observe additional `ggui_render`
656
- * results on the same render — the per-call `_meta.ui.resourceUri`
657
- * value stays stable across commits for a render, so re-fetching the
658
- * same URI returns fresher HTML on the second try.
701
+ * Thrown rather than returned: the MCP transport turns a thrown error
702
+ * carrying a numeric `code` into a JSON-RPC error response, and a
703
+ * JSON-RPC error is the only exit from the handler that is not a
704
+ * successful result. That is what makes "any successful `contents`
705
+ * result IS a mountable shell" checkable by a host instead of a claim
706
+ * in a docstring.
659
707
  *
660
- * @public
708
+ * The wire body comes from {@link resourceReadErrorToJsonRpc} and
709
+ * nowhere else. Routing every branch through the protocol's projection
710
+ * is what keeps a refused read and a read of a locator that never
711
+ * existed byte-identical: the projection substitutes a constant message
712
+ * and drops `detail` on `NOT_FOUND`, so no call site can leak a
713
+ * diagnostic by writing a more helpful message.
661
714
  */
662
- export function buildSelfContainedLoadingShell(sessionId) {
663
- // Standalone served-iframe loading document — same surface-backdrop
664
- // paint + gating as the thin/self-contained shells so the canvas is
665
- // dark while the render is still in flight (no Safari white flash).
666
- return `<!doctype html>
667
- <html lang="en" style="background-color:${GGUI_RENDER_SHELL_SURFACE}"><head><meta charset="utf-8"><title>ggui render</title></head>
668
- <body style="background-color:${GGUI_RENDER_SHELL_SURFACE}">
669
- <div id="ggui-root" data-ggui-shell="loading" data-ggui-session-id="${sessionId
670
- .replace(/&/g, "&amp;")
671
- .replace(/"/g, "&quot;")
672
- .replace(/</g, "&lt;")
673
- .replace(/>/g, "&gt;")}">Generating UI…</div>
674
- </body></html>`;
715
+ class ResourceReadFailure extends Error {
716
+ /** JSON-RPC error number — read off this instance by the transport. */
717
+ code;
718
+ /** JSON-RPC `error.data` — the closed classification plus optional detail. */
719
+ data;
720
+ constructor(failure) {
721
+ const wire = resourceReadErrorToJsonRpc(failure);
722
+ super(wire.message);
723
+ this.name = "ResourceReadFailure";
724
+ this.code = wire.code;
725
+ this.data = wire.data;
726
+ }
675
727
  }
728
+ /**
729
+ * The terminal failure for a read that resolved nothing, on a server
730
+ * that DOES keep durable records. One constant, so the branches that
731
+ * must stay indistinguishable — a locator that never existed, one whose
732
+ * record is gone, and one the caller may not read — have no shape in
733
+ * which to differ. The message is stated rather than left blank because
734
+ * the projection substitutes it anyway; writing it makes the call sites
735
+ * honest about what goes on the wire.
736
+ */
737
+ const NOT_FOUND_FAILURE = {
738
+ code: "NOT_FOUND",
739
+ message: RESOURCE_NOT_FOUND_MESSAGE,
740
+ };
741
+ /**
742
+ * The terminal failure for the same reads on a server that keeps no
743
+ * durable record. It replaces {@link NOT_FOUND_FAILURE} for EVERY
744
+ * locator on such a server, never for some of them: it describes the
745
+ * server, so a read that answered it for a missing locator and
746
+ * `NOT_FOUND` for an existing-but-refused one would tell a caller which
747
+ * locators exist — the disclosure the access check exists to prevent.
748
+ */
749
+ const NOT_SUPPORTED_FAILURE = {
750
+ code: "NOT_SUPPORTED",
751
+ message: "This server keeps no durable record of a render, so a locator whose render is gone cannot be restored.",
752
+ };
753
+ /**
754
+ * The three ways a resolved locator still yields nothing to mount. One
755
+ * caller-facing message per code, with `detail` carrying the
756
+ * discrimination — a host's decision is the same for all three, while
757
+ * an operator needs to know which link is missing.
758
+ */
759
+ const notMountable = (detail) => ({
760
+ code: "NOT_MOUNTABLE",
761
+ message: "This locator resolved, but nothing mountable can be produced for it.",
762
+ detail,
763
+ });
764
+ /** Neither delivery channel is wired, so a component cannot reach an iframe. */
765
+ const NO_DELIVERY_CHANNEL_FAILURE = notMountable("no static component URL and no live channel is wired");
766
+ /** The row exists and is the caller's, but its generation has not landed. */
767
+ const NOT_YET_COMMITTED_FAILURE = notMountable("the render has not committed a component yet");
768
+ /** A blueprint matched the locator's key, but its code cannot be delivered. */
769
+ const BLUEPRINT_UNDELIVERABLE_FAILURE = notMountable("the matched blueprint has no delivery channel");
770
+ /**
771
+ * The four ways a durable record stops short of a component. Same
772
+ * shape and the same reasoning as {@link notMountable} above. All four
773
+ * are reachable only after the access check has passed, so naming the
774
+ * step discloses nothing about a locator the caller cannot read.
775
+ */
776
+ const blueprintUnresolvable = (detail) => ({
777
+ code: "BLUEPRINT_UNRESOLVABLE",
778
+ message: "The record for this locator no longer resolves to a component.",
779
+ detail,
780
+ });
781
+ /**
782
+ * The single value returned for BOTH "no record was ever written" and
783
+ * "the caller is not entitled to this record". One shared constant
784
+ * rather than two equal literals: the two branches cannot drift apart
785
+ * later, because there is only one of them to change.
786
+ */
787
+ const RECORD_UNAVAILABLE = { ok: false, failure: NOT_FOUND_FAILURE };
788
+ /**
789
+ * Retention this read path assumes when the operator names none —
790
+ * deliberately the same hour `ggui_render` falls back to, so a
791
+ * deployment that has never set the knob gets one answer rather than
792
+ * two. Every use of it is behind
793
+ * {@link GguiRenderResourceTemplateOptions.renderTtlMs}, which is where
794
+ * a real deployment's retention comes from.
795
+ */
796
+ const FALLBACK_RENDER_TTL_MS = 60 * 60 * 1000;
676
797
  function pickComponentFromGguiSession(render) {
677
798
  if (!render)
678
799
  return null;
@@ -714,11 +835,79 @@ function pickComponentFromGguiSession(render) {
714
835
  * legacy postMessage shell at the static URI, self-contained shell at
715
836
  * the templated URI.
716
837
  *
717
- * Failure modes:
718
- * - GguiSession not found → loading shell (host re-fetches; absent
719
- * render is a transient state immediately after `ggui_render`).
720
- * - GguiSession found, no componentCode yet → loading shell.
721
- * - GguiSession found, componentCode present → self-contained shell.
838
+ * # The obligation
839
+ *
840
+ * A read returns EITHER a shell carrying mount material — a static
841
+ * component URL, a live channel, or a system card — OR exactly one
842
+ * typed JSON-RPC error. There is no third outcome, and in particular no
843
+ * successful result wrapping a shell that can never paint anything. A
844
+ * host can check this without trusting the server: if it got
845
+ * `contents`, it got something mountable.
846
+ *
847
+ * The obligation covers outcomes this server can DECIDE. A malfunction
848
+ * still reaches the caller as an internal error (`-32603`) carrying
849
+ * none of the four codes: a template wired with an empty `runtimeUrl`,
850
+ * or a delivery channel that faults **and leaves the read with no other
851
+ * channel to mount through**. A fault the read survives — the usual
852
+ * case on a deployment wiring both channels — is not an outcome at all;
853
+ * the render mounts through whatever is left. That split is deliberate:
854
+ * `-32603` says "something is broken here", which the four codes must
855
+ * never be diluted into claiming, and equally must not be raised over a
856
+ * blip the server routed around.
857
+ *
858
+ * Three resolutions are tried, in this order, and any of them can
859
+ * produce the mount:
860
+ *
861
+ * 1. The render row itself.
862
+ * 2. A re-mint from the durable identity record, when the row is gone
863
+ * and this server keeps one ({@link GguiRenderResourceTemplateOptions.renderIdentityStore}
864
+ * + {@link GguiRenderResourceTemplateOptions.durableBlueprints}).
865
+ * 3. The blueprint registry, keyed by the locator's own `blueprintKey`
866
+ * — the original component with its authoring-time defaults rather
867
+ * than the state the render last held.
868
+ *
869
+ * # Failure modes
870
+ *
871
+ * Which code a given read produces is fully predictable from two facts:
872
+ * whether this server binds a durable substrate (both
873
+ * {@link GguiRenderResourceTemplateOptions.renderIdentityStore} and
874
+ * {@link GguiRenderResourceTemplateOptions.durableBlueprints} — either
875
+ * one alone is as good as neither), and how far the read got.
876
+ *
877
+ * - `NOT_FOUND` (`-32002`) — nothing resolved the locator, **and** the
878
+ * response for a caller who may not read a locator that DOES
879
+ * resolve. Those two are byte-identical by construction: both route
880
+ * through the protocol's projection, which substitutes a constant
881
+ * message and drops `detail`. A distinguishable refusal would turn
882
+ * this read into an oracle for the existence of other callers'
883
+ * renders, so the equality is a security property, not a courtesy.
884
+ * - `NOT_SUPPORTED` (`-32006`) — the same two cases, on a server with
885
+ * no durable substrate: an evicted locator can never be restored
886
+ * here, so saying "not found" would understate it. It takes
887
+ * `NOT_FOUND`'s place WHOLESALE on such a server rather than
888
+ * answering for some locators and not others — a server that said
889
+ * `NOT_FOUND` for a row it refused and `NOT_SUPPORTED` for one that
890
+ * never existed would have rebuilt the same oracle out of the two
891
+ * codes. Correspondingly, a server that DOES bind the substrate
892
+ * never emits it.
893
+ * - `BLUEPRINT_UNRESOLVABLE` (`-32006`) — a record named the render
894
+ * but its component is gone; `detail` names which link broke.
895
+ * Reachable only after the access check.
896
+ * - `NOT_MOUNTABLE` (`-32006`) — something resolved, but nothing
897
+ * mountable can be produced from it: no delivery channel is wired
898
+ * (neither {@link GguiRenderResourceTemplateOptions.codeStore} nor
899
+ * {@link GguiRenderResourceTemplateOptions.mintWsToken}), the row's
900
+ * generation has not committed a component yet, or a registry
901
+ * blueprint matched but cannot be delivered. Unlike the pair above,
902
+ * this one does NOT vary with the substrate: it is what the
903
+ * caller's OWN row gets on every server, because "this server
904
+ * cannot rehydrate" is not the useful truth for a row that is
905
+ * sitting right there. Reachable only after the access check, or —
906
+ * for the registry case — off the caller's own supplied key, so it
907
+ * discloses nothing either way.
908
+ *
909
+ * A URI matching neither template never reaches any of this: the
910
+ * transport rejects it as invalid params, outside these four codes.
722
911
  *
723
912
  * Returns nothing; mutates the server in place.
724
913
  *
@@ -730,16 +919,20 @@ export function registerGguiRenderResourceTemplate(server, opts) {
730
919
  // 1. Single-segment legacy URI — `ui://ggui/render/{sessionId}`.
731
920
  // Pre-resume-contract chats in claude.ai's history persisted
732
921
  // this shape; we keep the registration so historical messages
733
- // still rehydrate (loading shell on render miss).
922
+ // still rehydrate.
734
923
  //
735
924
  // 2. Two-segment resume URI — `ui://ggui/render/{sessionId}/
736
925
  // {blueprintKey}`. Stamped by every render since the resume
737
- // contract landed. Carries enough state for the handler to do:
738
- // (a) parallel render + blueprint registry lookup (no data
739
- // dependency between them), (b) registry-only fallback when
740
- // the render is gone but the blueprint is still cached
741
- // (renders the original card with default props/context
742
- // instead of the dead loading shell).
926
+ // contract landed. Carries enough state for the handler to
927
+ // fall back to a registry-only render when the render is gone
928
+ // but the blueprint is still cached (the original card with
929
+ // default props/context instead of a typed failure).
930
+ //
931
+ // Both shapes ALSO re-mint from a durable identity record when
932
+ // the deployment keeps one — that path is keyed by sessionId
933
+ // alone and needs no blueprintKey, so it serves the legacy
934
+ // shape too. Every lookup either shape triggers happens after
935
+ // the access check, never in parallel with it.
743
936
  const legacyTemplate = new ResourceTemplate(`${GGUI_RENDER_RESOURCE_URI}/{sessionId}`, {
744
937
  // No list-callback — the resource set is unbounded per render
745
938
  // count, and `resources/list` would leak render ids across
@@ -795,7 +988,583 @@ export function registerGguiRenderResourceTemplate(server, opts) {
795
988
  },
796
989
  ],
797
990
  });
798
- const loadingShell = (uri, sessionId) => shellContents(uri, buildSelfContainedLoadingShell(sessionId));
991
+ /**
992
+ * The three stores a re-mint needs, or `null` when this server keeps
993
+ * no durable record of a render.
994
+ *
995
+ * Read through ONE accessor because two callers depend on the same
996
+ * answer: the re-mint itself, and the handler's choice of terminal
997
+ * failure. Deriving that choice separately is how a server could end
998
+ * up answering `NOT_SUPPORTED` for some locators and `NOT_FOUND` for
999
+ * others, which is a disclosure rather than a cosmetic difference.
1000
+ */
1001
+ function durableSubstrate() {
1002
+ const identityStore = opts.renderIdentityStore;
1003
+ const blueprintStore = opts.durableBlueprints?.blueprintStore;
1004
+ const bodyStore = opts.durableBlueprints?.codeStore;
1005
+ if (!identityStore || !blueprintStore || !bodyStore)
1006
+ return null;
1007
+ // #457 — bound is not enough: every member must DECLARE durability.
1008
+ // An operator binding three in-memory stores must get NOT_SUPPORTED
1009
+ // (an honest "keeps no durable record"), not a NOT_FOUND that
1010
+ // promises restorability a restart erases.
1011
+ if (identityStore.durability !== "durable" ||
1012
+ blueprintStore.durability !== "durable" ||
1013
+ bodyStore.durability !== "durable") {
1014
+ return null;
1015
+ }
1016
+ return { identityStore, blueprintStore, bodyStore };
1017
+ }
1018
+ /** Operator retention, or the shared fallback. Read in both places. */
1019
+ const renderTtlMs = opts.renderTtlMs ?? FALLBACK_RENDER_TTL_MS;
1020
+ /**
1021
+ * Give a row that has outlived its `expiresAt` a full lifetime again.
1022
+ *
1023
+ * A store may keep a row readable past its expiry — the reaper runs
1024
+ * on its own schedule, and some stamp the deletion deadline with a
1025
+ * grace window on top. A read landing in that window is a caller
1026
+ * entitled to the row proving they are actively using it, which is
1027
+ * the same signal every other touch-and-extend path acts on, so the
1028
+ * row gets its lifetime back.
1029
+ *
1030
+ * The live-channel token is the SHARPEST case, not the only one: a
1031
+ * read that mints one turns "the row dies soon" into "the thing I
1032
+ * was just handed outlives what it points at". But a deployment with
1033
+ * no minter wired reaches this too, and its row earns the same
1034
+ * extension — the read was just as real. Widening this to any active
1035
+ * use is why the call site sits ahead of the mint rather than inside
1036
+ * it.
1037
+ *
1038
+ * CONDITIONAL, and that is the load-bearing half: a resource read is
1039
+ * the hottest path this handler has, so extending a row that is
1040
+ * already live would put a store write on every read of every render
1041
+ * for nothing.
1042
+ *
1043
+ * A failure does not fail the read. The caller owns this render and
1044
+ * the handler can mount it; refusing over an extension would turn a
1045
+ * store's bad moment into a dead card, and the pre-extension
1046
+ * behavior — a token that may outlive its row — is what the read
1047
+ * would have done anyway. It is logged rather than swallowed,
1048
+ * because a row that could not be extended is now living on borrowed
1049
+ * time and nothing else will say so.
1050
+ *
1051
+ * One asymmetry worth knowing about: this moves the row's OWN
1052
+ * `expiresAt`, and the render payload stored inside it keeps the
1053
+ * value stamped at the last commit. Two spellings of one fact,
1054
+ * briefly disagreeing. Which one a reader sees is the store's
1055
+ * business — a store that re-projects lifecycle columns onto the
1056
+ * payload heals it on the very next read, one that stores the payload
1057
+ * verbatim carries the stale copy until the next commit. The
1058
+ * authoritative field is the row's, which is what the lifecycle
1059
+ * gates and the reaper both read; the payload's copy is a projection
1060
+ * for the wire. Extending both here would mean rewriting the payload
1061
+ * on a read, which is a much larger write for a field nothing gates
1062
+ * on.
1063
+ */
1064
+ /**
1065
+ * #457 — the row's total-lifetime ceiling, or undefined when the
1066
+ * operator set none / the row predates the createdAt column (the
1067
+ * legacy projection is 0, and a cap keyed on that sentinel would
1068
+ * mass-kill pre-column rows).
1069
+ */
1070
+ function lifetimeCapFor(subject) {
1071
+ if (opts.maxRenderLifetimeMs === undefined)
1072
+ return undefined;
1073
+ if (subject.createdAt === 0)
1074
+ return undefined;
1075
+ return subject.createdAt + opts.maxRenderLifetimeMs;
1076
+ }
1077
+ async function extendExpiredRow(sessionId, stored) {
1078
+ const now = Date.now();
1079
+ if (stored.expiresAt > now)
1080
+ return;
1081
+ // #457 — resurrection is bounded. Past the cap: serve the read
1082
+ // (a mount is not retention) but extend nothing — the row reaps
1083
+ // at its standing deadline. Under the cap: extend, clamped to it.
1084
+ // Either clamp branch logs; successful UNCAPPED extension stays
1085
+ // silent as before, so the one new line is the bounded-retention
1086
+ // signal, not a metrics build-out.
1087
+ const cap = lifetimeCapFor(stored);
1088
+ if (cap !== undefined && now >= cap) {
1089
+ opts.logger?.warn("render_resource_ttl_extend_capped", {
1090
+ sessionId,
1091
+ createdAt: stored.createdAt,
1092
+ cap,
1093
+ });
1094
+ return;
1095
+ }
1096
+ const target = cap !== undefined ? Math.min(now + renderTtlMs, cap) : now + renderTtlMs;
1097
+ try {
1098
+ await opts.renderStore.update(sessionId, { expiresAt: target });
1099
+ if (cap !== undefined && target < now + renderTtlMs) {
1100
+ opts.logger?.warn("render_resource_ttl_extend_capped", {
1101
+ sessionId,
1102
+ createdAt: stored.createdAt,
1103
+ cap,
1104
+ });
1105
+ }
1106
+ }
1107
+ catch (cause) {
1108
+ opts.logger?.warn("render_resource_ttl_extend_failed", {
1109
+ sessionId,
1110
+ expiredAt: stored.expiresAt,
1111
+ error: cause instanceof Error ? cause.message : String(cause),
1112
+ });
1113
+ }
1114
+ }
1115
+ /**
1116
+ * Rehydration access control (spec §3), applied to ONE read
1117
+ * candidate — a render row, or the durable identity record that
1118
+ * stands in for an evicted one. Both carry the same two gated
1119
+ * fields, so both go through here.
1120
+ *
1121
+ * The anonymous-context synthesis is why this is shared rather than
1122
+ * inlined twice: a compose path that cannot thread a request context
1123
+ * still reads renders owned by the default builder identity, because
1124
+ * a candidate owned by THAT identity is attributed to an anonymous
1125
+ * caller. Anything else fails closed — `renderReadAllowed` denies a
1126
+ * missing context outright. Losing the synthesis on the record path
1127
+ * would break exactly the deployments the re-mint exists to help.
1128
+ *
1129
+ * Refusal is never an early return at the call sites: it collapses
1130
+ * the candidate to "absent" so every downstream branch runs as it
1131
+ * would for a locator that never existed. The refusal is observable
1132
+ * only server-side, on the audit line below — whose `rowAppId` names
1133
+ * the render's owner whichever store answered for it.
1134
+ */
1135
+ function readAllowed(sessionId, candidate) {
1136
+ const callerCtx = opts.getContext?.();
1137
+ const fallbackCtx = callerCtx === undefined && candidate.appId === DEFAULT_BUILDER_APP_ID
1138
+ ? {
1139
+ appId: DEFAULT_BUILDER_APP_ID,
1140
+ authSource: "anonymous",
1141
+ requestId: "resource-read",
1142
+ }
1143
+ : callerCtx;
1144
+ if (renderReadAllowed(candidate, fallbackCtx))
1145
+ return true;
1146
+ opts.logger?.warn("render_resource_read_denied", {
1147
+ sessionId,
1148
+ rowAppId: candidate.appId,
1149
+ callerAppId: callerCtx?.appId,
1150
+ });
1151
+ return false;
1152
+ }
1153
+ /**
1154
+ * Re-mint an evicted locator from its durable identity record: the
1155
+ * record names the blueprint, the blueprint names the body, and the
1156
+ * body is committed back onto a FRESH row under the same sessionId.
1157
+ * Returns the committed row, or the failure that stopped it.
1158
+ *
1159
+ * The two branches that must stay indistinguishable — no record was
1160
+ * ever written, and the caller is not entitled to the one that was —
1161
+ * return the SAME constant, so there is no shape in which they could
1162
+ * differ. Every other failure is reachable only after the access
1163
+ * check has passed, and so may say what went wrong.
1164
+ *
1165
+ * Ordering is load-bearing. The record's own owner gates the read
1166
+ * before ANY blueprint or body lookup runs, and every lookup is
1167
+ * keyed by the record's `blueprintId` under the record's own owner —
1168
+ * never by a caller-supplied key. A resolution step reachable before
1169
+ * the check would let a caller distinguish a resolvable locator from
1170
+ * one that never existed, which is the same disclosure the gate
1171
+ * exists to prevent.
1172
+ *
1173
+ * The body is INLINED onto the committed row rather than delivered
1174
+ * by URL: a row carrying its own component code is self-sufficient,
1175
+ * so the existing mount path serves it with no extra wiring, and
1176
+ * deployments with no content-addressed delivery channel re-mint
1177
+ * just as well as those with one.
1178
+ *
1179
+ * Store faults are not caught. A rejecting store is a malfunction,
1180
+ * and swallowing it here would report a working render as
1181
+ * unresolvable — indistinguishable from one that was genuinely
1182
+ * purged, and it would stay that way on every retry.
1183
+ */
1184
+ async function remintFromRecord(sessionId) {
1185
+ const substrate = durableSubstrate();
1186
+ if (substrate === null)
1187
+ return { ok: false, failure: NOT_SUPPORTED_FAILURE };
1188
+ const { identityStore, blueprintStore, bodyStore } = substrate;
1189
+ const record = await identityStore.get(sessionId);
1190
+ if (record === null)
1191
+ return RECORD_UNAVAILABLE;
1192
+ if (!readAllowed(sessionId, {
1193
+ appId: record.appId,
1194
+ // The record's SUBJECT — the same field the row's gate binds
1195
+ // on, written by the same commit.
1196
+ ...(record.userId !== undefined ? { userId: record.userId } : {}),
1197
+ })) {
1198
+ return RECORD_UNAVAILABLE;
1199
+ }
1200
+ // Everything below can only be reached by a caller entitled to
1201
+ // this locator. A null `blueprintId` is terminal (#460): the id is
1202
+ // resolved before the success commit, so null means registration
1203
+ // failed or was unavailable at commit — the record names nothing
1204
+ // to resolve, by design (#445).
1205
+ if (record.blueprintId === null) {
1206
+ return { ok: false, failure: blueprintUnresolvable("the record names no blueprint") };
1207
+ }
1208
+ const blueprint = await blueprintStore.get(record.blueprintId);
1209
+ if (blueprint === null) {
1210
+ return {
1211
+ ok: false,
1212
+ failure: blueprintUnresolvable("the blueprint the record names is gone"),
1213
+ };
1214
+ }
1215
+ const codeHash = blueprint.codeHash;
1216
+ if (codeHash === undefined) {
1217
+ return {
1218
+ ok: false,
1219
+ failure: blueprintUnresolvable("the blueprint stores no component reference"),
1220
+ };
1221
+ }
1222
+ const componentCode = await bodyStore.get(codeHash);
1223
+ if (componentCode === null || componentCode.length === 0) {
1224
+ return {
1225
+ ok: false,
1226
+ failure: blueprintUnresolvable("the component body behind the blueprint is gone"),
1227
+ };
1228
+ }
1229
+ // Sidecars are re-resolved from the live app record rather than
1230
+ // carried on the record: gadget descriptors and the theme overlay
1231
+ // are the operator's current configuration, and a re-minted render
1232
+ // should mount under it, not under a snapshot of what it was.
1233
+ //
1234
+ // Degrades rather than fails, matching the same lookup on the mount
1235
+ // path below: these are presentation sidecars, so a metadata store
1236
+ // having a bad moment costs the render its theme and its wrapper
1237
+ // catalog — it must not cost the render its rehydrate. The body and
1238
+ // the props, which a mount cannot do without, were already resolved
1239
+ // above and are NOT part of this tolerance.
1240
+ let gadgetDescriptors;
1241
+ let theme;
1242
+ if (opts.appMetadataStore) {
1243
+ try {
1244
+ const appRecord = await opts.appMetadataStore.get(record.appId);
1245
+ const resolved = filterDescriptorsToContract(blueprint.contract, resolveAppGadgets(appRecord?.gadgets));
1246
+ if (resolved.length > 0)
1247
+ gadgetDescriptors = resolved;
1248
+ theme = appRecord?.theme;
1249
+ }
1250
+ catch {
1251
+ // Silent — the render mounts with the renderer's default theme
1252
+ // and a STDLIB-only wrapper catalog.
1253
+ }
1254
+ }
1255
+ const now = Date.now();
1256
+ const contract = blueprint.contract;
1257
+ const render = {
1258
+ type: "component",
1259
+ id: sessionId,
1260
+ appId: record.appId,
1261
+ componentCode,
1262
+ contentType: "application/javascript+react",
1263
+ // The props the render last carried — the whole point of the
1264
+ // record — restored verbatim, including their ABSENCE. A record
1265
+ // with no props describes a render that had none, so the re-mint
1266
+ // gives it none: `props` is optional on the wire shape and the
1267
+ // record round-trips the distinction.
1268
+ //
1269
+ // The one thing this must never do is substitute authoring-time
1270
+ // defaults for missing props. Nothing repopulates a
1271
+ // defaults-booted card afterwards — props travel the session
1272
+ // channel, and no agent turn runs at rehydration — so it would
1273
+ // show plausible-looking wrong state indefinitely, which is
1274
+ // worse than showing none.
1275
+ ...(record.props !== undefined ? { props: record.props } : {}),
1276
+ ...(contract.propsSpec ? { propsSpec: contract.propsSpec } : {}),
1277
+ ...(contract.actionSpec ? { actionSpec: contract.actionSpec } : {}),
1278
+ ...(contract.streamSpec ? { streamSpec: contract.streamSpec } : {}),
1279
+ ...(contract.contextSpec ? { contextSpec: contract.contextSpec } : {}),
1280
+ ...(contract.clientCapabilities
1281
+ ? { clientCapabilities: contract.clientCapabilities }
1282
+ : {}),
1283
+ ...(gadgetDescriptors !== undefined ? { gadgetDescriptors } : {}),
1284
+ ...(theme !== undefined ? { theme } : {}),
1285
+ // Carried from the record so a re-minted render does not present
1286
+ // itself as newly created.
1287
+ createdAt: record.createdAt,
1288
+ lastActivityAt: now,
1289
+ // #457 — the re-mint is the other resurrection surface: capped
1290
+ // the same way the expired-read extension is, keyed on the
1291
+ // ORIGINAL createdAt the record carries. Past the cap the mount
1292
+ // still serves (a mount is not retention) but the row lands
1293
+ // already in-grace and un-extendable.
1294
+ expiresAt: Math.min(now + renderTtlMs, lifetimeCapFor(record) ?? Number.POSITIVE_INFINITY),
1295
+ // States the same fact as the `seqFloor` below, which is what
1296
+ // the store actually seeds the row's ledger from.
1297
+ eventSequence: record.seqAtLastCommit,
1298
+ };
1299
+ const row = await opts.renderStore.commit({
1300
+ render,
1301
+ appId: record.appId,
1302
+ ...(record.userId !== undefined ? { userId: record.userId } : {}),
1303
+ // The render resumes rather than restarts: its ledger continues
1304
+ // above where the record says it last was, so a reader still
1305
+ // holding a cursor from before the eviction sees the new events
1306
+ // instead of filtering them out as already-seen.
1307
+ //
1308
+ // "Where the record says it last was" is the honest bound. The
1309
+ // record samples the sequence at COMMIT, so events appended
1310
+ // between the last commit and the eviction are not reflected and
1311
+ // their numbers do get reissued. This narrows sequence reuse to
1312
+ // that window rather than eliminating it — which is the best any
1313
+ // record-based resume can do, and still strictly better than
1314
+ // restarting the ledger at zero.
1315
+ seqFloor: record.seqAtLastCommit,
1316
+ });
1317
+ return { ok: true, row };
1318
+ }
1319
+ /**
1320
+ * Serve the self-contained shell for a render row — the mount path,
1321
+ * shared by rows read from the store and rows a re-mint just
1322
+ * committed.
1323
+ *
1324
+ * Three exits, and the caller has to handle all three:
1325
+ *
1326
+ * - a response, when the row resolved to something mountable;
1327
+ * - `null`, when the row carries no renderable visible-bits surface
1328
+ * (a placeholder whose generation has not committed yet), so the
1329
+ * caller can go on looking;
1330
+ * - a thrown {@link ResourceReadFailure}, when a render DID resolve
1331
+ * but no channel can deliver it. That one is terminal by design —
1332
+ * it is the deepest the read gets, so there is nothing left for
1333
+ * the caller to try.
1334
+ */
1335
+ async function serveMount(uri, sessionId, accessibleStored) {
1336
+ const picked = pickComponentFromGguiSession(accessibleStored.render);
1337
+ if (!picked)
1338
+ return null;
1339
+ // Put an expired-but-still-readable row back on a full lifetime.
1340
+ //
1341
+ // The reason is that a caller entitled to this row has just proved
1342
+ // they are using it, which is the same signal every other
1343
+ // touch-and-extend path acts on. The live-channel token is the
1344
+ // sharpest case rather than the only one — it turns "the row dies
1345
+ // soon" into "the thing I just handed you outlives what it points
1346
+ // at" — which is why this sits ahead of the mint below. But a
1347
+ // deployment with no minter wired reaches here too, and its row
1348
+ // deserves the same extension: the read was just as real.
1349
+ //
1350
+ // After the `picked` check, because a row with nothing to mount is
1351
+ // not a row anyone is using yet.
1352
+ await extendExpiredRow(sessionId, accessibleStored);
1353
+ // Project the active render to the transport-agnostic bootstrap
1354
+ // view — same source of truth the render-mutation handler and
1355
+ // `/r/<shortCode>` consume. Carries permissionsPolicy when
1356
+ // clientCapabilities declares permissions. The MCP Apps
1357
+ // resource path emits this only into the inline bootstrap
1358
+ // (the browser-enforced gate ultimately comes from the host's
1359
+ // `allow=""` attribute when the host translates
1360
+ // `_meta.ui.permissions` — set by McpAppIframe consumers).
1361
+ const view = deriveRenderMeta(picked.source);
1362
+ const isSystem = picked.kind !== undefined;
1363
+ // A fault on EITHER delivery channel, held rather than acted on.
1364
+ //
1365
+ // Two things have to stay true at once. A channel that faulted must
1366
+ // never be reported as a channel that was never wired — that is
1367
+ // NOT_MOUNTABLE, which rides -32006 and tells the host the outcome
1368
+ // is deterministic and a retry cannot succeed, when a store having
1369
+ // a bad moment is the one thing that is not. But most deployments
1370
+ // wire BOTH channels, and there a fault on one is survivable: the
1371
+ // other still carries the mount, and failing the read would throw
1372
+ // away a perfectly good delivery path.
1373
+ //
1374
+ // So the fault is remembered here and consulted at the mount-mode
1375
+ // gate, which is the only place that knows whether anything
1376
+ // survived. If a channel did, the render mounts through it and the
1377
+ // fault costs the read nothing. If none did, the fault is thrown in
1378
+ // place of NOT_MOUNTABLE and reaches the caller as an internal
1379
+ // error — the honest answer for a blip, and the same policy the
1380
+ // re-mint path applies to its own stores.
1381
+ //
1382
+ // Wrapped in an object so a thrown `undefined` is still recorded as
1383
+ // a fault, and `??=` keeps the FIRST one when both channels break.
1384
+ let channelFault;
1385
+ // Static-component delivery via codeUrl. The compiled-component
1386
+ // path mints a content-addressable URL the iframe-runtime fetches
1387
+ // at boot. When codeStore + codeBaseUrl aren't wired this channel
1388
+ // simply does not exist, and the live channel below has to carry
1389
+ // the mount.
1390
+ let codeUrl;
1391
+ let codeHash;
1392
+ let contractHash;
1393
+ let validatorsUrl;
1394
+ if (!isSystem && opts.codeStore && opts.codeBaseUrl) {
1395
+ try {
1396
+ const hash = opts.codeStore.hashOf(picked.componentCode);
1397
+ await opts.codeStore.put(hash, picked.componentCode);
1398
+ codeHash = hash;
1399
+ const base = opts.codeBaseUrl.replace(/\/$/, "");
1400
+ codeUrl = `${base}/code/${hash}.js`;
1401
+ }
1402
+ catch (cause) {
1403
+ channelFault ??= { cause };
1404
+ }
1405
+ // Content-addressable contract-validator bundle (#109).
1406
+ try {
1407
+ const bundle = await deriveContractBundle(picked.source);
1408
+ if (bundle) {
1409
+ await opts.codeStore.put(bundle.contractHash, bundle.bundleSource);
1410
+ contractHash = bundle.contractHash;
1411
+ const base = opts.codeBaseUrl.replace(/\/$/, "");
1412
+ validatorsUrl = `${base}/contract/${bundle.contractHash}.js`;
1413
+ }
1414
+ }
1415
+ catch {
1416
+ // Silent, and unlike the two channel faults this one stays
1417
+ // that way: validators are an optional client-side courtesy,
1418
+ // the server-side gate is authoritative, and losing them costs
1419
+ // the read nothing it needs to mount. It cannot be mistaken for
1420
+ // an absent channel, which is what makes swallowing it safe
1421
+ // here and not above.
1422
+ }
1423
+ }
1424
+ // The codeUrl gate is applied AFTER the live-channel mint below, so
1425
+ // a render with no static codeUrl still mounts via live-mode
1426
+ // (wsUrl + wsToken) instead of failing as undeliverable —
1427
+ // parity with the `/r/<shortCode>` path. (See the gate after the
1428
+ // mint.) This matters for deployments that wire `mintWsToken` but no
1429
+ // `codeStore`/`codeBaseUrl` (e.g. the cloud pod): the agent-server
1430
+ // inlines THIS resource, so without live-mode every render on such a
1431
+ // deployment would resolve fine and then be reported as having no
1432
+ // way to be delivered.
1433
+ // Project the wrapper catalog AND the union-filtered
1434
+ // publicEnv onto the inline bootstrap so the resource-served
1435
+ // iframe matches the MCP-Apps postMessage path. Without this,
1436
+ // wrapper-using contracts rendered through `resources/read`
1437
+ // mount as STDLIB-only.
1438
+ let resourcePublicEnv;
1439
+ if (opts.appMetadataStore) {
1440
+ try {
1441
+ const appRecord = await opts.appMetadataStore.get(accessibleStored.appId);
1442
+ resourcePublicEnv = derivePublicEnvProjection(picked.source, appRecord?.publicEnv);
1443
+ }
1444
+ catch {
1445
+ // Silent — wrappers calling getPublicEnv throw clearly.
1446
+ }
1447
+ }
1448
+ // Live-channel bootstrap — when the operator wired
1449
+ // {@link GguiRenderResourceTemplateOptions.mintWsToken}, mint a
1450
+ // wsToken for this render so the iframe-runtime opens a
1451
+ // WebSocket on mount and receives `props_update` frames.
1452
+ // Without this, the resource shell renders in static-component
1453
+ // mode only — `ggui_update` server-side mutations never
1454
+ // visibly reach the live iframe (hosts must re-fetch
1455
+ // `resources/read` after every update tool result to see new
1456
+ // state).
1457
+ //
1458
+ // A mint FAULT is held the same way the code-store write above is,
1459
+ // for the same reason: `wsToken` left undefined is indistinguishable
1460
+ // from "no live channel is wired" by the time the gate reads it.
1461
+ let wsUrl;
1462
+ let wsToken;
1463
+ let wsExpiresAt;
1464
+ if (opts.mintWsToken) {
1465
+ try {
1466
+ const minted = opts.mintWsToken(sessionId, accessibleStored.appId);
1467
+ wsUrl = minted.wsUrl;
1468
+ wsToken = minted.token;
1469
+ // Forward the token TTL so the iframe-runtime can degrade to
1470
+ // static-only mode once it lapses (parity with the render-tool
1471
+ // slice projection, render.ts). Dropping it left the live-mode
1472
+ // resource shell unable to know when its WS token expired.
1473
+ wsExpiresAt = minted.expiresAt;
1474
+ }
1475
+ catch (cause) {
1476
+ channelFault ??= { cause };
1477
+ }
1478
+ }
1479
+ // Mount-mode gate (below the live-channel mint): a compiled
1480
+ // component needs ONE of the two channels. A deployment that wires
1481
+ // no codeStore (codeUrl === undefined) but DOES wire mintWsToken
1482
+ // mounts via live-mode; one that wires neither has resolved a
1483
+ // render it cannot deliver, and says so.
1484
+ //
1485
+ // Terminal, deliberately: this is the deepest the read gets, so
1486
+ // there is nothing left to try. It also has to stay AHEAD of
1487
+ // `buildSelfContainedShell`, which throws a plain Error on the same
1488
+ // condition — that would reach the caller as an untyped internal
1489
+ // error announcing a malfunction where the server is behaving
1490
+ // exactly as configured.
1491
+ //
1492
+ // Reaching here having FAULTED is the one case that is not the
1493
+ // server behaving as configured, and it is the only place with
1494
+ // enough information to tell: a fault matters exactly when nothing
1495
+ // else produced a channel. Anywhere above this line the same fault
1496
+ // may have been survivable, and on a deployment wiring both
1497
+ // channels it usually is.
1498
+ if (!isSystem &&
1499
+ codeUrl === undefined &&
1500
+ view.codeB64 === undefined &&
1501
+ (wsUrl === undefined || wsToken === undefined)) {
1502
+ if (channelFault !== undefined)
1503
+ throw channelFault.cause;
1504
+ throw new ResourceReadFailure(NO_DELIVERY_CHANNEL_FAILURE);
1505
+ }
1506
+ const html = buildSelfContainedShell({
1507
+ sessionId,
1508
+ appId: accessibleStored.appId,
1509
+ ...(isSystem
1510
+ ? { systemKind: picked.kind }
1511
+ : {
1512
+ ...(codeUrl !== undefined
1513
+ ? {
1514
+ codeUrl,
1515
+ ...(codeHash !== undefined ? { codeHash } : {}),
1516
+ }
1517
+ : {}),
1518
+ // Inline fetch-free channel (size-capped, projected by
1519
+ // `deriveRenderMeta`). Independent of the codeStore, so a
1520
+ // dead/unwired store still yields a mountable shell — and
1521
+ // hosts whose iframe CSP blocks the codeUrl fetch decode
1522
+ // this instead.
1523
+ ...(view.codeB64 !== undefined ? { codeB64: view.codeB64 } : {}),
1524
+ }),
1525
+ runtimeUrl: opts.runtimeUrl,
1526
+ ...(wsUrl !== undefined && wsToken !== undefined
1527
+ ? {
1528
+ wsUrl,
1529
+ token: wsToken,
1530
+ ...(wsExpiresAt !== undefined ? { expiresAt: wsExpiresAt } : {}),
1531
+ }
1532
+ : {}),
1533
+ ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
1534
+ ...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
1535
+ // Per-app theme overlay projected by `deriveRenderMeta` from
1536
+ // the render's `theme` sidecar — forwarded so the
1537
+ // resource-served iframe matches the postMessage path.
1538
+ ...(view.theme !== undefined ? { theme: view.theme } : {}),
1539
+ ...(view.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
1540
+ ...(view.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
1541
+ ...(view.permissionsPolicy !== undefined
1542
+ ? { permissionsPolicy: view.permissionsPolicy }
1543
+ : {}),
1544
+ ...(view.gadgets !== undefined && view.gadgets.length > 0
1545
+ ? { gadgets: view.gadgets }
1546
+ : {}),
1547
+ ...(contractHash !== undefined && validatorsUrl !== undefined
1548
+ ? { contractHash, validatorsUrl }
1549
+ : {}),
1550
+ ...(resourcePublicEnv !== undefined && Object.keys(resourcePublicEnv).length > 0
1551
+ ? { publicEnv: resourcePublicEnv }
1552
+ : {}),
1553
+ // R6 — ledger cursor stamp for polling-cursor alignment.
1554
+ lastSequence: accessibleStored.eventSequence,
1555
+ });
1556
+ // Augment per-call CSP with gadget-declared bundle / style /
1557
+ // API origins. Without this, claude.ai's iframe CSP only allows
1558
+ // the publicBaseUrl origin, so Leaflet wrapper bundles fetched
1559
+ // from registry.ggui.ai, leaflet.css fetched from same, and
1560
+ // OSM tile requests to tile.openstreetmap.org all get blocked
1561
+ // → the component throws and the React error boundary renders
1562
+ // "Something went wrong." The /r/<shortCode> HTTP path already
1563
+ // derives these via deriveBundleOrigins; this is the per-call
1564
+ // resource mirror.
1565
+ const gadgetOrigins = deriveBundleOrigins(picked.source);
1566
+ return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
1567
+ }
799
1568
  // Single shared handler powers both templates. `blueprintKey` is
800
1569
  // optional in the variables map — present for the resume URI shape,
801
1570
  // absent for the legacy single-segment shape.
@@ -803,197 +1572,97 @@ export function registerGguiRenderResourceTemplate(server, opts) {
803
1572
  const sessionIdRaw = variables["sessionId"];
804
1573
  const sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
805
1574
  if (typeof sessionId !== "string" || sessionId.length === 0) {
806
- return loadingShell(uri, "unknown");
1575
+ // A URI with no session segment names no locator, which is the
1576
+ // same thing as naming one that does not exist.
1577
+ throw new ResourceReadFailure(NOT_FOUND_FAILURE);
807
1578
  }
808
1579
  const blueprintKeyRaw = variables["blueprintKey"];
809
1580
  const blueprintKey = Array.isArray(blueprintKeyRaw) ? blueprintKeyRaw[0] : blueprintKeyRaw;
810
1581
  const hasResumeKey = typeof blueprintKey === "string" && blueprintKey.length > 0;
811
- // Parallel lookup. The render and the blueprint registry are
812
- // independent — even though the render's componentCode could feed
813
- // the renderable directly, we ALSO want the blueprint entry as a
814
- // registry-only fallback when the render is gone but the blueprint
815
- // is still cached (chat-history rehydrate after render TTL or
816
- // process restart).
817
- const [stored, blueprint] = await Promise.all([
818
- opts.renderStore.get(sessionId),
819
- hasResumeKey && opts.vectorStore && opts.index && opts.defaultAppIdFallback
820
- ? findBlueprintExact({ vectorStore: opts.vectorStore, index: opts.index }, opts.defaultAppIdFallback, "template",
821
- // Resume URI carries only a contract hash — omit variantKey
822
- // so the lookup resolves the default variant.
823
- blueprintKey)
824
- : Promise.resolve(null),
825
- ]);
826
- // Happy path: render present and renderable. Mount with the live
827
- // state (current props, current contextSpec values).
828
- if (stored) {
829
- const picked = pickComponentFromGguiSession(stored.render);
830
- if (picked) {
831
- // Project the active render to the transport-agnostic bootstrap
832
- // view — same source of truth the render-mutation handler and
833
- // `/r/<shortCode>` consume. Carries permissionsPolicy when
834
- // clientCapabilities declares permissions. The MCP Apps
835
- // resource path emits this only into the inline bootstrap
836
- // (the browser-enforced gate ultimately comes from the host's
837
- // `allow=""` attribute when the host translates
838
- // `_meta.ui.permissions` — set by McpAppIframe consumers).
839
- const view = deriveRenderMeta(picked.source);
840
- const isSystem = picked.kind !== undefined;
841
- // Static-component delivery via codeUrl. The compiled-component
842
- // path mints a content-addressable URL the iframe-runtime
843
- // fetches at boot; the loading shell takes over when codeStore +
844
- // codeBaseUrl aren't wired.
845
- let codeUrl;
846
- let codeHash;
847
- let contractHash;
848
- let validatorsUrl;
849
- if (!isSystem && opts.codeStore && opts.codeBaseUrl) {
850
- try {
851
- const hash = opts.codeStore.hashOf(picked.componentCode);
852
- await opts.codeStore.put(hash, picked.componentCode);
853
- codeHash = hash;
854
- const base = opts.codeBaseUrl.replace(/\/$/, "");
855
- codeUrl = `${base}/code/${hash}.js`;
856
- }
857
- catch {
858
- // Silent — falls through to loading shell below.
859
- }
860
- // Content-addressable contract-validator bundle (#109).
861
- try {
862
- const bundle = await deriveContractBundle(picked.source);
863
- if (bundle) {
864
- await opts.codeStore.put(bundle.contractHash, bundle.bundleSource);
865
- contractHash = bundle.contractHash;
866
- const base = opts.codeBaseUrl.replace(/\/$/, "");
867
- validatorsUrl = `${base}/contract/${bundle.contractHash}.js`;
868
- }
869
- }
870
- catch {
871
- // Silent — bundle write failure degrades to no client-side
872
- // validators (server-side gate is authoritative).
873
- }
874
- }
875
- // The codeUrl gate is applied AFTER the live-channel mint below, so
876
- // a render with no static codeUrl still mounts via live-mode
877
- // (wsUrl + wsToken) instead of stalling on the loading shell —
878
- // parity with the `/r/<shortCode>` path. (See the gate after the
879
- // mint.) This matters for deployments that wire `mintWsToken` but no
880
- // `codeStore`/`codeBaseUrl` (e.g. the cloud pod): the agent-server
881
- // inlines THIS resource, so without the fallback every cloud render
882
- // hung on the dead "Generating UI…" shell.
883
- // Project the wrapper catalog AND the union-filtered
884
- // publicEnv onto the inline bootstrap so the resource-served
885
- // iframe matches the MCP-Apps postMessage path. Without this,
886
- // wrapper-using contracts rendered through `resources/read`
887
- // mount as STDLIB-only.
888
- let resourcePublicEnv;
889
- if (opts.appMetadataStore) {
890
- try {
891
- const appRecord = await opts.appMetadataStore.get(stored.appId);
892
- resourcePublicEnv = derivePublicEnvProjection(picked.source, appRecord?.publicEnv);
893
- }
894
- catch {
895
- // Silent — wrappers calling getPublicEnv throw clearly.
896
- }
897
- }
898
- // Live-channel bootstrap — when the operator wired
899
- // {@link GguiRenderResourceTemplateOptions.mintWsToken}, mint a
900
- // wsToken for this render so the iframe-runtime opens a
901
- // WebSocket on mount and receives `props_update` frames.
902
- // Without this, the resource shell renders in static-component
903
- // mode only — `ggui_update` server-side mutations never
904
- // visibly reach the live iframe (hosts must re-fetch
905
- // `resources/read` after every update tool result to see new
906
- // state).
907
- let wsUrl;
908
- let wsToken;
909
- let wsExpiresAt;
910
- if (opts.mintWsToken) {
911
- try {
912
- const minted = opts.mintWsToken(sessionId, stored.appId);
913
- wsUrl = minted.wsUrl;
914
- wsToken = minted.token;
915
- // Forward the token TTL so the iframe-runtime can degrade to
916
- // static-only mode once it lapses (parity with the render-tool
917
- // slice projection, render.ts). Dropping it left the live-mode
918
- // resource shell unable to know when its WS token expired.
919
- wsExpiresAt = minted.expiresAt;
920
- }
921
- catch {
922
- // Silent — falls back to static-component mode.
923
- }
924
- }
925
- // Mount-mode gate (moved below the live-channel mint): emit the
926
- // loading shell ONLY when NEITHER a static codeUrl channel NOR a
927
- // live-channel wsToken is available. A deployment that wires no
928
- // codeStore (codeUrl === undefined) but DOES wire mintWsToken now
929
- // mounts via live-mode rather than hanging on "Generating UI…".
930
- if (!isSystem && codeUrl === undefined && (wsUrl === undefined || wsToken === undefined)) {
931
- return loadingShell(uri, sessionId);
932
- }
933
- const html = buildSelfContainedShell({
934
- sessionId,
935
- appId: stored.appId,
936
- ...(isSystem
937
- ? { systemKind: picked.kind }
938
- : codeUrl !== undefined
939
- ? {
940
- codeUrl,
941
- ...(codeHash !== undefined ? { codeHash } : {}),
942
- }
943
- : // No static codeUrl → live-mode (wsUrl + token spread below)
944
- // carries the render; buildSelfContainedShell accepts
945
- // live-mode without codeUrl.
946
- {}),
947
- runtimeUrl: opts.runtimeUrl,
948
- ...(wsUrl !== undefined && wsToken !== undefined
949
- ? {
950
- wsUrl,
951
- token: wsToken,
952
- ...(wsExpiresAt !== undefined ? { expiresAt: wsExpiresAt } : {}),
953
- }
954
- : {}),
955
- ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
956
- ...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
957
- // Per-app theme overlay projected by `deriveRenderMeta` from
958
- // the render's `theme` sidecar — forwarded so the
959
- // resource-served iframe matches the postMessage path.
960
- ...(view.theme !== undefined ? { theme: view.theme } : {}),
961
- ...(view.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
962
- ...(view.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
963
- ...(view.permissionsPolicy !== undefined
964
- ? { permissionsPolicy: view.permissionsPolicy }
965
- : {}),
966
- ...(view.gadgets !== undefined && view.gadgets.length > 0
967
- ? { gadgets: view.gadgets }
968
- : {}),
969
- ...(contractHash !== undefined && validatorsUrl !== undefined
970
- ? { contractHash, validatorsUrl }
971
- : {}),
972
- ...(resourcePublicEnv !== undefined && Object.keys(resourcePublicEnv).length > 0
973
- ? { publicEnv: resourcePublicEnv }
974
- : {}),
975
- // R6 — ledger cursor stamp for polling-cursor alignment.
976
- lastSequence: stored.eventSequence,
977
- });
978
- // Augment per-call CSP with gadget-declared bundle / style /
979
- // API origins. Without this, claude.ai's iframe CSP only allows
980
- // the publicBaseUrl origin, so Leaflet wrapper bundles fetched
981
- // from registry.ggui.ai, leaflet.css fetched from same, and
982
- // OSM tile requests to tile.openstreetmap.org all get blocked
983
- // → the component throws and the React error boundary renders
984
- // "Something went wrong." The /r/<shortCode> HTTP path already
985
- // derives these via deriveBundleOrigins; this is the per-call
986
- // resource mirror.
987
- const gadgetOrigins = deriveBundleOrigins(picked.source);
988
- return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
1582
+ // The failure this read ends in if nothing mounts. Seeded from a
1583
+ // property of the SERVER, never of the locator, so a caller cannot
1584
+ // read the answer as a statement about which locators exist; the
1585
+ // branches below refine it only where the access check has already
1586
+ // passed.
1587
+ let failure = durableSubstrate() === null ? NOT_SUPPORTED_FAILURE : NOT_FOUND_FAILURE;
1588
+ const stored = await opts.renderStore.get(sessionId);
1589
+ // Rehydration access control (spec §3): gate BEFORE any shell
1590
+ // bytes, code hashing, or token mint. Refusal does NOT
1591
+ // early-return — it nulls out row access into `accessibleStored`
1592
+ // so every downstream branch (mount, re-mint, registry fallback,
1593
+ // terminal failure) runs exactly as if the row were absent. A
1594
+ // refusal on the resume URI must fall through to the SAME
1595
+ // registry-only fallback a genuine miss would hit (the fallback is
1596
+ // keyed off the caller-supplied blueprintKey + registry defaults,
1597
+ // never off the refused row) — otherwise refusal would
1598
+ // short-circuit to its own error while a miss of the same
1599
+ // blueprintKey resolves the registry shell, leaking row existence
1600
+ // to a same-probe attacker.
1601
+ const accessibleStored = stored !== null &&
1602
+ stored !== undefined &&
1603
+ readAllowed(sessionId, {
1604
+ appId: stored.appId,
1605
+ // #446 — the row's SUBJECT is `userId`, written at commit.
1606
+ // This used to project `endUserIdentity`, which nothing has
1607
+ // written since the repo split, so the subject rung never
1608
+ // bound.
1609
+ ...(stored.userId !== undefined ? { userId: stored.userId } : {}),
1610
+ })
1611
+ ? stored
1612
+ : null;
1613
+ // Live state first: render present and renderable mounts with the
1614
+ // current props + current contextSpec values.
1615
+ if (accessibleStored) {
1616
+ const served = await serveMount(uri, sessionId, accessibleStored);
1617
+ if (served !== null)
1618
+ return served;
1619
+ // The row is here and the caller may read it; it simply has no
1620
+ // component yet, because the generation that will fill it has not
1621
+ // committed. Safe to say so — this branch is past the check, so
1622
+ // it can only ever describe a locator the caller is entitled to.
1623
+ failure = NOT_YET_COMMITTED_FAILURE;
1624
+ }
1625
+ // Re-mint: the row is GONE and the deployment keeps a durable
1626
+ // record of what it was. Gated on the row being genuinely absent
1627
+ // rather than merely unreadable — a re-mint COMMITS, and a commit
1628
+ // fired while a row exists would overwrite live state (or another
1629
+ // party's) with a reconstruction. A refused read still reaches the
1630
+ // same fallback and the same terminal failure a miss reaches, so
1631
+ // nothing here tells the two apart.
1632
+ if (stored === null || stored === undefined) {
1633
+ const reminted = await remintFromRecord(sessionId);
1634
+ if (reminted.ok) {
1635
+ const served = await serveMount(uri, sessionId, reminted.row);
1636
+ if (served !== null)
1637
+ return served;
1638
+ }
1639
+ else {
1640
+ failure = reminted.failure;
989
1641
  }
990
1642
  }
991
1643
  // Registry-only fallback: render is gone (TTL / restart) but the
992
1644
  // blueprint is still in the registry. Synthesize the shell from
993
1645
  // the blueprint's componentCode + propsSpec defaults — strictly
994
1646
  // worse than the live mount (no current props, no preserved
995
- // context state), but strictly better than the dead loading
996
- // shell.
1647
+ // context state), but a real mount rather than a failure.
1648
+ //
1649
+ // It runs on EVERY path that has not mounted, including a refused
1650
+ // one, and that is load-bearing: it is keyed by the caller-supplied
1651
+ // blueprintKey under the registry default and never by the row, so
1652
+ // a refusal and a miss of the same key resolve the same shell. A
1653
+ // refusal that skipped it would be distinguishable from a miss.
1654
+ //
1655
+ // The lookup runs HERE, not before the gate. It is keyed by a
1656
+ // caller-supplied blueprintKey under a registry default, so firing
1657
+ // it ahead of the access check spent a lookup on every read and
1658
+ // put blueprint-existence work in front of the one check that
1659
+ // decides whether the caller may learn anything at all.
1660
+ const blueprint = hasResumeKey && opts.vectorStore && opts.index && opts.defaultAppIdFallback
1661
+ ? await findBlueprintExact({ vectorStore: opts.vectorStore, index: opts.index }, opts.defaultAppIdFallback, "template",
1662
+ // Resume URI carries only a contract hash — omit variantKey
1663
+ // so the lookup resolves the default variant.
1664
+ blueprintKey)
1665
+ : null;
997
1666
  if (blueprint && opts.defaultAppIdFallback) {
998
1667
  const html = await buildShellFromBlueprint({
999
1668
  sessionId,
@@ -1008,18 +1677,24 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1008
1677
  if (html !== undefined) {
1009
1678
  return shellContents(uri, html);
1010
1679
  }
1011
- // Fallthrough to loading shell when codeStore isn't wired.
1680
+ // A blueprint matched, but `buildShellFromBlueprint` needs the
1681
+ // static-delivery pair to turn one into a shell. Its own failure,
1682
+ // not the one held above: what the read found is a component it
1683
+ // cannot deliver, and that answer depends only on the
1684
+ // caller-supplied key and this server's wiring — identical for a
1685
+ // refused read and a miss of the same key.
1686
+ throw new ResourceReadFailure(BLUEPRINT_UNDELIVERABLE_FAILURE);
1012
1687
  }
1013
- return loadingShell(uri, sessionId);
1688
+ throw new ResourceReadFailure(failure);
1014
1689
  }
1015
1690
  server.registerResource("ggui-render-self-contained", legacyTemplate, {
1016
1691
  title: "ggui render (self-contained, legacy URI)",
1017
- description: "Per-render self-contained shell — single-segment URI shape predating the resume contract. Falls back to loading shell when the render is gone (no blueprintKey to do registry-only render).",
1692
+ description: "Per-render self-contained shell — single-segment URI shape predating the resume contract. A read returns a mountable shell or exactly one typed JSON-RPC error, never a shell that cannot paint. Carrying no blueprintKey, this shape cannot reach the blueprint-registry fallback; a server that keeps durable identity records still re-mints it, since that path is keyed by sessionId alone. Failures: NOT_FOUND (-32002) when nothing resolves the locator, and identically when the caller may not read one that does; NOT_SUPPORTED (-32006) in place of NOT_FOUND on a server that keeps no durable record, for both of those cases alike; BLUEPRINT_UNRESOLVABLE (-32006) when a record names a component that is gone; NOT_MOUNTABLE (-32006) when the caller's own render resolved but nothing mountable can be produced from it, on any server.",
1018
1693
  mimeType: GGUI_RENDER_RESOURCE_MIME,
1019
1694
  }, handle);
1020
1695
  server.registerResource("ggui-render-self-contained-resume", resumeTemplate, {
1021
1696
  title: "ggui render (self-contained, resume URI)",
1022
- description: "Per-render self-contained shell — two-segment URI shape carrying both sessionId AND blueprintKey. Resource handler runs Promise.all over render + registry; falls back to registry-only static render when the render has been evicted but the blueprint is still cached.",
1697
+ description: "Per-render self-contained shell — two-segment URI shape carrying both sessionId AND blueprintKey. A read returns a mountable shell or exactly one typed JSON-RPC error, never a shell that cannot paint. When the render has been evicted the handler tries, in order and only after the access check: a re-mint from the durable identity record, then a registry-only static render from the blueprintKey. Failures: NOT_FOUND (-32002) when neither resolves the locator, and identically when the caller may not read one that does; NOT_SUPPORTED (-32006) in place of NOT_FOUND on a server that keeps no durable record, for both of those cases alike; BLUEPRINT_UNRESOLVABLE (-32006) when a record names a component that is gone; NOT_MOUNTABLE (-32006) when the caller's own render, or a blueprint matching the supplied key, resolved but nothing mountable can be produced from it, on any server.",
1023
1698
  mimeType: GGUI_RENDER_RESOURCE_MIME,
1024
1699
  }, handle);
1025
1700
  }
@@ -1118,7 +1793,11 @@ function deriveDefaultContextSlots(spec) {
1118
1793
  */
1119
1794
  export function installMcpAppsOutbound(server, opts = {}) {
1120
1795
  advertiseMcpAppsUiCapability(server);
1121
- registerGguiRenderResource(server, opts.shellHtml, opts.publicBaseUrl);
1796
+ // The self-contained template's absolute runtimeUrl doubles as the
1797
+ // static registration's CSP-declaration fallback — deployments that
1798
+ // set no `publicBaseUrl` (it also feeds Origin/Host enforcement +
1799
+ // OAuth) still declare their origin to spec-compliant hosts.
1800
+ registerGguiRenderResource(server, opts.shellHtml, opts.publicBaseUrl, opts.selfContained?.runtimeUrl);
1122
1801
  if (opts.selfContained) {
1123
1802
  registerGguiRenderResourceTemplate(server, opts.selfContained);
1124
1803
  }