@ggui-ai/mcp-server 0.7.0 → 0.9.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 (56) hide show
  1. package/dist/api-renders-routes.d.ts.map +1 -1
  2. package/dist/api-renders-routes.js +32 -23
  3. package/dist/api-renders-stream-route.d.ts +80 -0
  4. package/dist/api-renders-stream-route.d.ts.map +1 -0
  5. package/dist/api-renders-stream-route.js +311 -0
  6. package/dist/build-mcp.d.ts +10 -0
  7. package/dist/build-mcp.d.ts.map +1 -1
  8. package/dist/build-mcp.js +64 -4
  9. package/dist/console-session-routes.d.ts +10 -5
  10. package/dist/console-session-routes.d.ts.map +1 -1
  11. package/dist/console-session-routes.js +21 -5
  12. package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
  13. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  14. package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
  15. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  16. package/dist/ggui-session-channel/internal-types.d.ts +63 -9
  17. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  18. package/dist/ggui-session-channel/outbound.d.ts +15 -5
  19. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  20. package/dist/ggui-session-channel/outbound.js +64 -24
  21. package/dist/ggui-session-channel/socket-router.d.ts +8 -3
  22. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  23. package/dist/ggui-session-channel/socket-router.js +6 -1
  24. package/dist/ggui-session-channel/subscribe.d.ts +48 -3
  25. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  26. package/dist/ggui-session-channel/subscribe.js +97 -36
  27. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
  28. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  29. package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
  30. package/dist/ggui-session-channel.d.ts +58 -11
  31. package/dist/ggui-session-channel.d.ts.map +1 -1
  32. package/dist/ggui-session-channel.js +55 -19
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -1
  36. package/dist/instructions-presets.js +10 -10
  37. package/dist/mcp-apps-outbound.d.ts +93 -4
  38. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  39. package/dist/mcp-apps-outbound.js +460 -105
  40. package/dist/mcp-endpoint-routes.d.ts +17 -5
  41. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  42. package/dist/mcp-endpoint-routes.js +2 -0
  43. package/dist/oauth-as-routes.d.ts.map +1 -1
  44. package/dist/oauth-as-routes.js +36 -0
  45. package/dist/oauth.d.ts.map +1 -1
  46. package/dist/oauth.js +8 -1
  47. package/dist/runtime-bundle-hash.d.ts +43 -0
  48. package/dist/runtime-bundle-hash.d.ts.map +1 -0
  49. package/dist/runtime-bundle-hash.js +66 -0
  50. package/dist/runtime-bundle-route.d.ts +15 -0
  51. package/dist/runtime-bundle-route.d.ts.map +1 -1
  52. package/dist/runtime-bundle-route.js +21 -5
  53. package/dist/server.d.ts +83 -14
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +167 -15
  56. package/package.json +12 -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, 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, 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
  }
@@ -262,26 +294,32 @@ window.addEventListener('message',function(ev){
262
294
  // above.
263
295
  }
264
296
  });
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
- });
297
+ // Named so the failure card's Retry can re-run the whole handshake —
298
+ // a host that missed or rejected the first ui/initialize may answer a
299
+ // second (observed with hosts that attach their listener late).
300
+ function startInit(){
301
+ setOverlay('Initializing…');
302
+ var initTimer=setTimeout(function(){
303
+ showFailure('This view did not hear back from its host','INIT_TIMEOUT: no response to ui/initialize within 3s.',startInit);
304
+ },3000);
305
+ postRpc('ui/initialize',{
306
+ appCapabilities:{},
307
+ appInfo:{name:'ggui-render',version:'1.0.0'},
308
+ protocolVersion:'2026-01-26'
309
+ }).then(function(){
310
+ clearTimeout(initTimer);
311
+ postNotification('ui/notifications/initialized',{});
312
+ // Wait for the host to send ui/notifications/tool-result carrying
313
+ // the slice envelope in _meta — the spec-canonical delivery channel.
314
+ // The ui/initialize result itself carries no slice meta (the
315
+ // McpUiInitializeResult schema defines no such field).
316
+ setOverlay('Waiting for tool result…');
317
+ }).catch(function(e){
318
+ clearTimeout(initTimer);
319
+ showFailure('This view could not connect to its host','ui/initialize failed: '+(e&&e.message||JSON.stringify(e)),startInit);
320
+ });
321
+ }
322
+ startInit();
285
323
  })();
286
324
  `;
287
325
  // `--ggui-color-surface` is injected at `:root` on this document's
@@ -373,6 +411,77 @@ export const GGUI_RENDER_SHELL_HTML = `<!doctype html>
373
411
  export const GGUI_RENDER_SHELL_SCRIPT_HASH = `'sha256-${createHash("sha256")
374
412
  .update(GGUI_RENDER_SHELL_SCRIPT_BODY)
375
413
  .digest("base64")}'`;
