@ggui-ai/mcp-server 0.8.0 → 0.10.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 (67) hide show
  1. package/dist/api-renders-routes.d.ts +21 -0
  2. package/dist/api-renders-routes.d.ts.map +1 -1
  3. package/dist/api-renders-routes.js +54 -28
  4. package/dist/api-renders-stream-route.d.ts +80 -0
  5. package/dist/api-renders-stream-route.d.ts.map +1 -0
  6. package/dist/api-renders-stream-route.js +311 -0
  7. package/dist/build-mcp.d.ts +48 -7
  8. package/dist/build-mcp.d.ts.map +1 -1
  9. package/dist/build-mcp.js +87 -6
  10. package/dist/code-module-variant.d.ts +150 -0
  11. package/dist/code-module-variant.d.ts.map +1 -0
  12. package/dist/code-module-variant.js +243 -0
  13. package/dist/code-routes.d.ts +12 -2
  14. package/dist/code-routes.d.ts.map +1 -1
  15. package/dist/code-routes.js +12 -2
  16. package/dist/console-session-routes.d.ts.map +1 -1
  17. package/dist/console-session-routes.js +11 -0
  18. package/dist/control-service.d.ts +29 -3
  19. package/dist/control-service.d.ts.map +1 -1
  20. package/dist/control-service.js +26 -2
  21. package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
  22. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  23. package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
  24. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  25. package/dist/ggui-session-channel/internal-types.d.ts +63 -9
  26. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  27. package/dist/ggui-session-channel/outbound.d.ts +15 -5
  28. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  29. package/dist/ggui-session-channel/outbound.js +64 -24
  30. package/dist/ggui-session-channel/socket-router.d.ts +8 -3
  31. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  32. package/dist/ggui-session-channel/socket-router.js +6 -1
  33. package/dist/ggui-session-channel/subscribe.d.ts +48 -3
  34. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  35. package/dist/ggui-session-channel/subscribe.js +97 -36
  36. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
  37. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  38. package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
  39. package/dist/ggui-session-channel.d.ts +58 -11
  40. package/dist/ggui-session-channel.d.ts.map +1 -1
  41. package/dist/ggui-session-channel.js +55 -19
  42. package/dist/health-routes.d.ts +19 -3
  43. package/dist/health-routes.d.ts.map +1 -1
  44. package/dist/health-routes.js +26 -18
  45. package/dist/index.d.ts +6 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +13 -1
  48. package/dist/instructions-presets.js +10 -10
  49. package/dist/mcp-apps-outbound.d.ts +88 -11
  50. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  51. package/dist/mcp-apps-outbound.js +470 -77
  52. package/dist/mcp-endpoint-routes.d.ts +23 -5
  53. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  54. package/dist/mcp-endpoint-routes.js +69 -1
  55. package/dist/oauth-as-routes.d.ts +11 -0
  56. package/dist/oauth-as-routes.d.ts.map +1 -1
  57. package/dist/oauth-as-routes.js +45 -1
  58. package/dist/oauth.d.ts.map +1 -1
  59. package/dist/oauth.js +8 -1
  60. package/dist/runtime-bundle-hash.d.ts +55 -0
  61. package/dist/runtime-bundle-hash.d.ts.map +1 -0
  62. package/dist/runtime-bundle-hash.js +85 -0
  63. package/dist/runtime-bundle-route.js +1 -1
  64. package/dist/server.d.ts +239 -61
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +355 -143
  67. package/package.json +13 -12
@@ -27,13 +27,13 @@
27
27
  * The thin shell is static content; it depends on nothing except the
28
28
  * MIME constant and the HTML. Keeping it next to the registration
29
29
  * means a future refactor of the shell edits one file. The
