@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.
- package/dist/api-renders-routes.d.ts +21 -0
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +54 -28
- package/dist/api-renders-stream-route.d.ts +80 -0
- package/dist/api-renders-stream-route.d.ts.map +1 -0
- package/dist/api-renders-stream-route.js +311 -0
- package/dist/build-mcp.d.ts +48 -7
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +87 -6
- package/dist/code-module-variant.d.ts +150 -0
- package/dist/code-module-variant.d.ts.map +1 -0
- package/dist/code-module-variant.js +243 -0
- package/dist/code-routes.d.ts +12 -2
- package/dist/code-routes.d.ts.map +1 -1
- package/dist/code-routes.js +12 -2
- package/dist/console-session-routes.d.ts.map +1 -1
- package/dist/console-session-routes.js +11 -0
- package/dist/control-service.d.ts +29 -3
- package/dist/control-service.d.ts.map +1 -1
- package/dist/control-service.js +26 -2
- package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
- package/dist/ggui-session-channel/internal-types.d.ts +63 -9
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.d.ts +15 -5
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +64 -24
- package/dist/ggui-session-channel/socket-router.d.ts +8 -3
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
- package/dist/ggui-session-channel/socket-router.js +6 -1
- package/dist/ggui-session-channel/subscribe.d.ts +48 -3
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +97 -36
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
- package/dist/ggui-session-channel.d.ts +58 -11
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +55 -19
- package/dist/health-routes.d.ts +19 -3
- package/dist/health-routes.d.ts.map +1 -1
- package/dist/health-routes.js +26 -18
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/instructions-presets.js +10 -10
- package/dist/mcp-apps-outbound.d.ts +88 -11
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +470 -77
- package/dist/mcp-endpoint-routes.d.ts +23 -5
- package/dist/mcp-endpoint-routes.d.ts.map +1 -1
- package/dist/mcp-endpoint-routes.js +69 -1
- package/dist/oauth-as-routes.d.ts +11 -0
- package/dist/oauth-as-routes.d.ts.map +1 -1
- package/dist/oauth-as-routes.js +45 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/runtime-bundle-hash.d.ts +55 -0
- package/dist/runtime-bundle-hash.d.ts.map +1 -0
- package/dist/runtime-bundle-hash.js +85 -0
- package/dist/runtime-bundle-route.js +1 -1
- package/dist/server.d.ts +239 -61
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +355 -143
- 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:
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
// the
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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="
|
|
436
|
-
<body style="margin:0;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1183
|
+
const augmentCspMeta = (gadgetOrigins, base = templateCspMeta) => {
|
|
1184
|
+
if (base === undefined)
|
|
965
1185
|
return undefined;
|
|
966
1186
|
if (gadgetOrigins === undefined)
|
|
967
|
-
return
|
|
1187
|
+
return base;
|
|
968
1188
|
return {
|
|
969
1189
|
ui: {
|
|
970
1190
|
csp: {
|
|
971
|
-
connectDomains: [...
|
|
1191
|
+
connectDomains: [...base.ui.csp.connectDomains, ...gadgetOrigins.connect],
|
|
972
1192
|
resourceDomains: [
|
|
973
|
-
...
|
|
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
|
-
|
|
1534
|
-
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
...(
|
|
1542
|
-
?
|
|
1543
|
-
:
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
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
|
}
|