414
+ /**
415
+ * Bootstrap `<script>` body of the INLINE-RUNTIME shell (see
416
+ * {@link buildInlineRenderShellHtml}). Runs BEFORE the (large) inline
417
+ * runtime module parses and does exactly two things:
418
+ *
419
+ * 1. Installs the `window.__GGUI_PENDING_TOOL_RESULTS__` buffer the
420
+ * iframe-runtime's autostart drains (`readPendingToolResults`
421
+ * contract: an array whose elements are the RAW
422
+ * `ui/notifications/tool-result` JSON-RPC `params` values, arrival
423
+ * order, capped so a long-lived host session cannot grow it
424
+ * unboundedly).
425
+ * 2. Sends the `ui/initialize` → `ui/notifications/initialized`
426
+ * preflight. Load-bearing against a mutual 30s stall: spec hosts
427
+ * gate tool-result delivery BEHIND the handshake, while the
428
+ * runtime's autostart waits for a tool-result before its own
429
+ * handshake runs. The runtime repeats `ui/initialize` when it
430
+ * boots; MCP Apps hosts handle the repeat idempotently (same
431
+ * preflight pattern as the thin shell above).
432
+ *
433
+ * No overlay, no meta inspection, no runtime loading — the runtime is
434
+ * inline in the same document and owns everything else.
435
+ */
436
+ const GGUI_INLINE_SHELL_BUFFER_SCRIPT_BODY = `
437
+ (function(){'use strict';
438
+ var buf=window.__GGUI_PENDING_TOOL_RESULTS__=window.__GGUI_PENDING_TOOL_RESULTS__||[];
439
+ window.addEventListener('message',function(ev){
440
+ var m=ev&&ev.data;
441
+ if(!m||m.jsonrpc!=='2.0'||m.method!=='ui/notifications/tool-result')return;
442
+ buf.push(m.params);
443
+ if(buf.length>8)buf.splice(0,buf.length-8);
444
+ });
445
+ try{
446
+ 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'}},'*');
447
+ window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}},'*');
448
+ }catch(e){}
449
+ })();
450
+ `;
451
+ /**
452
+ * Build the INLINE-RUNTIME static shell: a standalone document carrying
453
+ * the iframe-runtime bundle in its own bytes instead of an external
454
+ * `<script src>` tag. For MCP Apps hosts whose iframe CSP forbids
455
+ * external `script-src` while permitting inline scripts — the thin
456
+ * postMessage shell can never load its runtime there, so the shell IS
457
+ * the runtime.
458
+ *
459
+ * Per-render state does NOT live here (same posture as the thin
460
+ * shell): the host delivers it via `ui/notifications/tool-result`,
461
+ * caught either by the buffer script (pre-parse arrivals) or by the
462
+ * runtime's own autostart listener. Live-channel / codeUrl fetches are
463
+ * unavailable under the CSP this shell targets; delivered meta is
464
+ * expected to carry the fetch-free channels (inline `codeB64`, inline
465
+ * `propsJson`).
466
+ *
467
+ * Served per-mount via `installMcpAppsOutbound({ shellHtml })` — the
468
+ * module-level thin-shell constants (and their pinned CSP hash) are
469
+ * deliberately untouched.
470
+ */
471
+ export function buildInlineRenderShellHtml(runtimeSource) {
472
+ // No anchor div: the runtime appends its own mount target to
473
+ // `document.body` at boot, so a thin-shell-style `#ggui-root`
474
+ // placeholder here would just stack empty space ABOVE the rendered
475
+ // card (min-height'd blank div + content below it — the "long upper
476
+ // space" bug from the first claude.ai live test). Same
477
+ // no-container posture as `gguiShellHtml`; the shell marker rides
478
+ // on `<body>`.
479
+ return `<!doctype html>
480
+ <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>
481
+ <body style="margin:0;background-color:${GGUI_RENDER_SHELL_SURFACE}" data-ggui-shell="inline">
482
+ <script>${GGUI_INLINE_SHELL_BUFFER_SCRIPT_BODY}</script>
483
+ <script type="module" data-ggui-runtime="inline">${escapeInlineScript(runtimeSource)}</script></body></html>`;
484
+ }
376
485
  /**
377
486
  * Register `ui://ggui/render` as a readable resource on an `McpServer`.
378
487
  *
@@ -419,7 +528,17 @@ function buildCspMeta(publicBaseUrl,
419
528
  * references `:6786/_ggui/iframe-runtime.js`) trips a `script-src`
420
529
  * violation that blanks the iframe — verified live 2026-05-27.
421
530
  */