30
- * `@ggui-ai/react` package does NOT ship the shell as a separate
30
+ * `@ggui-ai/mcp-apps-react` package does NOT ship the shell as a separate
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, filterDescriptorsToContract, findBlueprintExact, } from "@ggui-ai/mcp-server-handlers/renders";
34
+ import { deriveBundleOrigins, deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, filterDescriptorsToContract, findBlueprintExact, resolveSliceTheme, spreadRenderMetaViewOntoSlice, wsOriginToHttpOrigin, } from "@ggui-ai/mcp-server-handlers/renders";
35
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";
36
+ import { GGUI_RENDER_RESOURCE_MIME, GGUI_RENDER_RESOURCE_URI, GGUI_RENDER_SHELL_SURFACE, MCP_APPS_UI_CAPABILITY, MCP_APP_BOOTSTRAP_FAILED_TYPE, asGguiRenderBootstrap, composeSessionApiUrls, deriveContextName, escapeInlineScript, gguiShellHtml, parseEpochUri, 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";
@@ -150,9 +150,39 @@ var rpcId=1,pending={};
150
150
  var rootEl=document.getElementById('ggui-root');
151
151
  rootEl.style.cssText='display:flex;flex-direction:column;height:100%;min-height:300px;margin:0';
152
152
  var mounted=false;
153
+ var lastEnvelope=null;
154
+ // Text color pairs with the shell surface: themed var when the runtime
155
+ // injected theme CSS, else the light-on-dark fallback matching the
156
+ // shell's static #1e293b pre-theme surface. The old hardcoded #666 was
157
+ // illegible on that dark fallback (#481).
158
+ var SHELL_FG='var(--ggui-color-onSurface,#e2e8f0)';
153
159
  function setOverlay(text){
154
160
  if(mounted)return;
155
- rootEl.innerHTML='<div style="font:14px system-ui,sans-serif;padding:24px;color:#666">'+text+'</div>';
161
+ rootEl.innerHTML='<div style="font:13px system-ui,sans-serif;padding:24px;color:'+SHELL_FG+';opacity:.55">'+text+'</div>';
162
+ }
163
+ function escText(s){
164
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
165
+ }
166
+ // Terminal failure card (#481): plain-language summary, diagnostic
167
+ // reachable but collapsed, Retry only when a retry can actually work.
168
+ // Same visual vocabulary as the runtime's React error boundary so the
169
+ // two error surfaces read as one system.
170
+ function showFailure(summary,detail,retry){
171
+ if(mounted)return;
172
+ rootEl.innerHTML=''+
173
+ '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:32px 24px;min-height:160px;font-family:system-ui,sans-serif;color:'+SHELL_FG+';text-align:center">'+
174
+ '<div style="font-size:14px;font-weight:600">'+escText(summary)+'</div>'+
175
+ '<div style="font-size:12px;opacity:.65;max-width:320px;line-height:1.45">The interface could not start. The conversation is unaffected — you can also just ask for the view again.</div>'+
176
+ (retry?'<button id="ggui-shell-retry" style="margin-top:2px;padding:7px 18px;border-radius:8px;border:1px solid currentColor;background:transparent;color:inherit;opacity:.75;font:500 13px system-ui,sans-serif;cursor:pointer">Retry</button>':'')+
177
+ '<details style="margin-top:6px;max-width:340px;width:100%;text-align:left;opacity:.75">'+
178
+ '<summary style="cursor:pointer;font-size:12px">Details</summary>'+
179
+ '<pre style="margin:6px 0 0;font:11px/1.5 ui-monospace,Menlo,monospace;white-space:pre-wrap;word-break:break-word">'+escText(detail)+'</pre>'+
180
+ '</details>'+
181
+ '</div>';
182
+ if(retry){
183
+ var b=document.getElementById('ggui-shell-retry');
184
+ if(b)b.onclick=retry;
185
+ }
156
186
  }
157
187
  function postNotification(method,params){
158
188
  try{window.parent.postMessage({jsonrpc:'2.0',method:method,params:params||{}},'*');}catch(e){}
@@ -183,10 +213,12 @@ async function mountFromMeta(envelope){
183
213
  var renderSlice=envelope&&envelope['ai.ggui/render'];
184
214
  var runtimeUrl=renderSlice&&renderSlice.runtimeUrl;
185
215
  if(!envelope||typeof runtimeUrl!=='string'){
186
- setOverlay('Bootstrap payload malformed.');
216
+ // No retry: without a valid envelope there is nothing to re-run.
217
+ showFailure('This view could not start','MALFORMED_BOOTSTRAP: bootstrap payload malformed (no ai.ggui/render slice with a runtimeUrl).',null);
187
218
  postBootstrapFailed('MALFORMED_BOOTSTRAP','Bootstrap payload malformed.');
188
219
  return;
189
220
  }
221
+ lastEnvelope=envelope;
190
222
  setOverlay('Loading UI…');
191
223
  window.__GGUI_META__=envelope;
192
224
  // Load the runtime bundle via a direct cross-origin script tag
@@ -212,14 +244,14 @@ async function mountFromMeta(envelope){
212
244
  s.onload=function(){mounted=true;};
213
245
  s.onerror=function(e){
214
246
  var msg='Runtime bundle failed to load: '+(e&&e.message||'script error');
215
- setOverlay(msg);
247
+ showFailure('This view could not load','BUNDLE_FETCH_FAILED: '+msg,function(){mountFromMeta(lastEnvelope);});
216
248
  postBootstrapFailed('BUNDLE_FETCH_FAILED',msg);
217
249
  };
218
250
  rootEl.innerHTML='';
219
251
  document.body.appendChild(s);
220
252
  }catch(e){
221
253
  var msg='Runtime bundle failed to load: '+(e&&e.message||e);
222
- setOverlay(msg);
254
+ showFailure('This view could not load','BUNDLE_FETCH_FAILED: '+msg,function(){mountFromMeta(lastEnvelope);});
223
255
  postBootstrapFailed('BUNDLE_FETCH_FAILED',msg);
224
256
  }
225
257
  }
@@ -255,33 +287,92 @@ window.addEventListener('message',function(ev){
255
287
  // at the top level.
256
288
  var specMeta=readMetaFromCallToolResult(m.params);
257
289
  if(specMeta){mountFromMeta(specMeta);return;}
258
- // R5 (2026-05-26) -- the /r/<shortCode> HTTP fallback was removed
259
- // along with the bearer-by-obscurity model. Hosts that strip
260
- // _meta on the tool-result wire have no fallback path here;
261
- // spec-canonical hosts deliver meta inline and land in the branch
262
- // above.
290
+ // Read-plane door (ggui#537). A server running the read-plane-only
291
+ // posture publishes only the view's IDENTITY on the result -- the
292
+ // ui:// locator on structuredContent.resourceUri and the spec
293
+ // pointer _meta.ui.resourceUri -- and no bootstrap material. The
294
+ // per-render resource that locator names is the self-contained
295
+ // shell, whose document inlines the very envelope this shell needs;
296
+ // ask the HOST to read it (resources/read, proxied by the host's
297
+ // own MCP client -- no fetch from this sandbox, so a host CSP with
298
+ // no connect-src to the server is not in the way). R5 (2026-05-26)
299
+ // removed the /r/<shortCode> HTTP fallback with the bearer-by-
300
+ // obscurity model; this door is the spec-canonical replacement.
301
+ var locator=readLocatorFromCallToolResult(m.params);
302
+ if(locator){mountFromLocator(locator);return;}
263
303
  }
264
304
  });
265
- setOverlay('Initializing…');
266
- var initTimer=setTimeout(function(){
267
- setOverlay('Host did not respond to ui/initialize within 3s.');
268
- },3000);
269
- postRpc('ui/initialize',{
270
- appCapabilities:{},
271
- appInfo:{name:'ggui-render',version:'1.0.0'},
272
- protocolVersion:'2026-01-26'
273
- }).then(function(){
274
- clearTimeout(initTimer);
275
- postNotification('ui/notifications/initialized',{});
276
- // Wait for the host to send ui/notifications/tool-result carrying
277
- // the slice envelope in _meta — the spec-canonical delivery channel.
278
- // The ui/initialize result itself carries no slice meta (the
279
- // McpUiInitializeResult schema defines no such field).
280
- setOverlay('Waiting for tool result…');
281
- }).catch(function(e){
282
- clearTimeout(initTimer);
283
- setOverlay('ui/initialize failed: '+(e&&e.message||JSON.stringify(e)));
284
- });
305
+ function readLocatorFromCallToolResult(params){
306
+ if(!params||typeof params!=='object')return null;
307
+ var sc=params.structuredContent, meta=params._meta, uri=null;
308
+ if(sc&&typeof sc==='object'&&typeof sc.resourceUri==='string')uri=sc.resourceUri;
309
+ else if(meta&&typeof meta==='object'&&meta.ui&&typeof meta.ui==='object'&&typeof meta.ui.resourceUri==='string')uri=meta.ui.resourceUri;
310
+ if(!uri||uri.indexOf('ui://ggui/render/')!==0)return null;
311
+ return uri;
312
+ }
313
+ function envelopeFromResourceDoc(text){
314
+ // The per-render self-contained shell inlines its envelope on ONE
315
+ // line inside its meta script tag: globalThis.__GGUI_META__ = {...};
316
+ // (protocol gguiShellHtml). The JSON is angle-bracket-escaped by the
317
+ // assembler, so the first ';' followed by the closing script tag
318
+ // after the marker is the terminator.
319
+ if(typeof text!=='string')return null;
320
+ var marker='globalThis.__GGUI_META__ = ';
321
+ var start=text.indexOf(marker);
322
+ if(start<0)return null;
323
+ start+=marker.length;
324
+ var end=text.indexOf(';<'+'/script>',start);
325
+ if(end<0)return null;
326
+ try{var env=JSON.parse(text.slice(start,end));}catch(e){return null;}
327
+ return (env&&typeof env==='object'&&env['ai.ggui/render']&&typeof env['ai.ggui/render'].runtimeUrl==='string')?env:null;
328
+ }
329
+ async function mountFromLocator(uri){
330
+ if(mounted)return;
331
+ setOverlay('Resolving view…');
332
+ var res;
333
+ try{res=await postRpc('resources/read',{uri:uri});}
334
+ catch(e){
335
+ var rmsg='READ_DOOR_FAILED: the host could not read '+uri+' -- '+(e&&e.message||JSON.stringify(e));
336
+ showFailure('This view could not be resolved',rmsg,function(){mountFromLocator(uri);});
337
+ postBootstrapFailed('MALFORMED_BOOTSTRAP',rmsg);
338
+ return;
339
+ }
340
+ var c=res&&res.contents&&res.contents[0];
341
+ var env=envelopeFromResourceDoc(c&&c.text);
342
+ if(!env){
343
+ var emsg='MALFORMED_BOOTSTRAP: resource '+uri+' carried no ai.ggui/render envelope with a runtimeUrl.';
344
+ showFailure('This view could not start',emsg,function(){mountFromLocator(uri);});
345
+ postBootstrapFailed('MALFORMED_BOOTSTRAP',emsg);
346
+ return;
347
+ }
348
+ mountFromMeta(env);
349
+ }
350
+ // Named so the failure card's Retry can re-run the whole handshake —
351
+ // a host that missed or rejected the first ui/initialize may answer a
352
+ // second (observed with hosts that attach their listener late).
353
+ function startInit(){
354
+ setOverlay('Initializing…');
355
+ var initTimer=setTimeout(function(){
356
+ showFailure('This view did not hear back from its host','INIT_TIMEOUT: no response to ui/initialize within 3s.',startInit);
357
+ },3000);
358
+ postRpc('ui/initialize',{
359
+ appCapabilities:{},
360
+ appInfo:{name:'ggui-render',version:'1.0.0'},
361
+ protocolVersion:'2026-01-26'
362
+ }).then(function(){
363
+ clearTimeout(initTimer);
364
+ postNotification('ui/notifications/initialized',{});
365
+ // Wait for the host to send ui/notifications/tool-result carrying
366
+ // the slice envelope in _meta — the spec-canonical delivery channel.
367
+ // The ui/initialize result itself carries no slice meta (the
368
+ // McpUiInitializeResult schema defines no such field).
369
+ setOverlay('Waiting for tool result…');
370
+ }).catch(function(e){
371
+ clearTimeout(initTimer);
372
+ showFailure('This view could not connect to its host','ui/initialize failed: '+(e&&e.message||JSON.stringify(e)),startInit);
373
+ });
374
+ }
375
+ startInit();
285
376
  })();
