@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.
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +32 -23
- 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 +10 -0
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +64 -4
- package/dist/console-session-routes.d.ts +10 -5
- package/dist/console-session-routes.d.ts.map +1 -1
- package/dist/console-session-routes.js +21 -5
- 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/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/instructions-presets.js +10 -10
- package/dist/mcp-apps-outbound.d.ts +93 -4
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +460 -105
- package/dist/mcp-endpoint-routes.d.ts +17 -5
- package/dist/mcp-endpoint-routes.d.ts.map +1 -1
- package/dist/mcp-endpoint-routes.js +2 -0
- package/dist/oauth-as-routes.d.ts.map +1 -1
- package/dist/oauth-as-routes.js +36 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/runtime-bundle-hash.d.ts +43 -0
- package/dist/runtime-bundle-hash.d.ts.map +1 -0
- package/dist/runtime-bundle-hash.js +66 -0
- package/dist/runtime-bundle-route.d.ts +15 -0
- package/dist/runtime-bundle-route.d.ts.map +1 -1
- package/dist/runtime-bundle-route.js +21 -5
- package/dist/server.d.ts +83 -14
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +167 -15
- 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:
|
|
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
|
}
|
|
@@ -262,26 +294,32 @@ window.addEventListener('message',function(ev){
|
|
|
262
294
|
// above.
|
|
263
295
|
}
|
|
264
296
|
});
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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 (
|
|
1113
|
+
const augmentCspMeta = (gadgetOrigins, base = templateCspMeta) => {
|
|
1114
|
+
if (base === undefined)
|
|
918
1115
|
return undefined;
|
|
919
1116
|
if (gadgetOrigins === undefined)
|
|
920
|
-
return
|
|
1117
|
+
return base;
|
|
921
1118
|
return {
|
|
922
1119
|
ui: {
|
|
923
1120
|
csp: {
|
|
924
|
-
connectDomains: [...
|
|
1121
|
+
connectDomains: [...base.ui.csp.connectDomains, ...gadgetOrigins.connect],
|
|
925
1122
|
resourceDomains: [
|
|
926
|
-
...
|
|
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 &&
|
|
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
|
-
:
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
// resource-served
|
|
1483
|
-
|
|
1484
|
-
...(view
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|