422
- runtimeUrl) {
531
+ runtimeUrl,
532
+ /**
533
+ * Origins of server-stamped fetch/stream URLs (`sseUrl`,
534
+ * `pollingUrl`) — each parseable entry's origin is unioned into
535
+ * `connectDomains` (deduplicated). Same-origin deployments add
536
+ * nothing new; split-origin session APIs (dedicated streaming host)
537
+ * would otherwise be silently blocked by `connect-src` on
538
+ * spec-compliant hosts — EventSource and fetch are both
539
+ * connect-src-governed.
540
+ */
541
+ extraConnectUrls) {
423
542
  const source = publicBaseUrl ?? runtimeUrl;
424
543
  if (!source)
425
544
  return undefined;
@@ -428,10 +547,26 @@ runtimeUrl) {
428
547
  const origin = parsed.origin;
429
548
  const wsScheme = parsed.protocol === "https:" ? "wss:" : "ws:";
430
549
  const wsOrigin = `${wsScheme}//${parsed.host}`;
550
+ const connectDomains = [origin, wsOrigin];
551
+ for (const url of extraConnectUrls ?? []) {
552
+ if (url === undefined)
553
+ continue;
554
+ let extraOrigin;
555
+ try {
556
+ extraOrigin = new URL(url).origin;
557
+ }
558
+ catch {
559
+ // Unparseable stamped URL — nothing to declare for it; the
560
+ // base declaration stands.
561
+ continue;
562
+ }
563
+ if (!connectDomains.includes(extraOrigin))
564
+ connectDomains.push(extraOrigin);
565
+ }
431
566
  return {
432
567
  ui: {
433
568
  csp: {
434
- connectDomains: [origin, wsOrigin],
569
+ connectDomains,
435
570
  resourceDomains: [origin],
436
571
  },
437
572
  },
@@ -441,53 +576,66 @@ runtimeUrl) {
441
576
  return undefined;
442
577
  }
443
578
  }