286
377
  `;
287
378
  // `--ggui-color-surface` is injected at `:root` on this document's
@@ -313,10 +404,15 @@ postRpc('ui/initialize',{
313
404
  // diverged. Painting the served document's own surface here removes the
314
405
  // dependency on a browser honoring iframe transparency.
315
406
  //
316
- // Value-resolution only — no `--ggui-*` token added or renamed. The
317
- // constant itself lives with the protocol host-helper (the shared
318
- // self-contained-shell assembler paints the same surface); imported
319
- // above and reused here for the thin postMessage shell.
407
+ // The paint is inline, so no stylesheet `background` rule can undo
408
+ // it — which is why the constant's `var()` chain opens with the
409
+ // `--ggui-shell-background` override point: content that KNOWS its
410
+ // host composites behind the document (the runtime's system-card
411
+ // layer inside Claude) sets that property from its stylesheet and
412
+ // the inline value re-resolves to transparent in place. See
413
+ // `GGUI_RENDER_SHELL_SURFACE` in the protocol host-helper (the
414
+ // shared self-contained-shell assembler paints the same surface);
415
+ // imported above and reused here for the thin postMessage shell.
320
416
  // `#ggui-root` here is LOAD-BEARING for the shell script (NOT a React
321
417
  // mount target): the inline script grabs it as `rootEl` for the
322
418
  // pre-mount overlays ("Initializing…", "Waiting for tool result…",
@@ -431,9 +527,16 @@ try{
431
527
  * deliberately untouched.
432
528
  */
433
529
  export function buildInlineRenderShellHtml(runtimeSource) {
530
+ // No anchor div: the runtime appends its own mount target to
531
+ // `document.body` at boot, so a thin-shell-style `#ggui-root`
532
+ // placeholder here would just stack empty space ABOVE the rendered
533
+ // card (min-height'd blank div + content below it — the "long upper
534
+ // space" bug from the first claude.ai live test). Same
535
+ // no-container posture as `gguiShellHtml`; the shell marker rides
536
+ // on `<body>`.
434
537
  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>
538
+ <html lang="en" style="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>
539
+ <body style="margin:0;background-color:${GGUI_RENDER_SHELL_SURFACE}" data-ggui-shell="inline">
437
540
  <script>${GGUI_INLINE_SHELL_BUFFER_SCRIPT_BODY}</script>
438
541
  <script type="module" data-ggui-runtime="inline">${escapeInlineScript(runtimeSource)}</script></body></html>`;
439
542
  }
@@ -483,7 +586,19 @@ function buildCspMeta(publicBaseUrl,
483
586
  * references `:6786/_ggui/iframe-runtime.js`) trips a `script-src`
484
587
  * violation that blanks the iframe — verified live 2026-05-27.
485
588
  */
486
- runtimeUrl) {
589
+ runtimeUrl,
590
+ /**
591
+ * Origins of server-stamped connect URLs (`wsUrl`, `sseUrl`,
592
+ * `pollingUrl`) — each parseable entry's origin is unioned into
593
+ * `connectDomains` (deduplicated), scheme preserved (`ws`/`wss` are
594
+ * WHATWG special schemes, so a `wss://` entry contributes its
595
+ * `wss://` origin). Same-origin deployments add nothing new;
596
+ * split-origin live channels or session APIs would otherwise be
597
+ * silently blocked by `connect-src` on spec-compliant hosts —
598
+ * WebSocket, EventSource, and fetch are all connect-src-governed,
599
+ * and the base's ws-twin flip only covers the base's own host.
600
+ */
601
+ extraConnectUrls) {
487
602
  const source = publicBaseUrl ?? runtimeUrl;
488
603
  if (!source)
489
604
  return undefined;
@@ -492,10 +607,26 @@ runtimeUrl) {
492
607
  const origin = parsed.origin;
493
608
  const wsScheme = parsed.protocol === "https:" ? "wss:" : "ws:";
494
609
  const wsOrigin = `${wsScheme}//${parsed.host}`;
610
+ const connectDomains = [origin, wsOrigin];
611
+ for (const url of extraConnectUrls ?? []) {
612
+ if (url === undefined)
613
+ continue;
614
+ let extraOrigin;
615
+ try {
616
+ extraOrigin = new URL(url).origin;
617
+ }
618
+ catch {
619
+ // Unparseable stamped URL — nothing to declare for it; the
620
+ // base declaration stands.
621
+ continue;
622
+ }
623
+ if (!connectDomains.includes(extraOrigin))
624
+ connectDomains.push(extraOrigin);
625
+ }
495
626
  return {
496
627
  ui: {
497
628
  csp: {
498
- connectDomains: [origin, wsOrigin],
629
+ connectDomains,
499
630
  resourceDomains: [origin],
500
631
  },
501
632
  },
@@ -518,21 +649,53 @@ export function registerGguiRenderResource(server, shellHtml = GGUI_RENDER_SHELL
518
649
  * declaration at all and spec-compliant hosts applied the
519
650
  * restrictive default (`connect-src 'none'`).
520
651
  */
521
- runtimeUrl) {
522
- const cspMeta = buildCspMeta(publicBaseUrl, runtimeUrl);
652
+ runtimeUrl,
653
+ /**
654
+ * Additional URLs whose origins the mounted iframe must be able to
655
+ * `connect-src` — unioned into `connectDomains` by
656
+ * {@link buildCspMeta}. The load-bearing entries are the live-channel
657
+ * origins (`wsUrl` + its ws→http origin flip): the STATIC shell is
658
+ * the resource cross-origin hosts (claude.ai) mount and derive the
659
+ * frame CSP from, so origins declared only on per-render resources
660
+ * never reach the frame. Without these, deployments that set no
661
+ * `publicBaseUrl` (the cloud pod — it feeds Origin/Host enforcement)
662
+ * declare only the runtime-CDN origin and every SSE / HTTP-polling /
663
+ * WS rung of the failover ladder is CSP-blocked in the mounted
664
+ * iframe — observed live on claude.ai (#471 round 11: frame booted
665
+ * with `connect-src assets.mcp.ggui.ai` only).
666
+ */
667
+ extraConnectUrls) {
668
+ const cspMeta = buildCspMeta(publicBaseUrl, runtimeUrl, extraConnectUrls);
669
+ // Content-addressed shell URI (2026-08-12, the stale-shell bust).
670
+ // Hosts cache the prefetched shell keyed on the RESOURCE URI —
671
+ // claude.ai's backend was observed serving days-old shell bytes
672
+ // across our deploys, fresh pages, and connector re-connects,
673
+ // because `ui://ggui/render` never changes. Hash the FULL SERVED
674
+ // REPRESENTATION — shell bytes (wrapper + any inlined runtime) AND
675
+ // the `_meta.ui.csp` declaration — into the advertised URI so any
676
+ // change a host may have cached mints a NEW URI, and an unchanged
677
+ // one never does; the same content-address discipline the hashed
678
+ // `/_ggui/iframe-runtime.<sha12>.js` HTTP route applies one layer
679
+ // down. The meta MUST be in the hash input: hosts cache the
680
+ // declaration alongside the bytes (claude.ai derives the frame's
681
+ // connect-src from it), so a meta-only change — e.g. adding the
682
+ // live-channel origins to `connectDomains` — would otherwise ship a
683
+ // new policy under an old URI and never reach cached frames. The
684
+ // bare URI stays registered for grandfathered sessions and hosts
685
+ // that read it directly.
686
+ const shellHash = createHash("sha256")
687
+ .update(shellHtml)
688
+ .update(JSON.stringify(cspMeta ?? null))
689
+ .digest("hex")
690
+ .slice(0, 12);
691
+ const versionedUri = `${GGUI_RENDER_RESOURCE_URI}/rt-${shellHash}`;
523
692
  // `registerAppResource` (from `@modelcontextprotocol/ext-apps/server`)
524
693
  // defaults `mimeType` to `RESOURCE_MIME_TYPE` — the same
525
694
  // `text/html;profile=mcp-app` value `GGUI_RENDER_RESOURCE_MIME`
526
695
  // carries. Letting the canonical helper own the default means the
527
696
  // mimeType string lives in ONE place across the ecosystem (the SDK)
528
697
  // rather than duplicated in our protocol package.
529
- registerAppResource(server, "ggui-render", GGUI_RENDER_RESOURCE_URI, {
530
- // `title` / `description` show up in MCP clients that surface
531
- // resource metadata. Short + concrete.
532
- title: "ggui render",
533
- description: "Thin-shell iframe bundle that bootstraps a ggui render. MCP Apps hosts fetch this when they see `_meta.ui.resourceUri` on a ggui_render result.",
534
- mimeType: GGUI_RENDER_RESOURCE_MIME,
535
- }, async (uri) => ({
698
+ const serveShell = async (uri) => ({
536
699
  contents: [
537
700
  {
538
701
  uri: uri.href,
@@ -541,7 +704,44 @@ runtimeUrl) {
541
704
  ...(cspMeta !== undefined ? { _meta: cspMeta } : {}),
542
705
  },
543
706
  ],
544
- }));
707
+ });
708
+ registerAppResource(server, "ggui-render", GGUI_RENDER_RESOURCE_URI, {
709
+ // `title` / `description` show up in MCP clients that surface
710
+ // resource metadata. Short + concrete.
711
+ title: "ggui render",
712
+ description: "Thin-shell iframe bundle that bootstraps a ggui render. MCP Apps hosts fetch this when they see `_meta.ui.resourceUri` on a ggui_render result.",
713
+ mimeType: GGUI_RENDER_RESOURCE_MIME,
714
+ }, serveShell);
715
+ registerAppResource(server, "ggui-render-versioned", versionedUri, {
716
+ title: "ggui render (content-addressed)",
717
+ description: "Content-addressed twin of ui://ggui/render — the URI embeds the shell-bytes hash so host prefetch caches miss exactly when the shell changed. Tool declarations advertise THIS URI.",
718
+ mimeType: GGUI_RENDER_RESOURCE_MIME,
719
+ }, serveShell);
720
+ // STALE-HASH grandfather template — `rt-{shellHash}` for ANY hash.
721
+ // Hosts snapshot tool declarations (claude.ai stores the connector's
722
+ // tool list server-side), so after a shell-changing deploy they keep
723
+ // asking for the PREVIOUS deploy's versioned URI. Without this
724
+ // template that read falls through to the per-session
725
+ // `ui://ggui/render/{sessionId}` template (a bare `rt-abc…` segment
726
+ // parses as a sessionId), resolves no render, and the host shows
727
+ // "unable to reach" — observed live on claude.ai the first deploy
728
+ // after the URI scheme changed (#471 round 12). Serving the CURRENT
729
+ // shell under the stale URI restores the pre-content-addressing
730
+ // behavior for stale hosts (at worst they cache today's shell under
731
+ // yesterday's key) while fresh declarations keep the cache-bust
732
+ // property. MUST register before the session templates
733
+ // (`installMcpAppsOutbound` orders this call first; the SDK matches
734
+ // templates in registration order).
735
+ server.registerResource("ggui-render-versioned-grandfather", new ResourceTemplate(`${GGUI_RENDER_RESOURCE_URI}/rt-{shellHash}`, {
736
+ // No list-callback — same posture as the session templates; the
737
+ // canonical URI is the one advertised on tool declarations.
738
+ list: undefined,
739
+ }), {
740
+ title: "ggui render (content-addressed, any revision)",
741
+ description: "Grandfather route for content-addressed shell URIs from earlier deploys — hosts holding a stale tool-declaration snapshot read their old rt-<hash> URI and receive the CURRENT shell.",
742
+ mimeType: GGUI_RENDER_RESOURCE_MIME,
743
+ }, serveShell);
744
+ return versionedUri;
545
745
  }
546
746
  /**
547
747
  * Advertise the `io.modelcontextprotocol/ui` extension capability on
@@ -650,7 +850,11 @@ export function buildSelfContainedShell(opts) {
650
850
  // WS-only mode (legacy behavior). See SelfContainedShellInputs
651
851
  // .pollingUrl for the URL shape.
652
852
  ...(opts.pollingUrl !== undefined ? { pollingUrl: opts.pollingUrl } : {}),
853
+ // SSE middle rung — same stamping posture as pollingUrl. See
854
+ // SelfContainedShellInputs.sseUrl for the stream contract.
855
+ ...(opts.sseUrl !== undefined ? { sseUrl: opts.sseUrl } : {}),
653
856
  ...(opts.lastSequence !== undefined ? { lastSequence: opts.lastSequence } : {}),
857
+ ...(opts.epoch !== undefined ? { epoch: opts.epoch } : {}),
654
858
  // Visible-bits surface — what the iframe is mounting right now.
655
859
  // Static-content discriminators (codeUrl / kind) are mutually
656
860
  // exclusive; the iframe-runtime rejects the both-set mix.
@@ -661,6 +865,11 @@ export function buildSelfContainedShell(opts) {
661
865
  ...(opts.codeHash !== undefined ? { codeHash: opts.codeHash } : {}),
662
866
  }
663
867
  : {}),
868
+ // Strict-CSP module-variant twin — only WITH a raw static carrier
869
+ // (the protocol parser drops it otherwise).
870
+ ...(!isSystem && (hasCodeUrl || hasCodeB64) && opts.codeModuleUrl !== undefined
871
+ ? { codeModuleUrl: opts.codeModuleUrl }
872
+ : {}),
664
873
  ...(!isSystem && hasCodeB64 ? { codeB64: opts.codeB64 } : {}),
665
874
  ...(opts.propsJson !== undefined ? { propsJson: opts.propsJson } : {}),
666
875
  ...(opts.contextSlots !== undefined && opts.contextSlots.length > 0
@@ -692,7 +901,12 @@ export function buildSelfContainedShell(opts) {
692
901
  // standalone served iframe (claude.ai per-render resource shells,
693
902
  // `/r/<shortCode>`), never inlined into a host page — so it paints
694
903
  // its own theme-surface backdrop. See `GguiShellHtmlOptions` for the
695
- // Safari white-canvas rationale.
904
+ // Safari white-canvas rationale. System-kind renders keep this
905
+ // posture too: the surface paint resolves through the
906
+ // `--ggui-shell-background` override point, so the card layer —
907
+ // the only layer that can detect a compositing host at runtime —
908
+ // drops the backdrop to transparent itself where that is right,
909
+ // and every other context keeps the per-browser-consistent paint.
696
910
  return gguiShellHtml(bootstrap, { background: "surface" });
697
911
  }
698
912
  /**
@@ -959,18 +1173,24 @@ export function registerGguiRenderResourceTemplate(server, opts) {
959
1173
  * claude.ai's iframe CSP and the component fails to render. Returns
960
1174
  * `undefined` when there's no base CSP at all (publicBaseUrl
961
1175
  * absent — first-party same-origin host).
1176
+ *
1177
+ * `base` defaults to the registration-time `templateCspMeta`;
1178
+ * `serveMount` passes a per-render base recomputed with the stamped
1179
+ * session-API URLs (`sseUrl` / `pollingUrl`) so their origins ride
1180
+ * `connectDomains` even when they differ from the publicBaseUrl
1181
+ * origin (ws→http origin-flip fallback).
962
1182
  */
963
- const augmentCspMeta = (gadgetOrigins) => {
964
- if (templateCspMeta === undefined)
1183
+ const augmentCspMeta = (gadgetOrigins, base = templateCspMeta) => {
1184
+ if (base === undefined)
965
1185
  return undefined;
966
1186
  if (gadgetOrigins === undefined)
967
- return templateCspMeta;
1187
+ return base;
968
1188
  return {
969
1189
  ui: {
970
1190
  csp: {
971
- connectDomains: [...templateCspMeta.ui.csp.connectDomains, ...gadgetOrigins.connect],
1191
+ connectDomains: [...base.ui.csp.connectDomains, ...gadgetOrigins.connect],
972
1192
  resourceDomains: [
973
- ...templateCspMeta.ui.csp.resourceDomains,
1193
+ ...base.ui.csp.resourceDomains,
974
1194
  ...gadgetOrigins.script,
975
1195
  ...gadgetOrigins.style,
976
1196
  ],
@@ -1389,6 +1609,7 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1389
1609
  // the mount.
1390
1610
  let codeUrl;
1391
1611
  let codeHash;
1612
+ let codeModuleUrl;
1392
1613
  let contractHash;
1393
1614
  let validatorsUrl;
1394
1615
  if (!isSystem && opts.codeStore && opts.codeBaseUrl) {
@@ -1398,6 +1619,13 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1398
1619
  codeHash = hash;
1399
1620
  const base = opts.codeBaseUrl.replace(/\/$/, "");
1400
1621
  codeUrl = `${base}/code/${hash}.js`;
1622
+ // Strict-CSP module-variant twin (ggui#522 slice 2) — a
1623
+ // decline (`undefined`) just means the blob ladder carries it.
1624
+ codeModuleUrl = opts.mintCodeModuleUrl?.({
1625
+ code: picked.componentCode,
1626
+ hash,
1627
+ base,
1628
+ });
1401
1629
  }
1402
1630
  catch (cause) {
1403
1631
  channelFault ??= { cause };
@@ -1476,6 +1704,20 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1476
1704
  channelFault ??= { cause };
1477
1705
  }
1478
1706
  }
1707
+ // Token-bearing session-API URL pair (pollingUrl + sseUrl) —
1708
+ // composed via the protocol's ONE composer so this surface cannot
1709
+ // drift from the render/update resultMeta stamping. Stamped only
1710
+ // when the mint above produced a token (both URLs embed it); base
1711
+ // = publicBaseUrl when configured, else the ws→http origin flip
1712
+ // of the minted wsUrl (session API served on the WS origin — OSS
1713
+ // defaults + the cloud pod's single ingress).
1714
+ let sessionApiUrls;
1715
+ if (wsToken !== undefined) {
1716
+ const base = opts.publicBaseUrl ?? (wsUrl !== undefined ? wsOriginToHttpOrigin(wsUrl) : undefined);
1717
+ if (base !== undefined) {
1718
+ sessionApiUrls = composeSessionApiUrls(base, sessionId, wsToken);
1719
+ }
1720
+ }
1479
1721
  // Mount-mode gate (below the live-channel mint): a compiled
1480
1722
  // component needs ONE of the two channels. A deployment that wires
1481
1723
  // no codeStore (codeUrl === undefined) but DOES wire mintWsToken
@@ -1513,6 +1755,7 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1513
1755
  ? {
1514
1756
  codeUrl,
1515
1757
  ...(codeHash !== undefined ? { codeHash } : {}),
1758
+ ...(codeModuleUrl !== undefined ? { codeModuleUrl } : {}),
1516
1759
  }
1517
1760
  : {}),
1518
1761
  // Inline fetch-free channel (size-capped, projected by
@@ -1530,26 +1773,34 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1530
1773
  ...(wsExpiresAt !== undefined ? { expiresAt: wsExpiresAt } : {}),
1531
1774
  }
1532
1775
  : {}),
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
- : {}),
1776
+ // Layered theme — the SAME resolver the tool-result slice uses
1777
+ // (live pick > per-render override on this render > static
1778
+ // ggui.json), so a `ggui_render({ themeId })` override survives a
1779
+ // mount-by-read (ggui#539). The picked source IS the committed
1780
+ // render, so its `themeId` is the override the emitter read at
1781
+ // commit time — component variant only, exactly the emitter's
1782
+ // own narrowing (render.ts resultMeta): mcpApps / system renders
1783
+ // carry no user-facing theme. Keys stay absent when unresolved.
1784
+ ...resolveSliceTheme(opts, picked.source.type !== "mcpApps" && picked.source.type !== "system"
1785
+ ? picked.source.themeId
1786
+ : undefined),
1787
+ // State + policy view fields (theme overlay, propsJson,
1788
+ // contextSlots, permissionsPolicy, gadgets, #483 epoch) — ONE
1789
+ // shared spread so the resource-served shell cannot drift from
1790
+ // the postMessage-path emitters.
1791
+ ...spreadRenderMetaViewOntoSlice(view),
1547
1792
  ...(contractHash !== undefined && validatorsUrl !== undefined
1548
1793
  ? { contractHash, validatorsUrl }
1549
1794
  : {}),
1550
1795
  ...(resourcePublicEnv !== undefined && Object.keys(resourcePublicEnv).length > 0
1551
1796
  ? { publicEnv: resourcePublicEnv }
1552
1797
  : {}),
1798
+ // Token-bearing HTTP fallback rungs — present exactly when the
1799
+ // mint above produced a token and a base resolved (see the
1800
+ // composition above the mount-mode gate).
1801
+ ...(sessionApiUrls !== undefined
1802
+ ? { pollingUrl: sessionApiUrls.pollingUrl, sseUrl: sessionApiUrls.sseUrl }
1803
+ : {}),
1553
1804
  // R6 — ledger cursor stamp for polling-cursor alignment.
1554
1805
  lastSequence: accessibleStored.eventSequence,
1555
1806
  });