444
- export function registerGguiRenderResource(server, shellHtml = GGUI_RENDER_SHELL_HTML, publicBaseUrl) {
445
- let cspMeta;
446
- if (publicBaseUrl) {
447
- try {
448
- const parsed = new URL(publicBaseUrl);
449
- const origin = parsed.origin;
450
- // CSP `connect-src` does NOT cross-translate between `https://`
451
- // and `wss://` — they're independent URL schemes for the
452
- // browser's URL-match algorithm. Declaring ONLY the HTTPS
453
- // origin will leave WebSocket subscribes (`wss://<same-host>/ws`)
454
- // blocked by hosts that compose strict CSPs from this
455
- // `connectDomains` list (claude.ai's iframe is the live
456
- // diagnosis case). Declare BOTH schemes so the same physical
457
- // origin is reachable via HTTPS (`/api/bootstrap`, `/_ggui/
458
- // iframe-runtime.js`) AND wss (live-channel subscribe).
459
- const wsScheme = parsed.protocol === "https:" ? "wss:" : "ws:";
460
- const wsOrigin = `${wsScheme}//${parsed.host}`;
461
- cspMeta = {
462
- ui: {
463
- csp: {
464
- connectDomains: [origin, wsOrigin],
465
- resourceDomains: [origin],
466
- },
467
- },
468
- };
469
- }
470
- catch {
471
- // Malformed `publicBaseUrl` — leave `_meta.ui.csp` off rather
472
- // than emitting a broken declaration. The host falls back to its
473
- // restrictive default and operators get the same observable
474
- // failure they'd get from any other malformed URL setting.
475
- cspMeta = undefined;
476
- }
477
- }
579
+ export function registerGguiRenderResource(server, shellHtml = GGUI_RENDER_SHELL_HTML, publicBaseUrl,
580
+ /**
581
+ * Absolute runtime-bundle URL used as the CSP-declaration fallback
582
+ * when `publicBaseUrl` is absent — same posture as the per-render
583
+ * template registration ({@link buildCspMeta}'s second parameter).
584
+ * Deployments that publish an absolute `runtime.url` but
585
+ * deliberately do NOT set `publicBaseUrl` (it also feeds
586
+ * Origin/Host enforcement and OAuth) still get
587
+ * `_meta.ui.csp.{connectDomains,resourceDomains}` on the static
588
+ * resource read; before this fallback those reads carried no
589
+ * declaration at all and spec-compliant hosts applied the
590
+ * restrictive default (`connect-src 'none'`).
591
+ */
592
+ runtimeUrl,
593
+ /**
594
+ * Additional URLs whose origins the mounted iframe must be able to
595
+ * `connect-src` — unioned into `connectDomains` by
596
+ * {@link buildCspMeta}. The load-bearing entries are the live-channel
597
+ * origins (`wsUrl` + its ws→http origin flip): the STATIC shell is
598
+ * the resource cross-origin hosts (claude.ai) mount and derive the
599
+ * frame CSP from, so origins declared only on per-render resources
600
+ * never reach the frame. Without these, deployments that set no
601
+ * `publicBaseUrl` (the cloud pod — it feeds Origin/Host enforcement)
602
+ * declare only the runtime-CDN origin and every SSE / HTTP-polling /
603
+ * WS rung of the failover ladder is CSP-blocked in the mounted
604
+ * iframe — observed live on claude.ai (#471 round 11: frame booted
605
+ * with `connect-src assets.mcp.ggui.ai` only).
606
+ */
607
+ extraConnectUrls) {
608
+ const cspMeta = buildCspMeta(publicBaseUrl, runtimeUrl, extraConnectUrls);
609
+ // Content-addressed shell URI (2026-08-12, the stale-shell bust).
610
+ // Hosts cache the prefetched shell keyed on the RESOURCE URI —
611
+ // claude.ai's backend was observed serving days-old shell bytes
612
+ // across our deploys, fresh pages, and connector re-connects,
613
+ // because `ui://ggui/render` never changes. Hash the FULL SERVED
614
+ // REPRESENTATION — shell bytes (wrapper + any inlined runtime) AND
615
+ // the `_meta.ui.csp` declaration — into the advertised URI so any
616
+ // change a host may have cached mints a NEW URI, and an unchanged
617
+ // one never does; the same content-address discipline the hashed
618
+ // `/_ggui/iframe-runtime.<sha12>.js` HTTP route applies one layer
619
+ // down. The meta MUST be in the hash input: hosts cache the
620
+ // declaration alongside the bytes (claude.ai derives the frame's
621
+ // connect-src from it), so a meta-only change — e.g. adding the
622
+ // live-channel origins to `connectDomains` — would otherwise ship a
623
+ // new policy under an old URI and never reach cached frames. The
624
+ // bare URI stays registered for grandfathered sessions and hosts
625
+ // that read it directly.
626
+ const shellHash = createHash("sha256")
627
+ .update(shellHtml)
628
+ .update(JSON.stringify(cspMeta ?? null))
629
+ .digest("hex")
630
+ .slice(0, 12);
631
+ const versionedUri = `${GGUI_RENDER_RESOURCE_URI}/rt-${shellHash}`;
478
632
  // `registerAppResource` (from `@modelcontextprotocol/ext-apps/server`)
479
633
  // defaults `mimeType` to `RESOURCE_MIME_TYPE` — the same
480
634
  // `text/html;profile=mcp-app` value `GGUI_RENDER_RESOURCE_MIME`
481
635
  // carries. Letting the canonical helper own the default means the
482
636
  // mimeType string lives in ONE place across the ecosystem (the SDK)
483
637
  // rather than duplicated in our protocol package.
484
- registerAppResource(server, "ggui-render", GGUI_RENDER_RESOURCE_URI, {
485
- // `title` / `description` show up in MCP clients that surface
486
- // resource metadata. Short + concrete.
487
- title: "ggui render",
488
- 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.",
489
- mimeType: GGUI_RENDER_RESOURCE_MIME,
490
- }, async (uri) => ({
638
+ const serveShell = async (uri) => ({
491
639
  contents: [
492
640
  {
493
641
  uri: uri.href,
@@ -496,7 +644,44 @@ export function registerGguiRenderResource(server, shellHtml = GGUI_RENDER_SHELL
496
644
  ...(cspMeta !== undefined ? { _meta: cspMeta } : {}),
497
645
  },
498
646
  ],
499
- }));
647
+ });
648
+ registerAppResource(server, "ggui-render", GGUI_RENDER_RESOURCE_URI, {
649
+ // `title` / `description` show up in MCP clients that surface
650
+ // resource metadata. Short + concrete.
651
+ title: "ggui render",
652
+ 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.",
653
+ mimeType: GGUI_RENDER_RESOURCE_MIME,
654
+ }, serveShell);
655
+ registerAppResource(server, "ggui-render-versioned", versionedUri, {
656
+ title: "ggui render (content-addressed)",
657
+ 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.",
658
+ mimeType: GGUI_RENDER_RESOURCE_MIME,
659
+ }, serveShell);
660
+ // STALE-HASH grandfather template — `rt-{shellHash}` for ANY hash.
661
+ // Hosts snapshot tool declarations (claude.ai stores the connector's
662
+ // tool list server-side), so after a shell-changing deploy they keep
663
+ // asking for the PREVIOUS deploy's versioned URI. Without this
664
+ // template that read falls through to the per-session
665
+ // `ui://ggui/render/{sessionId}` template (a bare `rt-abc…` segment
666
+ // parses as a sessionId), resolves no render, and the host shows
667
+ // "unable to reach" — observed live on claude.ai the first deploy
668
+ // after the URI scheme changed (#471 round 12). Serving the CURRENT
669
+ // shell under the stale URI restores the pre-content-addressing
670
+ // behavior for stale hosts (at worst they cache today's shell under
671
+ // yesterday's key) while fresh declarations keep the cache-bust
672
+ // property. MUST register before the session templates
673
+ // (`installMcpAppsOutbound` orders this call first; the SDK matches
674
+ // templates in registration order).
675
+ server.registerResource("ggui-render-versioned-grandfather", new ResourceTemplate(`${GGUI_RENDER_RESOURCE_URI}/rt-{shellHash}`, {
676
+ // No list-callback — same posture as the session templates; the
677
+ // canonical URI is the one advertised on tool declarations.
678
+ list: undefined,
679
+ }), {
680
+ title: "ggui render (content-addressed, any revision)",
681
+ 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.",
682
+ mimeType: GGUI_RENDER_RESOURCE_MIME,
683
+ }, serveShell);
684
+ return versionedUri;
500
685
  }
501
686
  /**
502
687
  * Advertise the `io.modelcontextprotocol/ui` extension capability on
@@ -549,12 +734,13 @@ export function buildSelfContainedShell(opts) {
549
734
  // picks per its priority order.
550
735
  const isSystem = typeof opts.systemKind === "string" && opts.systemKind.length > 0;
551
736
  const hasCodeUrl = typeof opts.codeUrl === "string" && opts.codeUrl.length > 0;
737
+ const hasCodeB64 = typeof opts.codeB64 === "string" && opts.codeB64.length > 0;
552
738
  const hasLive = typeof opts.wsUrl === "string" &&
553
739
  opts.wsUrl.length > 0 &&
554
740
  typeof opts.token === "string" &&
555
741
  opts.token.length > 0;
556
- if (!isSystem && !hasCodeUrl && !hasLive) {
557
- throw new Error("buildSelfContainedShell: at least one of `codeUrl`, `systemKind`, or live-mode (`wsUrl` + `token`) must be set");
742
+ if (!isSystem && !hasCodeUrl && !hasCodeB64 && !hasLive) {
743
+ throw new Error("buildSelfContainedShell: at least one of `codeUrl`, `codeB64`, `systemKind`, or live-mode (`wsUrl` + `token`) must be set");
558
744
  }
559
745
  // Build the single render slice (Phase B: ai.ggui/render collapsed
560
746
  // the prior ai.ggui/session + ai.ggui/stack-item pair into one flat
@@ -604,7 +790,11 @@ export function buildSelfContainedShell(opts) {
604
790
  // WS-only mode (legacy behavior). See SelfContainedShellInputs
605
791
  // .pollingUrl for the URL shape.
606
792
  ...(opts.pollingUrl !== undefined ? { pollingUrl: opts.pollingUrl } : {}),
793
+ // SSE middle rung — same stamping posture as pollingUrl. See
794
+ // SelfContainedShellInputs.sseUrl for the stream contract.
795
+ ...(opts.sseUrl !== undefined ? { sseUrl: opts.sseUrl } : {}),
607
796
  ...(opts.lastSequence !== undefined ? { lastSequence: opts.lastSequence } : {}),
797
+ ...(opts.epoch !== undefined ? { epoch: opts.epoch } : {}),
608
798
  // Visible-bits surface — what the iframe is mounting right now.
609
799
  // Static-content discriminators (codeUrl / kind) are mutually
610
800
  // exclusive; the iframe-runtime rejects the both-set mix.
@@ -615,6 +805,7 @@ export function buildSelfContainedShell(opts) {
615
805
  ...(opts.codeHash !== undefined ? { codeHash: opts.codeHash } : {}),
616
806
  }
617
807
  : {}),
808
+ ...(!isSystem && hasCodeB64 ? { codeB64: opts.codeB64 } : {}),
618
809
  ...(opts.propsJson !== undefined ? { propsJson: opts.propsJson } : {}),
619
810
  ...(opts.contextSlots !== undefined && opts.contextSlots.length > 0
620
811
  ? { contextSlots: opts.contextSlots }
@@ -912,18 +1103,24 @@ export function registerGguiRenderResourceTemplate(server, opts) {
912
1103
  * claude.ai's iframe CSP and the component fails to render. Returns
913
1104
  * `undefined` when there's no base CSP at all (publicBaseUrl
914
1105
  * absent — first-party same-origin host).
1106
+ *
1107
+ * `base` defaults to the registration-time `templateCspMeta`;
1108
+ * `serveMount` passes a per-render base recomputed with the stamped
1109
+ * session-API URLs (`sseUrl` / `pollingUrl`) so their origins ride
1110
+ * `connectDomains` even when they differ from the publicBaseUrl
1111
+ * origin (ws→http origin-flip fallback).
915
1112
  */
916
- const augmentCspMeta = (gadgetOrigins) => {
917
- if (templateCspMeta === undefined)
1113
+ const augmentCspMeta = (gadgetOrigins, base = templateCspMeta) => {
1114
+ if (base === undefined)
918
1115
  return undefined;
919
1116
  if (gadgetOrigins === undefined)
920
- return templateCspMeta;
1117
+ return base;
921
1118
  return {
922
1119
  ui: {
923
1120
  csp: {
924
- connectDomains: [...templateCspMeta.ui.csp.connectDomains, ...gadgetOrigins.connect],
1121
+ connectDomains: [...base.ui.csp.connectDomains, ...gadgetOrigins.connect],
925
1122
  resourceDomains: [
926
- ...templateCspMeta.ui.csp.resourceDomains,
1123
+ ...base.ui.csp.resourceDomains,
927
1124
  ...gadgetOrigins.script,
928
1125
  ...gadgetOrigins.style,
929
1126
  ],
@@ -1429,6 +1626,20 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1429
1626
  channelFault ??= { cause };
1430
1627
  }
1431
1628
  }
1629
+ // Token-bearing session-API URL pair (pollingUrl + sseUrl) —
1630
+ // composed via the protocol's ONE composer so this surface cannot
1631
+ // drift from the render/update resultMeta stamping. Stamped only
1632
+ // when the mint above produced a token (both URLs embed it); base
1633
+ // = publicBaseUrl when configured, else the ws→http origin flip
1634
+ // of the minted wsUrl (session API served on the WS origin — OSS
1635
+ // defaults + the cloud pod's single ingress).
1636
+ let sessionApiUrls;
1637
+ if (wsToken !== undefined) {
1638
+ const base = opts.publicBaseUrl ?? (wsUrl !== undefined ? wsOriginToHttpOrigin(wsUrl) : undefined);
1639
+ if (base !== undefined) {
1640
+ sessionApiUrls = composeSessionApiUrls(base, sessionId, wsToken);
1641
+ }
1642
+ }
1432
1643
  // Mount-mode gate (below the live-channel mint): a compiled
1433
1644
  // component needs ONE of the two channels. A deployment that wires
1434
1645
  // no codeStore (codeUrl === undefined) but DOES wire mintWsToken
@@ -1448,7 +1659,10 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1448
1659
  // else produced a channel. Anywhere above this line the same fault
1449
1660
  // may have been survivable, and on a deployment wiring both
1450
1661
  // channels it usually is.
1451
- if (!isSystem && codeUrl === undefined && (wsUrl === undefined || wsToken === undefined)) {
1662
+ if (!isSystem &&
1663
+ codeUrl === undefined &&
1664
+ view.codeB64 === undefined &&
1665
+ (wsUrl === undefined || wsToken === undefined)) {
1452
1666
  if (channelFault !== undefined)
1453
1667
  throw channelFault.cause;
1454
1668
  throw new ResourceReadFailure(NO_DELIVERY_CHANNEL_FAILURE);
@@ -1458,15 +1672,20 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1458
1672
  appId: accessibleStored.appId,
1459
1673
  ...(isSystem
1460
1674
  ? { systemKind: picked.kind }
1461
- : codeUrl !== undefined
1462
- ? {
1463
- codeUrl,
1464
- ...(codeHash !== undefined ? { codeHash } : {}),
1465
- }
1466
- : // No static codeUrl → live-mode (wsUrl + token spread below)
1467
- // carries the render; buildSelfContainedShell accepts
1468
- // live-mode without codeUrl.
1469
- {}),
1675
+ : {
1676
+ ...(codeUrl !== undefined
1677
+ ? {
1678
+ codeUrl,
1679
+ ...(codeHash !== undefined ? { codeHash } : {}),
1680
+ }
1681
+ : {}),
1682
+ // Inline fetch-free channel (size-capped, projected by
1683
+ // `deriveRenderMeta`). Independent of the codeStore, so a
1684
+ // dead/unwired store still yields a mountable shell — and
1685
+ // hosts whose iframe CSP blocks the codeUrl fetch decode
1686
+ // this instead.
1687
+ ...(view.codeB64 !== undefined ? { codeB64: view.codeB64 } : {}),
1688
+ }),
1470
1689
  runtimeUrl: opts.runtimeUrl,
1471
1690
  ...(wsUrl !== undefined && wsToken !== undefined
1472
1691
  ? {
@@ -1477,24 +1696,23 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1477
1696
  : {}),
1478
1697
  ...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
1479
1698
  ...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
1480
- // Per-app theme overlay projected by `deriveRenderMeta` from
1481
- // the render's `theme` sidecar — forwarded so the
1482
- // resource-served iframe matches the postMessage path.
1483
- ...(view.theme !== undefined ? { theme: view.theme } : {}),
1484
- ...(view.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
1485
- ...(view.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
1486
- ...(view.permissionsPolicy !== undefined
1487
- ? { permissionsPolicy: view.permissionsPolicy }
1488
- : {}),
1489
- ...(view.gadgets !== undefined && view.gadgets.length > 0
1490
- ? { gadgets: view.gadgets }
1491
- : {}),
1699
+ // State + policy view fields (theme overlay, propsJson,
1700
+ // contextSlots, permissionsPolicy, gadgets, #483 epoch) — ONE
1701
+ // shared spread so the resource-served shell cannot drift from
1702
+ // the postMessage-path emitters.
1703
+ ...spreadRenderMetaViewOntoSlice(view),
1492
1704
  ...(contractHash !== undefined && validatorsUrl !== undefined
1493
1705
  ? { contractHash, validatorsUrl }
1494
1706
  : {}),
1495
1707
  ...(resourcePublicEnv !== undefined && Object.keys(resourcePublicEnv).length > 0
1496
1708
  ? { publicEnv: resourcePublicEnv }
1497
1709
  : {}),
1710
+ // Token-bearing HTTP fallback rungs — present exactly when the
1711
+ // mint above produced a token and a base resolved (see the
1712
+ // composition above the mount-mode gate).
1713
+ ...(sessionApiUrls !== undefined
1714
+ ? { pollingUrl: sessionApiUrls.pollingUrl, sseUrl: sessionApiUrls.sseUrl }
1715
+ : {}),
1498
1716
  // R6 — ledger cursor stamp for polling-cursor alignment.
1499
1717
  lastSequence: accessibleStored.eventSequence,
1500
1718
  });
@@ -1508,21 +1726,121 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1508
1726
  // derives these via deriveBundleOrigins; this is the per-call
1509
1727
  // resource mirror.
1510
1728
  const gadgetOrigins = deriveBundleOrigins(picked.source);
1511
- return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
1729
+ // Per-render CSP base: recompute with the stamped session-API
1730
+ // URLs so their origins ride `connectDomains` (EventSource +
1731
+ // fetch are connect-src-governed). Same-origin stamps dedupe to
1732
+ // the registration-time declaration; a base derived from the
1733
+ // wsUrl origin flip (publicBaseUrl absent) adds the flip origin
1734
+ // that would otherwise be silently blocked.
1735
+ const renderCspBase = sessionApiUrls !== undefined
1736
+ ? buildCspMeta(opts.publicBaseUrl, opts.runtimeUrl, [
1737
+ sessionApiUrls.sseUrl,
1738
+ sessionApiUrls.pollingUrl,
1739
+ ])
1740
+ : templateCspMeta;
1741
+ return shellContents(uri, html, augmentCspMeta(gadgetOrigins, renderCspBase));
1742
+ }
1743
+ /**
1744
+ * Reconstruct the props of history record `#epoch` from the event
1745
+ * ledger (#483): walk ascending, apply every `ui.updated`, and stop
1746
+ * at the `ui.reminted` boundary that LEAVES the pinned epoch
1747
+ * (`data.epoch === epoch + 1`) — so the record includes the amends
1748
+ * made during its reign, matching the live freeze semantics
1749
+ * (state-at-supersession). Returns `null` when the walk cannot reach
1750
+ * the boundary (ledger horizon evicted the record's reign) — the
1751
+ * caller surfaces the standard not-found posture; the record aged
1752
+ * out of what this server can serve.
1753
+ */
1754
+ async function reconstructPropsAtEpoch(sessionId, epoch) {
1755
+ let since = 0;
1756
+ let currentProps = null;
1757
+ for (;;) {
1758
+ const page = await opts.renderStore.listEventsSince(sessionId, since, 200);
1759
+ if (page === null || page.events.length === 0)
1760
+ return null;
1761
+ for (const event of page.events) {
1762
+ if (event.type === "ui.updated") {
1763
+ const data = event.data;
1764
+ // Epoch-stamped filtering: the update that MINTS epoch N+1
1765
+ // appends its props event (stamped N+1) BEFORE the N+1
1766
+ // boundary — those props belong to the NEXT record, never
1767
+ // to #N. Pre-#483 events carry no stamp and read as
1768
+ // belonging to the then-current (≤ pinned) epoch.
1769
+ if ((data.epoch ?? 0) <= epoch) {
1770
+ currentProps = data.props;
1771
+ }
1772
+ }
1773
+ else if (event.type === "ui.reminted") {
1774
+ const data = event.data;
1775
+ if (data.epoch === epoch + 1)
1776
+ return currentProps;
1777
+ }
1778
+ since = event.seq;
1779
+ }
1780
+ if (!page.hasMore && page.lastSequence <= since)
1781
+ return null;
1782
+ }
1512
1783
  }
1513
1784
  // Single shared handler powers both templates. `blueprintKey` is
1514
1785
  // optional in the variables map — present for the resume URI shape,
1515
1786
  // absent for the legacy single-segment shape.
1516
1787
  async function handle(uri, variables) {
1517
1788
  const sessionIdRaw = variables["sessionId"];
1518
- const sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
1789
+ let sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
1790
+ const blueprintKeyRaw = variables["blueprintKey"];
1791
+ let blueprintKey = Array.isArray(blueprintKeyRaw) ? blueprintKeyRaw[0] : blueprintKeyRaw;
1792
+ // Epoch pin (#483): `…#N` names the immutable history record N;
1793
+ // bare names the live head. Depending on the transport's URL
1794
+ // handling the pin may arrive as `uri.hash`, glued RAW onto the
1795
+ // last matched variable, or PERCENT-ENCODED inside it (`%23N`) —
1796
+ // resolve all three tolerantly via the one seam, cleaning the
1797
+ // variable either way.
1798
+ const parsePin = (segment) => {
1799
+ const direct = parseEpochUri(segment);
1800
+ if (direct.epoch !== undefined) {
1801
+ return { base: direct.baseUri, epoch: direct.epoch };
1802
+ }
1803
+ try {
1804
+ const decoded = decodeURIComponent(segment);
1805
+ if (decoded !== segment) {
1806
+ const parsed = parseEpochUri(decoded);
1807
+ if (parsed.epoch !== undefined) {
1808
+ return { base: parsed.baseUri, epoch: parsed.epoch };
1809
+ }
1810
+ }
1811
+ }
1812
+ catch {
1813
+ // Malformed percent-encoding — not a pin; segment passes
1814
+ // through whole (same tolerant posture as parseEpochUri).
1815
+ }
1816
+ return { base: segment };
1817
+ };
1818
+ // ALWAYS clean the variables (transports have been observed to
1819
+ // deliver the pin BOTH as uri.hash and glued raw onto the matched
1820
+ // variable); the pin resolves from whichever source carried it.
1821
+ let pinnedEpoch;
1822
+ if (uri.hash.length > 1) {
1823
+ pinnedEpoch = parseEpochUri(`x${uri.hash}`).epoch;
1824
+ }
1825
+ if (typeof blueprintKey === "string") {
1826
+ const parsed = parsePin(blueprintKey);
1827
+ if (parsed.epoch !== undefined) {
1828
+ pinnedEpoch = pinnedEpoch ?? parsed.epoch;
1829
+ blueprintKey = parsed.base;
1830
+ }
1831
+ }
1832
+ if (typeof sessionId === "string") {
1833
+ const parsed = parsePin(sessionId);
1834
+ if (parsed.epoch !== undefined) {
1835
+ pinnedEpoch = pinnedEpoch ?? parsed.epoch;
1836
+ sessionId = parsed.base;
1837
+ }
1838
+ }
1519
1839
  if (typeof sessionId !== "string" || sessionId.length === 0) {
1520
1840
  // A URI with no session segment names no locator, which is the
1521
1841
  // same thing as naming one that does not exist.
1522
1842
  throw new ResourceReadFailure(NOT_FOUND_FAILURE);
1523
1843
  }
1524
- const blueprintKeyRaw = variables["blueprintKey"];
1525
- const blueprintKey = Array.isArray(blueprintKeyRaw) ? blueprintKeyRaw[0] : blueprintKeyRaw;
1526
1844
  const hasResumeKey = typeof blueprintKey === "string" && blueprintKey.length > 0;
1527
1845
  // The failure this read ends in if nothing mounts. Seeded from a
1528
1846
  // property of the SERVER, never of the locator, so a caller cannot
@@ -1555,6 +1873,38 @@ export function registerGguiRenderResourceTemplate(server, opts) {
1555
1873
  })
1556
1874
  ? stored
1557
1875
  : null;
1876
+ // Pinned history read (#483): `#N` where N is a SUPERSEDED epoch
1877
+ // reconstructs that record's props from the ledger and serves a
1878
+ // shell frozen at them. N === head falls through to the live
1879
+ // mount (the pinned URI of the current head IS the head); N >
1880
+ // head names a record that does not exist.
1881
+ if (accessibleStored && pinnedEpoch !== undefined) {
1882
+ const headEpoch = accessibleStored.render.epoch ?? 0;
1883
+ if (pinnedEpoch > headEpoch) {
1884
+ throw new ResourceReadFailure(NOT_FOUND_FAILURE);
1885
+ }
1886
+ if (pinnedEpoch < headEpoch && accessibleStored.render.type !== "mcpApps") {
1887
+ const historicalProps = await reconstructPropsAtEpoch(sessionId, pinnedEpoch);
1888
+ if (historicalProps === null) {
1889
+ // The record's reign aged out of the ledger horizon — this
1890
+ // server can no longer serve it. Same terminal posture as a
1891
+ // locator that never existed (see #483 SPEC note).
1892
+ throw new ResourceReadFailure(failure);
1893
+ }
1894
+ const pinnedRow = {
1895
+ ...accessibleStored,
1896
+ render: {
1897
+ ...accessibleStored.render,
1898
+ props: historicalProps,
1899
+ epoch: pinnedEpoch,
1900
+ },
1901
+ };
1902
+ const served = await serveMount(uri, sessionId, pinnedRow);
1903
+ if (served !== null)
1904
+ return served;
1905
+ throw new ResourceReadFailure(failure);
1906
+ }
1907
+ }
1558
1908
  // Live state first: render present and renderable mounts with the
1559
1909
  // current props + current contextSpec values.
1560
1910
  if (accessibleStored) {
@@ -1738,8 +2088,13 @@ function deriveDefaultContextSlots(spec) {
1738
2088
  */
1739
2089
  export function installMcpAppsOutbound(server, opts = {}) {
1740
2090
  advertiseMcpAppsUiCapability(server);
1741
- registerGguiRenderResource(server, opts.shellHtml, opts.publicBaseUrl);
2091
+ // The self-contained template's absolute runtimeUrl doubles as the
2092
+ // static registration's CSP-declaration fallback — deployments that
2093
+ // set no `publicBaseUrl` (it also feeds Origin/Host enforcement +
2094
+ // OAuth) still declare their origin to spec-compliant hosts.
2095
+ const shellResourceUri = registerGguiRenderResource(server, opts.shellHtml, opts.publicBaseUrl, opts.selfContained?.runtimeUrl, opts.extraConnectUrls);
1742
2096
  if (opts.selfContained) {
1743
2097
  registerGguiRenderResourceTemplate(server, opts.selfContained);
1744
2098
  }
2099
+ return { shellResourceUri };
1745
2100
  }