@@ -1563,21 +1814,128 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1563
1814
  // derives these via deriveBundleOrigins; this is the per-call
1564
1815
  // resource mirror.
1565
1816
  const gadgetOrigins = deriveBundleOrigins(picked.source);
1566
- return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
1817
+ // Per-render CSP base: recompute with the stamped live-channel +
1818
+ // session-API URLs so their origins ride `connectDomains`
1819
+ // (WebSocket, EventSource, and fetch are all connect-src-governed).
1820
+ // The stamped `wsUrl` MUST be its own entry: CSP never
1821
+ // cross-translates `https://` ↔ `wss://`, and the base's ws-twin
1822
+ // flip only covers deployments whose base origin IS the ws host —
1823
+ // when `publicBaseUrl` is absent and the runtime bundle lives on
1824
+ // an assets CDN origin, the flip declares the CDN's wss twin while
1825
+ // the actual socket host goes undeclared and the live channel dies
1826
+ // in the mounted iframe (#479, observed as the cloud-render
1827
+ // capstone's CSP block). Same-origin stamps dedupe to the
1828
+ // registration-time declaration.
1829
+ const renderCspBase = sessionApiUrls !== undefined || wsUrl !== undefined
1830
+ ? buildCspMeta(opts.publicBaseUrl, opts.runtimeUrl, [
1831
+ wsUrl,
1832
+ sessionApiUrls?.sseUrl,
1833
+ sessionApiUrls?.pollingUrl,
1834
+ ])
1835
+ : templateCspMeta;
1836
+ return shellContents(uri, html, augmentCspMeta(gadgetOrigins, renderCspBase));
1837
+ }
1838
+ /**
1839
+ * Reconstruct the props of history record `#epoch` from the event
1840
+ * ledger (#483): walk ascending, apply every `ui.updated`, and stop
1841
+ * at the `ui.reminted` boundary that LEAVES the pinned epoch
1842
+ * (`data.epoch === epoch + 1`) — so the record includes the amends
1843
+ * made during its reign, matching the live freeze semantics
1844
+ * (state-at-supersession). Returns `null` when the walk cannot reach
1845
+ * the boundary (ledger horizon evicted the record's reign) — the
1846
+ * caller surfaces the standard not-found posture; the record aged
1847
+ * out of what this server can serve.
1848
+ */
1849
+ async function reconstructPropsAtEpoch(sessionId, epoch) {
1850
+ let since = 0;
1851
+ let currentProps = null;
1852
+ for (;;) {
1853
+ const page = await opts.renderStore.listEventsSince(sessionId, since, 200);
1854
+ if (page === null || page.events.length === 0)
1855
+ return null;
1856
+ for (const event of page.events) {
1857
+ if (event.type === "ui.updated") {
1858
+ const data = event.data;
1859
+ // Epoch-stamped filtering: the update that MINTS epoch N+1
1860
+ // appends its props event (stamped N+1) BEFORE the N+1
1861
+ // boundary — those props belong to the NEXT record, never
1862
+ // to #N. Pre-#483 events carry no stamp and read as
1863
+ // belonging to the then-current (≤ pinned) epoch.
1864
+ if ((data.epoch ?? 0) <= epoch) {
1865
+ currentProps = data.props;
1866
+ }
1867
+ }
1868
+ else if (event.type === "ui.reminted") {
1869
+ const data = event.data;
1870
+ if (data.epoch === epoch + 1)
1871
+ return currentProps;
1872
+ }
1873
+ since = event.seq;
1874
+ }
1875
+ if (!page.hasMore && page.lastSequence <= since)
1876
+ return null;
1877
+ }
1567
1878
  }
1568
1879
  // Single shared handler powers both templates. `blueprintKey` is
1569
1880
  // optional in the variables map — present for the resume URI shape,
1570
1881
  // absent for the legacy single-segment shape.
1571
1882
  async function handle(uri, variables) {
1572
1883
  const sessionIdRaw = variables["sessionId"];
1573
- const sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
1884
+ let sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
1885
+ const blueprintKeyRaw = variables["blueprintKey"];
1886
+ let blueprintKey = Array.isArray(blueprintKeyRaw) ? blueprintKeyRaw[0] : blueprintKeyRaw;
1887
+ // Epoch pin (#483): `…#N` names the immutable history record N;
1888
+ // bare names the live head. Depending on the transport's URL
1889
+ // handling the pin may arrive as `uri.hash`, glued RAW onto the
1890
+ // last matched variable, or PERCENT-ENCODED inside it (`%23N`) —
1891
+ // resolve all three tolerantly via the one seam, cleaning the
1892
+ // variable either way.
1893
+ const parsePin = (segment) => {
1894
+ const direct = parseEpochUri(segment);
1895
+ if (direct.epoch !== undefined) {
1896
+ return { base: direct.baseUri, epoch: direct.epoch };
1897
+ }
1898
+ try {
1899
+ const decoded = decodeURIComponent(segment);
1900
+ if (decoded !== segment) {
1901
+ const parsed = parseEpochUri(decoded);
1902
+ if (parsed.epoch !== undefined) {
1903
+ return { base: parsed.baseUri, epoch: parsed.epoch };
1904
+ }
1905
+ }
1906
+ }
1907
+ catch {
1908
+ // Malformed percent-encoding — not a pin; segment passes
1909
+ // through whole (same tolerant posture as parseEpochUri).
1910
+ }
1911
+ return { base: segment };
1912
+ };
1913
+ // ALWAYS clean the variables (transports have been observed to
1914
+ // deliver the pin BOTH as uri.hash and glued raw onto the matched
1915
+ // variable); the pin resolves from whichever source carried it.
1916
+ let pinnedEpoch;
1917
+ if (uri.hash.length > 1) {
1918
+ pinnedEpoch = parseEpochUri(`x${uri.hash}`).epoch;
1919
+ }
1920
+ if (typeof blueprintKey === "string") {
1921
+ const parsed = parsePin(blueprintKey);
1922
+ if (parsed.epoch !== undefined) {
1923
+ pinnedEpoch = pinnedEpoch ?? parsed.epoch;
1924
+ blueprintKey = parsed.base;
1925
+ }
1926
+ }
1927
+ if (typeof sessionId === "string") {
1928
+ const parsed = parsePin(sessionId);
1929
+ if (parsed.epoch !== undefined) {
1930
+ pinnedEpoch = pinnedEpoch ?? parsed.epoch;
1931
+ sessionId = parsed.base;
1932
+ }
1933
+ }
1574
1934
  if (typeof sessionId !== "string" || sessionId.length === 0) {
1575
1935
  // A URI with no session segment names no locator, which is the
1576
1936
  // same thing as naming one that does not exist.
1577
1937
  throw new ResourceReadFailure(NOT_FOUND_FAILURE);
1578
1938
  }
1579
- const blueprintKeyRaw = variables["blueprintKey"];
1580
- const blueprintKey = Array.isArray(blueprintKeyRaw) ? blueprintKeyRaw[0] : blueprintKeyRaw;
1581
1939
  const hasResumeKey = typeof blueprintKey === "string" && blueprintKey.length > 0;
1582
1940
  // The failure this read ends in if nothing mounts. Seeded from a
1583
1941
  // property of the SERVER, never of the locator, so a caller cannot
@@ -1610,6 +1968,38 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1610
1968
  })
1611
1969
  ? stored
1612
1970
  : null;
1971
+ // Pinned history read (#483): `#N` where N is a SUPERSEDED epoch
1972
+ // reconstructs that record's props from the ledger and serves a
1973
+ // shell frozen at them. N === head falls through to the live
1974
+ // mount (the pinned URI of the current head IS the head); N >
1975
+ // head names a record that does not exist.
1976
+ if (accessibleStored && pinnedEpoch !== undefined) {
1977
+ const headEpoch = accessibleStored.render.epoch ?? 0;
1978
+ if (pinnedEpoch > headEpoch) {
1979
+ throw new ResourceReadFailure(NOT_FOUND_FAILURE);
1980
+ }
1981
+ if (pinnedEpoch < headEpoch && accessibleStored.render.type !== "mcpApps") {
1982
+ const historicalProps = await reconstructPropsAtEpoch(sessionId, pinnedEpoch);
1983
+ if (historicalProps === null) {
1984
+ // The record's reign aged out of the ledger horizon — this
1985
+ // server can no longer serve it. Same terminal posture as a
1986
+ // locator that never existed (see #483 SPEC note).
1987
+ throw new ResourceReadFailure(failure);
1988
+ }
1989
+ const pinnedRow = {
1990
+ ...accessibleStored,
1991
+ render: {
1992
+ ...accessibleStored.render,
1993
+ props: historicalProps,
1994
+ epoch: pinnedEpoch,
1995
+ },
1996
+ };
1997
+ const served = await serveMount(uri, sessionId, pinnedRow);
1998
+ if (served !== null)
1999
+ return served;
2000
+ throw new ResourceReadFailure(failure);
2001
+ }
2002
+ }
1613
2003
  // Live state first: render present and renderable mounts with the
1614
2004
  // current props + current contextSpec values.
1615
2005
  if (accessibleStored) {
@@ -1669,8 +2059,10 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1669
2059
  appId: opts.defaultAppIdFallback,
1670
2060
  blueprint,
1671
2061
  runtimeUrl: opts.runtimeUrl,
1672
- ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
1673
- ...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
2062
+ // No render row exists on this branch, so there is no
2063
+ // per-render override to honor — but the live pick still beats
2064
+ // the static preset, same resolver as every other shell.
2065
+ ...resolveSliceTheme(opts, undefined),
1674
2066
  ...(opts.codeStore !== undefined ? { codeStore: opts.codeStore } : {}),
1675
2067
  ...(opts.codeBaseUrl !== undefined ? { codeBaseUrl: opts.codeBaseUrl } : {}),
1676
2068
  });
@@ -1797,8 +2189,9 @@ export function installMcpAppsOutbound(server, opts = {}) {
1797
2189
  // static registration's CSP-declaration fallback — deployments that
1798
2190
  // set no `publicBaseUrl` (it also feeds Origin/Host enforcement +
1799
2191
  // OAuth) still declare their origin to spec-compliant hosts.
1800
- registerGguiRenderResource(server, opts.shellHtml, opts.publicBaseUrl, opts.selfContained?.runtimeUrl);
2192
+ const shellResourceUri = registerGguiRenderResource(server, opts.shellHtml, opts.publicBaseUrl, opts.selfContained?.runtimeUrl, opts.extraConnectUrls);
1801
2193
  if (opts.selfContained) {
1802
2194
  registerGguiRenderResourceTemplate(server, opts.selfContained);
1803
2195
  }
2196
+ return { shellResourceUri };
1804
2197
  }