@ggui-ai/mcp-server 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- package/package.json +76 -0
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Apps outbound wiring — the server-side half of the
|
|
3
|
+
* `ggui_push -> ui://ggui/session -> iframe -> live channel` delivery path.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities of this module (and nothing else):
|
|
6
|
+
*
|
|
7
|
+
* 1. Advertise the `io.modelcontextprotocol/ui` capability on every
|
|
8
|
+
* fresh `McpServer` instance so MCP Apps hosts know the server
|
|
9
|
+
* speaks the UI extension.
|
|
10
|
+
* 2. Register `ui://ggui/session` as a static resource servable via
|
|
11
|
+
* MCP `resources/read`. The resource body is the thin-shell HTML
|
|
12
|
+
* hosts sandbox-render when they see `_meta.ui.resourceUri` on a
|
|
13
|
+
* `ggui_push` result.
|
|
14
|
+
*
|
|
15
|
+
* Boundary discipline:
|
|
16
|
+
*
|
|
17
|
+
* - This module imports from `@ggui-ai/protocol/integrations/mcp-apps`
|
|
18
|
+
* (the subpath). It does NOT expose MCP-Apps-specific shapes back
|
|
19
|
+
* into `build-mcp.ts`, `server.ts`, or the blueprint handlers.
|
|
20
|
+
* - Capability advertisement and resource registration are the ONLY
|
|
21
|
+
* server-wide concerns here. Bootstrap-token mint + live-channel
|
|
22
|
+
* bootstrap-auth live in separate slices of the same overall
|
|
23
|
+
* outbound path.
|
|
24
|
+
*
|
|
25
|
+
* Why the shell body lives here:
|
|
26
|
+
*
|
|
27
|
+
* The thin shell is static content; it depends on nothing except the
|
|
28
|
+
* MIME constant and the HTML. Keeping it next to the registration
|
|
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
|
|
31
|
+
* build target — per the design lock, the shell is served by the
|
|
32
|
+
* same `@ggui-ai/mcp-server` instance that mints the bootstrap.
|
|
33
|
+
*/
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
36
|
+
import { deriveContextDefault, } from '@ggui-ai/protocol';
|
|
37
|
+
import { deriveStackItemBootstrapView, derivePublicEnvProjection, deriveBundleOrigins, findBlueprintExact, } from '@ggui-ai/mcp-server-handlers/session-mutations';
|
|
38
|
+
import { MCP_APPS_UI_CAPABILITY, GGUI_SESSION_RESOURCE_URI, GGUI_SESSION_RESOURCE_MIME, deriveContextName, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
39
|
+
/**
|
|
40
|
+
* Thin-shell body served from `ui://ggui/session` (C8 pivot).
|
|
41
|
+
*
|
|
42
|
+
* **Architectural role.** The shell is a ~30 LOC bootstrap wrapper -
|
|
43
|
+
* it runs a minimal `ui/initialize` preflight to read
|
|
44
|
+
* `_meta.ggui.bootstrap.runtimeUrl`, then dynamic-script-loads the
|
|
45
|
+
* `@ggui-ai/iframe-runtime` bundle from that URL. Every rendering
|
|
46
|
+
* concern - WS open, subscribe, stack render, component code eval,
|
|
47
|
+
* adapter install - belongs to the renderer bundle (shipped C7a-d),
|
|
48
|
+
* not here.
|
|
49
|
+
*
|
|
50
|
+
* **Why bootstrap-driven URL.** `srcdoc` iframes have `about:srcdoc`
|
|
51
|
+
* as their URL, so relative paths can't resolve to the MCP server's
|
|
52
|
+
* HTTP listener. The server-controlled `runtimeUrl` lands on
|
|
53
|
+
* `_meta.ggui.bootstrap.runtimeUrl` and the shell picks it up from
|
|
54
|
+
* the first `ui/initialize` response - origin-agnostic, works under
|
|
55
|
+
* OSS same-origin AND hosted-cloud CDN deployments.
|
|
56
|
+
*
|
|
57
|
+
* **Why `<script type="module">`.** `@ggui-ai/iframe-runtime` is bundled as
|
|
58
|
+
* ESM (its own runtime.ts:5 declares the contract: "the thin-shell
|
|
59
|
+
* HTML loads it via `<script type="module" src=".../renderer.js">`").
|
|
60
|
+
* Loading the bundle as a classic `<script src=...>` throws
|
|
61
|
+
* `SyntaxError: Unexpected token 'export'` synchronously when the
|
|
62
|
+
* browser parses the bundle - the renderer never executes, the
|
|
63
|
+
* lifecycle never advances past `mounting`, and any host-side spec
|
|
64
|
+
* pinning `data-ggui-mcp-app-iframe-lifecycle="code-ready"` hangs
|
|
65
|
+
* to timeout. The shell honours the renderer's published contract by
|
|
66
|
+
* setting `s.type='module'` before assigning `s.src`.
|
|
67
|
+
*
|
|
68
|
+
* **Failure envelope (C8 Deliverable 4).** Pre-renderer failures
|
|
69
|
+
* surface to the parent via
|
|
70
|
+
* `postMessage({type:'ggui:bootstrap-failed', reason, message}, '*')`:
|
|
71
|
+
*
|
|
72
|
+
* - `BOOTSTRAP_META_MISSING` - `ui/initialize` returned without a
|
|
73
|
+
* valid `_meta.ggui.bootstrap` OR without a `runtimeUrl` field.
|
|
74
|
+
* - `BUNDLE_FETCH_FAILED` - `<script src>` errored (network failure,
|
|
75
|
+
* 404, CSP reject with an observable `error` event).
|
|
76
|
+
*
|
|
77
|
+
* Post-renderer failures (WS handshake / auth / session-mismatch) are
|
|
78
|
+
* the renderer bundle's responsibility - `runtime.ts::postBootFailure`
|
|
79
|
+
* emits the same `ggui:bootstrap-failed` envelope AND, for post-WS-open
|
|
80
|
+
* failures, a `_ggui:contract-error` envelope on the live channel per
|
|
81
|
+
* `ContractErrorCode` additions (C8 Commit 1/3).
|
|
82
|
+
*
|
|
83
|
+
* **Adapter boundary unchanged.** The preflight's `message` listener
|
|
84
|
+
* routes ONLY responses to its own pending JSON-RPC ids. MCP Apps
|
|
85
|
+
* lifecycle notifications from the host (`ui/notifications/message`,
|
|
86
|
+
* `ui/update-model-context`) are dropped. This mirrors the pre-C8
|
|
87
|
+
* posture - the shell still MUST NOT mutate session state from
|
|
88
|
+
* arbitrary host messages. ADAPTER BOUNDARY enforced by the
|
|
89
|
+
* `pending[m.id]` route-by-id check.
|
|
90
|
+
*
|
|
91
|
+
* **Double `ui/initialize` is intentional.** The shell runs a minimal
|
|
92
|
+
* preflight solely to fetch `runtimeUrl`; the renderer bundle's
|
|
93
|
+
* autostart path runs its own `ui/initialize` for the full bootstrap
|
|
94
|
+
* parse. MCP Apps hosts handle repeats idempotently - the preflight's
|
|
95
|
+
* cost is a single postMessage round-trip.
|
|
96
|
+
*
|
|
97
|
+
* Exported as a string constant for tests; not part of the public
|
|
98
|
+
* package API. Vanilla JS, no build step, no external deps. The shell
|
|
99
|
+
* must work in any browser that implements MCP Apps; no modern-ES
|
|
100
|
+
* features.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* Inline body of the thin shell's bootstrap `<script>` block.
|
|
104
|
+
*
|
|
105
|
+
* Split from {@link GGUI_SESSION_SHELL_HTML} so the exact bytes the
|
|
106
|
+
* browser sees inside the `<script>...</script>` tag are addressable
|
|
107
|
+
* for CSP-hash purposes — see {@link GGUI_SESSION_SHELL_SCRIPT_HASH}.
|
|
108
|
+
*
|
|
109
|
+
* The browser's CSP `'sha256-...'` source-expression is computed over
|
|
110
|
+
* the literal text content of the `<script>` element (everything
|
|
111
|
+
* between the opening and closing tags, including leading and trailing
|
|
112
|
+
* whitespace inside). Concatenating this constant unchanged into the
|
|
113
|
+
* shell HTML means the runtime hash and the constant-time hash agree.
|
|
114
|
+
*
|
|
115
|
+
* NEVER mutate this constant without also bumping
|
|
116
|
+
* {@link GGUI_SESSION_SHELL_SCRIPT_HASH} — the
|
|
117
|
+
* `mcp-apps-outbound.test.ts` drift test recomputes the hash and fails
|
|
118
|
+
* loudly if they diverge.
|
|
119
|
+
*/
|
|
120
|
+
const GGUI_SESSION_SHELL_SCRIPT_BODY = `
|
|
121
|
+
(function(){'use strict';
|
|
122
|
+
// MCP Apps shell. Speaks the canonical postMessage protocol from
|
|
123
|
+
// @modelcontextprotocol/ext-apps:
|
|
124
|
+
// 1. iframe -> host: ui/initialize
|
|
125
|
+
// 2. iframe -> host: ui/notifications/initialized
|
|
126
|
+
// 3. host -> iframe: ui/notifications/tool-result (per CallToolResult)
|
|
127
|
+
// On tool-result, derive base URL + shortCode from structuredContent,
|
|
128
|
+
// fetch JSON bootstrap (componentCode + ids + runtimeUrl), set
|
|
129
|
+
// window.__GGUI_BOOTSTRAP__, fetch runtime as blob, inject as script.
|
|
130
|
+
// Runtime auto-mounts inline. No nested iframe (claudemcpcontent.com
|
|
131
|
+
// CSP frame-src forbids cross-origin frames). State machine matches
|
|
132
|
+
// buildSelfContainedShell so the same runtime mounts both paths.
|
|
133
|
+
var rpcId=1,pending={};
|
|
134
|
+
var rootEl=document.getElementById('ggui-root');
|
|
135
|
+
rootEl.style.cssText='display:flex;flex-direction:column;height:100%;min-height:300px;margin:0';
|
|
136
|
+
var mounted=false;
|
|
137
|
+
function setOverlay(text){
|
|
138
|
+
if(mounted)return;
|
|
139
|
+
rootEl.innerHTML='<div style="font:14px system-ui,sans-serif;padding:24px;color:#666">'+text+'</div>';
|
|
140
|
+
}
|
|
141
|
+
function postNotification(method,params){
|
|
142
|
+
try{window.parent.postMessage({jsonrpc:'2.0',method:method,params:params||{}},'*');}catch(e){}
|
|
143
|
+
}
|
|
144
|
+
function postRpc(method,params){
|
|
145
|
+
return new Promise(function(res,rej){
|
|
146
|
+
var id=rpcId++;pending[id]={res:res,rej:rej};
|
|
147
|
+
try{window.parent.postMessage({jsonrpc:'2.0',id:id,method:method,params:params||{}},'*');}
|
|
148
|
+
catch(e){delete pending[id];rej(e);}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function readToolResult(p){
|
|
152
|
+
if(!p)return null;
|
|
153
|
+
var sc=p.structuredContent;
|
|
154
|
+
if(sc&&typeof sc.url==='string'&&typeof sc.shortCode==='string'){
|
|
155
|
+
return {url:sc.url,shortCode:sc.shortCode};
|
|
156
|
+
}
|
|
157
|
+
if(Array.isArray(p.content)){
|
|
158
|
+
for(var i=0;i<p.content.length;i++){
|
|
159
|
+
var c=p.content[i];
|
|
160
|
+
if(c&&c.type==='text'&&typeof c.text==='string'){
|
|
161
|
+
try{var j=JSON.parse(c.text);
|
|
162
|
+
if(j&&typeof j.url==='string'&&typeof j.shortCode==='string'){
|
|
163
|
+
return {url:j.url,shortCode:j.shortCode};
|
|
164
|
+
}
|
|
165
|
+
}catch(e){}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
function deriveBase(url){
|
|
172
|
+
try{var u=new URL(url);return u.origin;}catch(e){return null;}
|
|
173
|
+
}
|
|
174
|
+
function postBootstrapFailed(reason,message){
|
|
175
|
+
// Surface every shell-layer bootstrap-failure path as a typed
|
|
176
|
+
// RendererBootFailedMessage envelope so hosts pinning the C9
|
|
177
|
+
// error pane (McpAppIframe onError, IframeErrorPane) see the
|
|
178
|
+
// failure instead of staring at the inert overlay until the test
|
|
179
|
+
// times out. Reason codes match BootstrapFailureReason.
|
|
180
|
+
try{window.parent.postMessage({type:'ggui:bootstrap-failed',reason:reason,message:message},'*');}catch(e){}
|
|
181
|
+
}
|
|
182
|
+
async function mountFromBootstrap(bootstrap){
|
|
183
|
+
if(mounted)return;
|
|
184
|
+
// runtimeUrl is the only load-bearing field at the shell layer —
|
|
185
|
+
// it tells us which iframe-runtime bundle to fetch. componentCode
|
|
186
|
+
// is OPTIONAL: present in self-contained-mode bootstraps (the
|
|
187
|
+
// runtime renders the inlined code without WS), absent in
|
|
188
|
+
// GguiBootstrapMeta-shaped bootstraps (the runtime opens the WS
|
|
189
|
+
// subscription via wsUrl+token to receive the stack). Either is
|
|
190
|
+
// valid; the runtime decides at boot time. Rejecting on missing
|
|
191
|
+
// componentCode silently breaks every Path B GguiBootstrapMeta
|
|
192
|
+
// delivery, which is the whole point of the inline-bootstrap path.
|
|
193
|
+
if(!bootstrap||typeof bootstrap.runtimeUrl!=='string'){
|
|
194
|
+
setOverlay('Bootstrap payload malformed.');
|
|
195
|
+
postBootstrapFailed('BOOTSTRAP_MALFORMED','Bootstrap payload malformed.');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
setOverlay('Loading UI…');
|
|
199
|
+
window.__GGUI_BOOTSTRAP__=bootstrap;
|
|
200
|
+
// Load the runtime bundle via a direct cross-origin script tag
|
|
201
|
+
// (governed by CSP script-src) instead of fetch + Blob (governed by
|
|
202
|
+
// CSP connect-src). claude.ai's claudemcpcontent.com iframe CSP
|
|
203
|
+
// forbids cross-origin connect-src so fetch throws TypeError
|
|
204
|
+
// 'Failed to fetch', but allows cross-origin script-src when the
|
|
205
|
+
// bundle responds with the right CORS headers (the iframe-runtime
|
|
206
|
+
// mount sets them). The self-contained shell already uses this
|
|
207
|
+
// pattern; legacy postMessage shell now matches it.
|
|
208
|
+
try{
|
|
209
|
+
var s=document.createElement('script');
|
|
210
|
+
s.type='module';
|
|
211
|
+
// crossorigin=anonymous opts into CORS-mode error reporting.
|
|
212
|
+
// Without it, cross-origin script tags get a sanitized
|
|
213
|
+
// "script error" with no details, masking the real cause
|
|
214
|
+
// (CSP block, CORS reject, module-evaluation throw). With it,
|
|
215
|
+
// the error event surfaces the actual message in the iframe
|
|
216
|
+
// console -- the bundle ships ACAO=* so credentialed mode is
|
|
217
|
+
// unnecessary.
|
|
218
|
+
s.crossOrigin='anonymous';
|
|
219
|
+
s.src=bootstrap.runtimeUrl;
|
|
220
|
+
s.onload=function(){mounted=true;};
|
|
221
|
+
s.onerror=function(e){
|
|
222
|
+
var msg='Runtime bundle failed to load: '+(e&&e.message||'script error');
|
|
223
|
+
setOverlay(msg);
|
|
224
|
+
postBootstrapFailed('BUNDLE_FETCH_FAILED',msg);
|
|
225
|
+
};
|
|
226
|
+
rootEl.innerHTML='';
|
|
227
|
+
document.body.appendChild(s);
|
|
228
|
+
}catch(e){
|
|
229
|
+
var msg='Runtime bundle failed to load: '+(e&&e.message||e);
|
|
230
|
+
setOverlay(msg);
|
|
231
|
+
postBootstrapFailed('BUNDLE_FETCH_FAILED',msg);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function mount(toolResult){
|
|
235
|
+
if(mounted)return;
|
|
236
|
+
var base=deriveBase(toolResult.url);
|
|
237
|
+
if(!base){setOverlay('Invalid URL in tool result.');return;}
|
|
238
|
+
setOverlay('Loading UI…');
|
|
239
|
+
var bootstrap;
|
|
240
|
+
try{
|
|
241
|
+
var bRes=await fetch(base+'/api/bootstrap/'+encodeURIComponent(toolResult.shortCode),{
|
|
242
|
+
cache:'no-store',
|
|
243
|
+
headers:{accept:'application/json'},
|
|
244
|
+
});
|
|
245
|
+
if(!bRes.ok){setOverlay('Bootstrap fetch failed: HTTP '+bRes.status);return;}
|
|
246
|
+
bootstrap=await bRes.json();
|
|
247
|
+
}catch(e){setOverlay('Bootstrap fetch error: '+(e&&e.message||e));return;}
|
|
248
|
+
return mountFromBootstrap(bootstrap);
|
|
249
|
+
}
|
|
250
|
+
function readBootstrapFromInitResult(result){
|
|
251
|
+
if(!result||typeof result!=='object')return null;
|
|
252
|
+
var toolOutput=result.toolOutput;
|
|
253
|
+
if(!toolOutput||typeof toolOutput!=='object')return null;
|
|
254
|
+
var meta=toolOutput._meta;
|
|
255
|
+
if(!meta||typeof meta!=='object')return null;
|
|
256
|
+
var ggui=meta.ggui;
|
|
257
|
+
if(!ggui||typeof ggui!=='object')return null;
|
|
258
|
+
var b=ggui.bootstrap;
|
|
259
|
+
if(!b||typeof b!=='object')return null;
|
|
260
|
+
// Same loosening as mountFromBootstrap: only runtimeUrl is required
|
|
261
|
+
// here. GguiBootstrapMeta forwarded by first-party McpAppIframe
|
|
262
|
+
// hosts omits componentCode by design (the runtime fetches the
|
|
263
|
+
// stack via
|
|
264
|
+
// wsUrl+token). Requiring componentCode at the shell layer turned
|
|
265
|
+
// every Path B inline-bootstrap delivery into a silent reject.
|
|
266
|
+
if(typeof b.runtimeUrl!=='string')return null;
|
|
267
|
+
return b;
|
|
268
|
+
}
|
|
269
|
+
function hasGguiMetaPlaceholder(result){
|
|
270
|
+
// Detect the protocol-violation case where the host signaled
|
|
271
|
+
// "I tried to deliver bootstrap" (toolOutput._meta.ggui exists)
|
|
272
|
+
// but the bootstrap field itself is absent or malformed. In that
|
|
273
|
+
// shape, we fail-fast with BOOTSTRAP_META_MISSING rather than
|
|
274
|
+
// sitting in the Path-A waiting state forever.
|
|
275
|
+
if(!result||typeof result!=='object')return false;
|
|
276
|
+
var toolOutput=result.toolOutput;
|
|
277
|
+
if(!toolOutput||typeof toolOutput!=='object')return false;
|
|
278
|
+
var meta=toolOutput._meta;
|
|
279
|
+
if(!meta||typeof meta!=='object')return false;
|
|
280
|
+
var ggui=meta.ggui;
|
|
281
|
+
return !!ggui&&typeof ggui==='object';
|
|
282
|
+
}
|
|
283
|
+
function readBootstrapFromCallToolResult(params){
|
|
284
|
+
// MCP Apps spec (specification/2026-01-26/apps.mdx:1145-1155):
|
|
285
|
+
// ui/notifications/tool-result
|
|
286
|
+
// params: CallToolResult // Standard MCP type
|
|
287
|
+
// So params IS the CallToolResult and _meta lives at the top
|
|
288
|
+
// level (NOT under params.toolOutput, which is where the
|
|
289
|
+
// first-party McpAppIframe convention wraps it). Spec-compliant
|
|
290
|
+
// hosts (Claude Desktop, claude.ai Connector, Claude Code) deliver
|
|
291
|
+
// bootstrap material here.
|
|
292
|
+
if(!params||typeof params!=='object')return null;
|
|
293
|
+
var meta=params._meta;
|
|
294
|
+
if(!meta||typeof meta!=='object')return null;
|
|
295
|
+
var ggui=meta.ggui;
|
|
296
|
+
if(!ggui||typeof ggui!=='object')return null;
|
|
297
|
+
var b=ggui.bootstrap;
|
|
298
|
+
if(!b||typeof b!=='object')return null;
|
|
299
|
+
// Same loosening as readBootstrapFromInitResult / mountFromBootstrap:
|
|
300
|
+
// only runtimeUrl is required at the shell layer. componentCode is
|
|
301
|
+
// optional (absent for GguiBootstrapMeta-shaped bootstraps that
|
|
302
|
+
// open WS via wsUrl+token).
|
|
303
|
+
if(typeof b.runtimeUrl!=='string')return null;
|
|
304
|
+
return b;
|
|
305
|
+
}
|
|
306
|
+
window.addEventListener('message',function(ev){
|
|
307
|
+
var m=ev&&ev.data;
|
|
308
|
+
if(!m||m.jsonrpc!=='2.0')return;
|
|
309
|
+
if(m.id!=null&&pending[m.id]){
|
|
310
|
+
var p=pending[m.id];delete pending[m.id];
|
|
311
|
+
if(m.error)p.rej(m.error);else p.res(m.result);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if(m.method==='ui/notifications/tool-result'){
|
|
315
|
+
// Spec-compliant hosts: m.params IS the CallToolResult; _meta is
|
|
316
|
+
// at the top level. Try this FIRST so Claude Desktop / claude.ai
|
|
317
|
+
// Connector / Claude Code land here.
|
|
318
|
+
var specB=readBootstrapFromCallToolResult(m.params);
|
|
319
|
+
if(specB){mountFromBootstrap(specB);return;}
|
|
320
|
+
// First-party McpAppIframe convention: bootstrap nested under
|
|
321
|
+
// params.toolOutput._meta. First-party hosts (Studio, Portal,
|
|
322
|
+
// console) use this shape for both init-response and post-init
|
|
323
|
+
// notification.
|
|
324
|
+
var bb=readBootstrapFromInitResult(m.params);
|
|
325
|
+
if(bb){mountFromBootstrap(bb);return;}
|
|
326
|
+
// Path A (last-resort fallback): host posts structuredContent.
|
|
327
|
+
// {url, shortCode}; shell fetches /api/bootstrap/<shortCode>
|
|
328
|
+
// over HTTP. The OSS server doesn't mount that endpoint, so this
|
|
329
|
+
// path is effectively dead unless a hosted operator wires it.
|
|
330
|
+
var tr=readToolResult(m.params);
|
|
331
|
+
if(tr)mount(tr);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
setOverlay('Initializing…');
|
|
335
|
+
var initTimer=setTimeout(function(){
|
|
336
|
+
setOverlay('Host did not respond to ui/initialize within 3s.');
|
|
337
|
+
},3000);
|
|
338
|
+
postRpc('ui/initialize',{
|
|
339
|
+
appCapabilities:{},
|
|
340
|
+
appInfo:{name:'ggui-session',version:'1.0.0'},
|
|
341
|
+
protocolVersion:'2026-01-26'
|
|
342
|
+
}).then(function(result){
|
|
343
|
+
clearTimeout(initTimer);
|
|
344
|
+
postNotification('ui/notifications/initialized',{});
|
|
345
|
+
// Path B: bootstrap inline in ui/initialize result. Hosts using
|
|
346
|
+
// <McpAppIframe>'s first-party dispatch deliver bootstrap meta here
|
|
347
|
+
// and never send a separate ui/notifications/tool-result.
|
|
348
|
+
var b=readBootstrapFromInitResult(result);
|
|
349
|
+
if(b){mountFromBootstrap(b);return;}
|
|
350
|
+
// Protocol-violation surface: host signalled an attempt
|
|
351
|
+
// (toolOutput._meta.ggui exists) but bootstrap is missing or
|
|
352
|
+
// malformed. Fail fast so hosts pinning a typed error envelope
|
|
353
|
+
// don't sit on the Path-A waiting overlay forever. Only fires
|
|
354
|
+
// when the meta placeholder is present — a fully absent _meta.ggui
|
|
355
|
+
// still falls through to Path A waiting.
|
|
356
|
+
if(hasGguiMetaPlaceholder(result)){
|
|
357
|
+
var msg='ui/initialize result missing _meta.ggui.bootstrap or runtimeUrl';
|
|
358
|
+
setOverlay(msg);
|
|
359
|
+
postBootstrapFailed('BOOTSTRAP_META_MISSING',msg);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// Path A: wait for the host to send ui/notifications/tool-result
|
|
363
|
+
// with structuredContent.{url, shortCode}. MCP Apps hosts that don't
|
|
364
|
+
// implement the reading-B inline-bootstrap convention land here.
|
|
365
|
+
setOverlay('Waiting for tool result…');
|
|
366
|
+
}).catch(function(e){
|
|
367
|
+
clearTimeout(initTimer);
|
|
368
|
+
setOverlay('ui/initialize failed: '+(e&&e.message||JSON.stringify(e)));
|
|
369
|
+
});
|
|
370
|
+
})();
|
|
371
|
+
`;
|
|
372
|
+
export const GGUI_SESSION_SHELL_HTML = `<!doctype html>
|
|
373
|
+
<html lang="en" style="height:100%"><head><meta charset="utf-8"><title>ggui session</title></head>
|
|
374
|
+
<body style="margin:0;height:100%;min-height:480px"><div id="ggui-root" data-ggui-shell="thin" style="height:100%;min-height:480px"></div>
|
|
375
|
+
<script>${GGUI_SESSION_SHELL_SCRIPT_BODY}</script></body></html>`;
|
|
376
|
+
/**
|
|
377
|
+
* CSP `script-src` source expression that authorises the inline
|
|
378
|
+
* `<script>` block of {@link GGUI_SESSION_SHELL_HTML} when it executes
|
|
379
|
+
* inside an iframe whose CSP is inherited from a parent host.
|
|
380
|
+
*
|
|
381
|
+
* # Why this exists
|
|
382
|
+
*
|
|
383
|
+
* The console's `<McpAppIframe>` mounts the production shell via
|
|
384
|
+
* `srcdoc`. The `about:srcdoc` iframe inherits the parent console
|
|
385
|
+
* SPA's CSP, which intentionally forbids `'unsafe-inline'` for
|
|
386
|
+
* `script-src` (`packages/mcp-server/src/console-headers.ts` —
|
|
387
|
+
* "If a future slice needs inline bootstrapping, add a nonce —
|
|
388
|
+
* NEVER `'unsafe-inline'` for scripts."). Without an authorising
|
|
389
|
+
* source expression for this exact script body, the inline shell
|
|
390
|
+
* is blocked at parse time and the renderer is never fetched. The
|
|
391
|
+
* lifecycle protocol never advances past `mounting`; specs pinning
|
|
392
|
+
* `data-ggui-mcp-app-iframe-lifecycle="code-ready"` time out.
|
|
393
|
+
*
|
|
394
|
+
* Hash CSP is the right shape here: the shell body is **static**
|
|
395
|
+
* and known at build time, and a hash binds the policy to the
|
|
396
|
+
* exact bytes — narrower than `'unsafe-inline'`, narrower than a
|
|
397
|
+
* runtime-generated nonce. If the shell body changes, the hash
|
|
398
|
+
* changes; the drift test in `mcp-apps-outbound.test.ts` catches
|
|
399
|
+
* a stale value.
|
|
400
|
+
*
|
|
401
|
+
* # Where it gets used
|
|
402
|
+
*
|
|
403
|
+
* `console-headers.ts::DEVTOOL_CSP` appends this expression to its
|
|
404
|
+
* `script-src` directive. Hosted closed-runtime session-resource
|
|
405
|
+
* endpoints have their own CSP and serve the same shell — that path
|
|
406
|
+
* needs the same expression added; tracked separately.
|
|
407
|
+
*
|
|
408
|
+
* # What an MCP Apps host (Claude Desktop etc.) does
|
|
409
|
+
*
|
|
410
|
+
* Production hosts set their own CSP on the iframe document — that
|
|
411
|
+
* surface is opaque to ggui. This expression is for the FIRST-PARTY
|
|
412
|
+
* path where the host is `<McpAppIframe>` and the parent SPA owns
|
|
413
|
+
* the CSP it inherits.
|
|
414
|
+
*/
|
|
415
|
+
export const GGUI_SESSION_SHELL_SCRIPT_HASH = `'sha256-${createHash('sha256')
|
|
416
|
+
.update(GGUI_SESSION_SHELL_SCRIPT_BODY)
|
|
417
|
+
.digest('base64')}'`;
|
|
418
|
+
/**
|
|
419
|
+
* Register `ui://ggui/session` as a readable resource on an `McpServer`.
|
|
420
|
+
*
|
|
421
|
+
* The resource is STATIC - `resources/read` always returns the same
|
|
422
|
+
* body. Per-session state lives on the live channel, not in the resource.
|
|
423
|
+
*
|
|
424
|
+
* When `publicBaseUrl` is supplied, the resource content carries
|
|
425
|
+
* `_meta.ui.csp.{connectDomains,resourceDomains}` per the MCP Apps spec
|
|
426
|
+
* (specification/2026-01-26/apps.mdx:300-317). The shell needs to fetch
|
|
427
|
+
* the iframe-runtime bundle and open a WebSocket back to the same
|
|
428
|
+
* origin; without these declarations the host applies the default CSP
|
|
429
|
+
* (`connect-src 'none'`) and both are blocked.
|
|
430
|
+
*
|
|
431
|
+
* Without `publicBaseUrl`, the `_meta.ui.csp` block is omitted — falls
|
|
432
|
+
* back to the spec's restrictive default which is fine for first-party
|
|
433
|
+
* same-origin hosts (Studio/Portal/console) where the parent SPA owns
|
|
434
|
+
* the iframe CSP via `<McpAppIframe>`.
|
|
435
|
+
*
|
|
436
|
+
* Returns nothing; the registration mutates the server in place.
|
|
437
|
+
*/
|
|
438
|
+
/**
|
|
439
|
+
* Build the `_meta.ui.csp.{connectDomains,resourceDomains}` block from
|
|
440
|
+
* an absolute `publicBaseUrl`. Same shape every resource that serves a
|
|
441
|
+
* shell bootstrap needs: the iframe must `script-src` + `connect-src`
|
|
442
|
+
* the runtime bundle, and `wss-src` the live-channel socket. CSP rules
|
|
443
|
+
* do NOT cross-translate `https://` ↔ `wss://`, so the HTTPS origin
|
|
444
|
+
* AND its `wss://` twin are both declared.
|
|
445
|
+
*
|
|
446
|
+
* Returns `undefined` when `publicBaseUrl` is absent or malformed —
|
|
447
|
+
* the caller omits the `_meta` block entirely in that case, falling
|
|
448
|
+
* back to the host's default CSP (fine for same-origin hosts;
|
|
449
|
+
* restrictive for cross-origin claude.ai-style hosts).
|
|
450
|
+
*/
|
|
451
|
+
function buildCspMeta(publicBaseUrl) {
|
|
452
|
+
if (!publicBaseUrl)
|
|
453
|
+
return undefined;
|
|
454
|
+
try {
|
|
455
|
+
const parsed = new URL(publicBaseUrl);
|
|
456
|
+
const origin = parsed.origin;
|
|
457
|
+
const wsScheme = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
458
|
+
const wsOrigin = `${wsScheme}//${parsed.host}`;
|
|
459
|
+
return {
|
|
460
|
+
ui: {
|
|
461
|
+
csp: {
|
|
462
|
+
connectDomains: [origin, wsOrigin],
|
|
463
|
+
resourceDomains: [origin],
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
export function registerGguiSessionResource(server, shellHtml = GGUI_SESSION_SHELL_HTML, publicBaseUrl) {
|
|
473
|
+
let cspMeta;
|
|
474
|
+
if (publicBaseUrl) {
|
|
475
|
+
try {
|
|
476
|
+
const parsed = new URL(publicBaseUrl);
|
|
477
|
+
const origin = parsed.origin;
|
|
478
|
+
// CSP `connect-src` does NOT cross-translate between `https://`
|
|
479
|
+
// and `wss://` — they're independent URL schemes for the
|
|
480
|
+
// browser's URL-match algorithm. Declaring ONLY the HTTPS
|
|
481
|
+
// origin will leave WebSocket subscribes (`wss://<same-host>/ws`)
|
|
482
|
+
// blocked by hosts that compose strict CSPs from this
|
|
483
|
+
// `connectDomains` list (claude.ai's iframe is the live
|
|
484
|
+
// diagnosis case). Declare BOTH schemes so the same physical
|
|
485
|
+
// origin is reachable via HTTPS (`/api/bootstrap`, `/_ggui/
|
|
486
|
+
// iframe-runtime.js`) AND wss (live-channel subscribe).
|
|
487
|
+
const wsScheme = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
488
|
+
const wsOrigin = `${wsScheme}//${parsed.host}`;
|
|
489
|
+
cspMeta = {
|
|
490
|
+
ui: {
|
|
491
|
+
csp: {
|
|
492
|
+
connectDomains: [origin, wsOrigin],
|
|
493
|
+
resourceDomains: [origin],
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// Malformed `publicBaseUrl` — leave `_meta.ui.csp` off rather
|
|
500
|
+
// than emitting a broken declaration. The host falls back to its
|
|
501
|
+
// restrictive default and operators get the same observable
|
|
502
|
+
// failure they'd get from any other malformed URL setting.
|
|
503
|
+
cspMeta = undefined;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
server.registerResource('ggui-session', GGUI_SESSION_RESOURCE_URI, {
|
|
507
|
+
// `title` / `description` show up in MCP clients that surface
|
|
508
|
+
// resource metadata. Short + concrete.
|
|
509
|
+
title: 'ggui session',
|
|
510
|
+
description: 'Thin-shell iframe bundle that bootstraps a ggui session. MCP Apps hosts fetch this when they see `_meta.ui.resourceUri` on a ggui_push result.',
|
|
511
|
+
mimeType: GGUI_SESSION_RESOURCE_MIME,
|
|
512
|
+
}, async (uri) => ({
|
|
513
|
+
contents: [
|
|
514
|
+
{
|
|
515
|
+
uri: uri.href,
|
|
516
|
+
mimeType: GGUI_SESSION_RESOURCE_MIME,
|
|
517
|
+
text: shellHtml,
|
|
518
|
+
...(cspMeta !== undefined ? { _meta: cspMeta } : {}),
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Advertise the `io.modelcontextprotocol/ui` extension capability on
|
|
525
|
+
* an `McpServer`'s underlying `Server`. Idempotent - calling twice on
|
|
526
|
+
* the same server leaves the capability advertised once.
|
|
527
|
+
*
|
|
528
|
+
* We use the `experimental` capability slot (present in every MCP SDK
|
|
529
|
+
* release) rather than `extensions` (post-1.x addition) for broadest
|
|
530
|
+
* client compat. The capability *name* is what matters to hosts; the
|
|
531
|
+
* container field is a pragmatic choice we can migrate if the spec
|
|
532
|
+
* settles on `extensions` later.
|
|
533
|
+
*/
|
|
534
|
+
export function advertiseMcpAppsUiCapability(server) {
|
|
535
|
+
server.server.registerCapabilities({
|
|
536
|
+
experimental: {
|
|
537
|
+
[MCP_APPS_UI_CAPABILITY]: {},
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Build the self-contained shell HTML for a given session.
|
|
543
|
+
*
|
|
544
|
+
* The returned HTML is a complete, standalone document: it inlines the
|
|
545
|
+
* compiled component (base64) + session ids in a `window.__GGUI_BOOTSTRAP__`
|
|
546
|
+
* global, then loads the iframe-runtime bundle via `<script type="module"
|
|
547
|
+
* src={runtimeUrl}>`. The runtime takes over synchronously on import,
|
|
548
|
+
* mounts the component, and the iframe paints WITHOUT any further server
|
|
549
|
+
* round-trip.
|
|
550
|
+
*
|
|
551
|
+
* Pure function — no DOM access, no I/O, no `crypto` randomness. Same
|
|
552
|
+
* inputs always produce identical bytes (modulo input ordering of the
|
|
553
|
+
* bootstrap object's optional fields), which makes the output cacheable
|
|
554
|
+
* and testable.
|
|
555
|
+
*
|
|
556
|
+
* Escapes that matter:
|
|
557
|
+
* - HTML-entity-escapes the bootstrap JSON for `<` `>` `&` so a
|
|
558
|
+
* malicious appId / sessionId can't break out of the script tag.
|
|
559
|
+
* - The componentCode is base64-encoded by the caller before the JSON
|
|
560
|
+
* stringification runs, so even a raw `</script>` sequence in the
|
|
561
|
+
* compiled source can't break the surrounding HTML.
|
|
562
|
+
*
|
|
563
|
+
* @public
|
|
564
|
+
*/
|
|
565
|
+
export function buildSelfContainedShell(opts) {
|
|
566
|
+
// Discriminate between three modes: system-card, static-component
|
|
567
|
+
// (codeUrl), or live (wsUrl + token). At least one mode MUST be set —
|
|
568
|
+
// the builder rejects an empty bootstrap. Multiple modes may coexist
|
|
569
|
+
// (e.g. codeUrl + live-mode credentials for an iframe that mounts
|
|
570
|
+
// statically but subscribes for updates); the iframe-runtime parser
|
|
571
|
+
// picks per its priority order.
|
|
572
|
+
const isSystem = typeof opts.systemKind === 'string' && opts.systemKind.length > 0;
|
|
573
|
+
const hasCodeUrl = typeof opts.codeUrl === 'string' && opts.codeUrl.length > 0;
|
|
574
|
+
const hasLive = typeof opts.wsUrl === 'string' && opts.wsUrl.length > 0
|
|
575
|
+
&& typeof opts.token === 'string' && opts.token.length > 0;
|
|
576
|
+
const isCanvas = opts.canvasMode === true;
|
|
577
|
+
if (isCanvas && (isSystem || hasCodeUrl || opts.stackItemId !== undefined)) {
|
|
578
|
+
throw new Error('buildSelfContainedShell: canvasMode is mutually exclusive with `systemKind`, `codeUrl`, and `stackItemId` — canvas iframes are session-scoped and render via the live channel, not a pre-pinned stack item');
|
|
579
|
+
}
|
|
580
|
+
if (isCanvas && !hasLive) {
|
|
581
|
+
throw new Error('buildSelfContainedShell: canvasMode requires the live-mode trio (`wsUrl` + `token` + `expiresAt`) — the canvas iframe receives stack items via the live channel, not static inlining');
|
|
582
|
+
}
|
|
583
|
+
if (!isSystem && !hasCodeUrl && !hasLive) {
|
|
584
|
+
throw new Error('buildSelfContainedShell: at least one of `codeUrl`, `systemKind`, or live-mode (`wsUrl` + `token`) must be set');
|
|
585
|
+
}
|
|
586
|
+
// Inject `runtimeUrl` + the three bootstrap-derivation fields
|
|
587
|
+
// (`appCallableTools`, `actionNextSteps`, `contextSlots`) into the
|
|
588
|
+
// inline bootstrap so the iframe-runtime's bootstrap validator
|
|
589
|
+
// (which requires `runtimeUrl` across all modes + makes
|
|
590
|
+
// `appCallableTools`/`actionNextSteps`/`contextSlots` observable on
|
|
591
|
+
// the self-contained path) accepts the envelope. Omitting
|
|
592
|
+
// `runtimeUrl` from the inline JSON makes every `/r/<shortCode>`
|
|
593
|
+
// direct-preview a blank white page because `parseBootstrap`
|
|
594
|
+
// rejects it as MALFORMED.
|
|
595
|
+
const bootstrap = {
|
|
596
|
+
sessionId: opts.sessionId,
|
|
597
|
+
appId: opts.appId,
|
|
598
|
+
runtimeUrl: opts.runtimeUrl,
|
|
599
|
+
// Static-content discriminators — system-card and codeUrl are
|
|
600
|
+
// mutually exclusive (the iframe-runtime rejects the both-set mix
|
|
601
|
+
// as MALFORMED). Live-mode credentials (wsUrl/token below) may
|
|
602
|
+
// coexist with either or stand alone.
|
|
603
|
+
...(isSystem ? { kind: opts.systemKind } : {}),
|
|
604
|
+
...(!isSystem && hasCodeUrl
|
|
605
|
+
? {
|
|
606
|
+
codeUrl: opts.codeUrl,
|
|
607
|
+
...(opts.codeHash !== undefined ? { codeHash: opts.codeHash } : {}),
|
|
608
|
+
}
|
|
609
|
+
: {}),
|
|
610
|
+
...(opts.stackItemId !== undefined ? { stackItemId: opts.stackItemId } : {}),
|
|
611
|
+
...(isCanvas ? { canvasMode: true } : {}),
|
|
612
|
+
...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
|
|
613
|
+
...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
|
|
614
|
+
...(opts.propsJson !== undefined ? { propsJson: opts.propsJson } : {}),
|
|
615
|
+
...(opts.appCallableTools !== undefined && opts.appCallableTools.length > 0
|
|
616
|
+
? { appCallableTools: opts.appCallableTools }
|
|
617
|
+
: {}),
|
|
618
|
+
...(opts.actionNextSteps !== undefined &&
|
|
619
|
+
Object.keys(opts.actionNextSteps).length > 0
|
|
620
|
+
? { actionNextSteps: opts.actionNextSteps }
|
|
621
|
+
: {}),
|
|
622
|
+
...(opts.contextSlots !== undefined && opts.contextSlots.length > 0
|
|
623
|
+
? { contextSlots: opts.contextSlots }
|
|
624
|
+
: {}),
|
|
625
|
+
...(opts.permissionsPolicy !== undefined && opts.permissionsPolicy.length > 0
|
|
626
|
+
? { permissionsPolicy: opts.permissionsPolicy }
|
|
627
|
+
: {}),
|
|
628
|
+
// Wrapper catalog the iframe-runtime dynamic-imports at boot.
|
|
629
|
+
// Symmetric with `_meta.ggui.bootstrap`'s `gadgets` field.
|
|
630
|
+
// Without this forward, the self-contained shell path
|
|
631
|
+
// (/r/<shortCode>, resources/read) would render as STDLIB-only —
|
|
632
|
+
// wrapper-using contracts (Leaflet, Mapbox) destructure unknown
|
|
633
|
+
// hooks at runtime.
|
|
634
|
+
...(opts.gadgets !== undefined && opts.gadgets.length > 0
|
|
635
|
+
? { gadgets: opts.gadgets }
|
|
636
|
+
: {}),
|
|
637
|
+
// Precompiled, eval-free contract validators. Symmetric forward
|
|
638
|
+
// for the self-contained shell — the renderer iframe's strict CSP
|
|
639
|
+
// blocks runtime `ajv.compile()`, so it loads these modules via
|
|
640
|
+
// `blob:` import. Omitted when the contract declares no
|
|
641
|
+
// runtime-validated schema.
|
|
642
|
+
...(opts.compiledValidators !== undefined
|
|
643
|
+
? { compiledValidators: opts.compiledValidators }
|
|
644
|
+
: {}),
|
|
645
|
+
// Server-filtered public env values that declared wrappers'
|
|
646
|
+
// `requires` cover. Symmetric forward; without it, wrappers
|
|
647
|
+
// calling `getPublicEnv()` throw at hook-mount on the
|
|
648
|
+
// self-contained shell path.
|
|
649
|
+
...(opts.publicEnv !== undefined && Object.keys(opts.publicEnv).length > 0
|
|
650
|
+
? { publicEnv: opts.publicEnv }
|
|
651
|
+
: {}),
|
|
652
|
+
// Live-mode trio. parseBootstrap rejects half-live envelopes
|
|
653
|
+
// (`wsUrl XOR token` MALFORMED), so we forward all three together
|
|
654
|
+
// or none at all — the caller is responsible for pairing them at
|
|
655
|
+
// mint time. `expiresAt` is degrade-able (past-due → static-only)
|
|
656
|
+
// but is part of the live trio at emit time.
|
|
657
|
+
...(opts.wsUrl !== undefined ? { wsUrl: opts.wsUrl } : {}),
|
|
658
|
+
...(opts.token !== undefined ? { token: opts.token } : {}),
|
|
659
|
+
...(opts.expiresAt !== undefined ? { expiresAt: opts.expiresAt } : {}),
|
|
660
|
+
};
|
|
661
|
+
// JSON.stringify produces valid JS, but `<` / `>` / `&` / `U+2028`
|
|
662
|
+
// / `U+2029` can break HTML or JS parsers when embedded inline.
|
|
663
|
+
// Escape them. `U+2028` / `U+2029` are JS-source line terminators
|
|
664
|
+
// that JSON allows but JS parsers historically choked on; modern
|
|
665
|
+
// engines accept them in strings but the escape is cheap insurance.
|
|
666
|
+
const json = JSON.stringify(bootstrap)
|
|
667
|
+
.replace(/</g, '\\u003c')
|
|
668
|
+
.replace(/>/g, '\\u003e')
|
|
669
|
+
.replace(/&/g, '\\u0026')
|
|
670
|
+
.replace(/\u2028/g, '\\u2028')
|
|
671
|
+
.replace(/\u2029/g, '\\u2029');
|
|
672
|
+
// HTML-escape the runtimeUrl for the `src` attribute. Server
|
|
673
|
+
// operators control this string but a defensive escape avoids any
|
|
674
|
+
// surprise if a future code path lets user-derived data flow here.
|
|
675
|
+
const safeRuntimeUrl = opts.runtimeUrl
|
|
676
|
+
.replace(/&/g, '&')
|
|
677
|
+
.replace(/"/g, '"')
|
|
678
|
+
.replace(/</g, '<')
|
|
679
|
+
.replace(/>/g, '>');
|
|
680
|
+
return `<!doctype html>
|
|
681
|
+
<html lang="en"><head><meta charset="utf-8"><title>ggui session</title></head>
|
|
682
|
+
<body>
|
|
683
|
+
<div id="ggui-root" data-ggui-shell="self-contained"></div>
|
|
684
|
+
<script>window.__GGUI_BOOTSTRAP__ = ${json};</script>
|
|
685
|
+
<script type="module" crossorigin="anonymous" src="${safeRuntimeUrl}"></script>
|
|
686
|
+
</body></html>`;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Minimal "loading" HTML served when a per-session resource is fetched
|
|
690
|
+
* for a session whose top stack item has no componentCode yet
|
|
691
|
+
* (placeholder, generation in flight). Renders a tiny status surface
|
|
692
|
+
* so hosts that pin lifecycle selectors don't see a blank document.
|
|
693
|
+
*
|
|
694
|
+
* Hosts SHOULD re-fetch when they observe additional `ggui_push`
|
|
695
|
+
* results on the same session — the per-call `_meta.ui.resourceUri`
|
|
696
|
+
* value stays stable across pushes for a session, so re-fetching the
|
|
697
|
+
* same URI returns fresher HTML on the second try.
|
|
698
|
+
*
|
|
699
|
+
* @public
|
|
700
|
+
*/
|
|
701
|
+
export function buildSelfContainedLoadingShell(sessionId) {
|
|
702
|
+
return `<!doctype html>
|
|
703
|
+
<html lang="en"><head><meta charset="utf-8"><title>ggui session</title></head>
|
|
704
|
+
<body>
|
|
705
|
+
<div id="ggui-root" data-ggui-shell="loading" data-ggui-session-id="${sessionId
|
|
706
|
+
.replace(/&/g, '&')
|
|
707
|
+
.replace(/"/g, '"')
|
|
708
|
+
.replace(/</g, '<')
|
|
709
|
+
.replace(/>/g, '>')}">Generating UI…</div>
|
|
710
|
+
</body></html>`;
|
|
711
|
+
}
|
|
712
|
+
function pickTopComponentItem(stack) {
|
|
713
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
714
|
+
const entry = stack[i];
|
|
715
|
+
if (!entry || entry.type === 'mcpApps')
|
|
716
|
+
continue;
|
|
717
|
+
const props = entry.props !== undefined &&
|
|
718
|
+
entry.props !== null &&
|
|
719
|
+
typeof entry.props === 'object' &&
|
|
720
|
+
!Array.isArray(entry.props)
|
|
721
|
+
? entry.props
|
|
722
|
+
: undefined;
|
|
723
|
+
if (entry.type === 'system') {
|
|
724
|
+
if (typeof entry.kind === 'string' && entry.kind.length > 0) {
|
|
725
|
+
return {
|
|
726
|
+
id: entry.id,
|
|
727
|
+
kind: entry.kind,
|
|
728
|
+
...(props !== undefined ? { props } : {}),
|
|
729
|
+
source: entry,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const code = entry.componentCode;
|
|
735
|
+
if (typeof code === 'string' && code.length > 0) {
|
|
736
|
+
return {
|
|
737
|
+
id: entry.id,
|
|
738
|
+
componentCode: code,
|
|
739
|
+
...(props !== undefined ? { props } : {}),
|
|
740
|
+
source: entry,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Register a `ui://ggui/session/{sessionId}` resource template. Each
|
|
748
|
+
* `resources/read` request is resolved by looking up the session in the
|
|
749
|
+
* store, picking the topmost component stack item, and returning the
|
|
750
|
+
* self-contained shell with that componentCode inlined.
|
|
751
|
+
*
|
|
752
|
+
* Per-call `_meta.ui.resourceUri` (stamped by `ggui_push.resultMeta`)
|
|
753
|
+
* pins the URI to a specific sessionId; hosts fetch THAT URI rather
|
|
754
|
+
* than the static `ui://ggui/session` one. Both registrations co-exist:
|
|
755
|
+
* legacy postMessage shell at the static URI, self-contained shell at
|
|
756
|
+
* the templated URI.
|
|
757
|
+
*
|
|
758
|
+
* Failure modes:
|
|
759
|
+
* - Session not found → loading shell (host re-fetches; absent
|
|
760
|
+
* session is a transient state immediately after `ggui_push`).
|
|
761
|
+
* - Session found, no componentCode yet → loading shell.
|
|
762
|
+
* - Session found, componentCode present → self-contained shell.
|
|
763
|
+
*
|
|
764
|
+
* Returns nothing; mutates the server in place.
|
|
765
|
+
*
|
|
766
|
+
* @public
|
|
767
|
+
*/
|
|
768
|
+
export function registerGguiSessionResourceTemplate(server, opts) {
|
|
769
|
+
// TWO templates registered against the same handler core:
|
|
770
|
+
//
|
|
771
|
+
// 1. Single-segment legacy URI — `ui://ggui/session/{sessionId}`.
|
|
772
|
+
// Pre-resume-contract chats in claude.ai's history persisted
|
|
773
|
+
// this shape; we keep the registration so historical messages
|
|
774
|
+
// still rehydrate (loading shell on session miss).
|
|
775
|
+
//
|
|
776
|
+
// 2. Two-segment resume URI — `ui://ggui/session/{sessionId}/
|
|
777
|
+
// {blueprintKey}`. Stamped by every push since the resume
|
|
778
|
+
// contract landed. Carries enough state for the handler to do:
|
|
779
|
+
// (a) parallel session + blueprint registry lookup (no data
|
|
780
|
+
// dependency between them), (b) registry-only fallback when
|
|
781
|
+
// the session is gone but the blueprint is still cached
|
|
782
|
+
// (renders the original card with default props/context
|
|
783
|
+
// instead of the dead loading shell).
|
|
784
|
+
const legacyTemplate = new ResourceTemplate(`${GGUI_SESSION_RESOURCE_URI}/{sessionId}`, {
|
|
785
|
+
// No list-callback — the resource set is unbounded per session
|
|
786
|
+
// count, and `resources/list` would leak session ids across
|
|
787
|
+
// tenants. Hosts discover specific URIs via per-call `_meta.ui.
|
|
788
|
+
// resourceUri` instead.
|
|
789
|
+
list: undefined,
|
|
790
|
+
});
|
|
791
|
+
const resumeTemplate = new ResourceTemplate(`${GGUI_SESSION_RESOURCE_URI}/{sessionId}/{blueprintKey}`, { list: undefined });
|
|
792
|
+
// CSP-meta block forwarded on every shell response when the
|
|
793
|
+
// template was wired with `publicBaseUrl`. claude.ai's iframe
|
|
794
|
+
// applies the host's restrictive default (`connect-src 'none'`)
|
|
795
|
+
// unless the resource declares `_meta.ui.csp.connectDomains` —
|
|
796
|
+
// without that the `<script type="module" src=runtimeUrl>` tag
|
|
797
|
+
// fails with a generic "script error" since cross-origin script
|
|
798
|
+
// loading is blocked. Same shape declared on the static
|
|
799
|
+
// `ui://ggui/session` resource; this is the per-call mirror.
|
|
800
|
+
const templateCspMeta = buildCspMeta(opts.publicBaseUrl);
|
|
801
|
+
/**
|
|
802
|
+
* Merge gadget-declared origins from
|
|
803
|
+
* {@link deriveBundleOrigins} into the base `templateCspMeta`. The
|
|
804
|
+
* base only carries the publicBaseUrl origin (HTTPS + WSS); without
|
|
805
|
+
* the per-stack-item augmentation, gadget bundle / style / API
|
|
806
|
+
* origins (Leaflet tiles, Mapbox API, Stripe SDK, …) are blocked by
|
|
807
|
+
* claude.ai's iframe CSP and the component fails to render. Returns
|
|
808
|
+
* `undefined` when there's no base CSP at all (publicBaseUrl
|
|
809
|
+
* absent — first-party same-origin host).
|
|
810
|
+
*/
|
|
811
|
+
const augmentCspMeta = (gadgetOrigins) => {
|
|
812
|
+
if (templateCspMeta === undefined)
|
|
813
|
+
return undefined;
|
|
814
|
+
if (gadgetOrigins === undefined)
|
|
815
|
+
return templateCspMeta;
|
|
816
|
+
return {
|
|
817
|
+
ui: {
|
|
818
|
+
csp: {
|
|
819
|
+
connectDomains: [
|
|
820
|
+
...templateCspMeta.ui.csp.connectDomains,
|
|
821
|
+
...gadgetOrigins.connect,
|
|
822
|
+
],
|
|
823
|
+
resourceDomains: [
|
|
824
|
+
...templateCspMeta.ui.csp.resourceDomains,
|
|
825
|
+
...gadgetOrigins.script,
|
|
826
|
+
...gadgetOrigins.style,
|
|
827
|
+
],
|
|
828
|
+
},
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
};
|
|
832
|
+
const shellContents = (uri, text, cspMeta = templateCspMeta) => ({
|
|
833
|
+
contents: [
|
|
834
|
+
{
|
|
835
|
+
uri: uri.href,
|
|
836
|
+
mimeType: GGUI_SESSION_RESOURCE_MIME,
|
|
837
|
+
text,
|
|
838
|
+
...(cspMeta !== undefined ? { _meta: cspMeta } : {}),
|
|
839
|
+
},
|
|
840
|
+
],
|
|
841
|
+
});
|
|
842
|
+
const loadingShell = (uri, sessionId) => shellContents(uri, buildSelfContainedLoadingShell(sessionId));
|
|
843
|
+
// Single shared handler powers both templates. `blueprintKey` is
|
|
844
|
+
// optional in the variables map — present for the resume URI shape,
|
|
845
|
+
// absent for the legacy single-segment shape.
|
|
846
|
+
async function handle(uri, variables) {
|
|
847
|
+
const sessionIdRaw = variables['sessionId'];
|
|
848
|
+
const sessionId = Array.isArray(sessionIdRaw) ? sessionIdRaw[0] : sessionIdRaw;
|
|
849
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
850
|
+
return loadingShell(uri, 'unknown');
|
|
851
|
+
}
|
|
852
|
+
const blueprintKeyRaw = variables['blueprintKey'];
|
|
853
|
+
const blueprintKey = Array.isArray(blueprintKeyRaw)
|
|
854
|
+
? blueprintKeyRaw[0]
|
|
855
|
+
: blueprintKeyRaw;
|
|
856
|
+
const hasResumeKey = typeof blueprintKey === 'string' && blueprintKey.length > 0;
|
|
857
|
+
// Parallel lookup. The session and the blueprint registry are
|
|
858
|
+
// independent — even though `session.stack[top].componentCode`
|
|
859
|
+
// could feed the renderable directly, we ALSO want the blueprint
|
|
860
|
+
// entry as a registry-only fallback when the session is gone but
|
|
861
|
+
// the blueprint is still cached (chat-history rehydrate after
|
|
862
|
+
// session TTL or process restart).
|
|
863
|
+
const [session, blueprint] = await Promise.all([
|
|
864
|
+
opts.sessionStore.get(sessionId),
|
|
865
|
+
hasResumeKey && opts.vectorStore && opts.defaultAppIdFallback
|
|
866
|
+
? findBlueprintExact({ vectorStore: opts.vectorStore }, opts.defaultAppIdFallback, 'template', blueprintKey)
|
|
867
|
+
: Promise.resolve(null),
|
|
868
|
+
]);
|
|
869
|
+
// Canvas-mode branch: session-scoped
|
|
870
|
+
// iframe that renders {@link CanvasShell} and subscribes to
|
|
871
|
+
// the live channel for stack-item delivery. The bootstrap carries
|
|
872
|
+
// `canvasMode: true` + live-mode trio (wsUrl/token/expiresAt) and
|
|
873
|
+
// NO stackItemId / codeUrl / systemKind — the iframe-runtime
|
|
874
|
+
// mounts the canvas and waits for `push` envelopes rather than
|
|
875
|
+
// rendering a pinned static item.
|
|
876
|
+
//
|
|
877
|
+
// Falls through to the legacy single-item path when `mintBootstrap`
|
|
878
|
+
// isn't wired (canvas can't function without WS credentials, and
|
|
879
|
+
// serving a pinned stack item is strictly better than a dead
|
|
880
|
+
// loading shell).
|
|
881
|
+
if (session && session.mcpAppsMode === 'canvas' && opts.mintBootstrap) {
|
|
882
|
+
let creds = null;
|
|
883
|
+
try {
|
|
884
|
+
creds = await opts.mintBootstrap(sessionId, session.appId);
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
creds = null;
|
|
888
|
+
}
|
|
889
|
+
if (creds) {
|
|
890
|
+
const html = buildSelfContainedShell({
|
|
891
|
+
sessionId,
|
|
892
|
+
appId: session.appId,
|
|
893
|
+
canvasMode: true,
|
|
894
|
+
runtimeUrl: opts.runtimeUrl,
|
|
895
|
+
wsUrl: creds.wsUrl,
|
|
896
|
+
token: creds.token,
|
|
897
|
+
expiresAt: creds.expiresAt,
|
|
898
|
+
...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
|
|
899
|
+
...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
|
|
900
|
+
});
|
|
901
|
+
return shellContents(uri, html);
|
|
902
|
+
}
|
|
903
|
+
// mintBootstrap returned null / threw — fall through to legacy
|
|
904
|
+
// path below. Logs are silent at this layer; deployment-level
|
|
905
|
+
// metrics (cloud) catch the degradation rate.
|
|
906
|
+
}
|
|
907
|
+
// Happy path: session present, top stack item renderable. Mount
|
|
908
|
+
// with the live state (current props, current contextSpec values).
|
|
909
|
+
if (session) {
|
|
910
|
+
const top = pickTopComponentItem(session.stack);
|
|
911
|
+
if (top) {
|
|
912
|
+
// Project the active stack item to the transport-agnostic
|
|
913
|
+
// bootstrap view — same source of truth `push.ts` and
|
|
914
|
+
// `/r/<shortCode>` consume. Carries permissionsPolicy when
|
|
915
|
+
// clientCapabilities declares permissions. The MCP Apps
|
|
916
|
+
// resource path emits this only into the inline bootstrap
|
|
917
|
+
// (the browser-enforced gate ultimately comes from the host's
|
|
918
|
+
// `allow=""` attribute when the host translates
|
|
919
|
+
// `_meta.ui.permissions` — set by McpAppIframe consumers).
|
|
920
|
+
const view = deriveStackItemBootstrapView(top.source);
|
|
921
|
+
const isSystem = top.kind !== undefined;
|
|
922
|
+
// Static-component delivery via codeUrl (T3-1, 2026-05-13). The
|
|
923
|
+
// compiled-component path mints a content-addressable URL the
|
|
924
|
+
// iframe-runtime fetches at boot; the loading shell takes over
|
|
925
|
+
// when codeStore + codeBaseUrl aren't wired.
|
|
926
|
+
let codeUrl;
|
|
927
|
+
let codeHash;
|
|
928
|
+
if (!isSystem && opts.codeStore && opts.codeBaseUrl) {
|
|
929
|
+
try {
|
|
930
|
+
const hash = opts.codeStore.hashOf(top.componentCode);
|
|
931
|
+
await opts.codeStore.put(hash, top.componentCode);
|
|
932
|
+
codeHash = hash;
|
|
933
|
+
const base = opts.codeBaseUrl.replace(/\/$/, '');
|
|
934
|
+
codeUrl = `${base}/code/${hash}.js`;
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
// Silent — falls through to loading shell below.
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (!isSystem && codeUrl === undefined) {
|
|
941
|
+
// Compiled-component item but no codeUrl channel available —
|
|
942
|
+
// emit the loading shell so the operator can refresh once
|
|
943
|
+
// codeStore is wired. Direct-render `/r/<shortCode>` falls
|
|
944
|
+
// through to live-mode instead; this MCP-resource path has
|
|
945
|
+
// no WS-mode fallback (resources/read is one-shot).
|
|
946
|
+
return loadingShell(uri, sessionId);
|
|
947
|
+
}
|
|
948
|
+
// Project the wrapper catalog AND the union-filtered
|
|
949
|
+
// publicEnv onto the inline bootstrap so the resource-served
|
|
950
|
+
// iframe matches the MCP-Apps postMessage path. Without this,
|
|
951
|
+
// wrapper-using contracts rendered through `resources/read`
|
|
952
|
+
// mount as STDLIB-only.
|
|
953
|
+
let resourcePublicEnv;
|
|
954
|
+
if (opts.appMetadataStore) {
|
|
955
|
+
try {
|
|
956
|
+
const appRecord = await opts.appMetadataStore.get(session.appId);
|
|
957
|
+
resourcePublicEnv = derivePublicEnvProjection(top.source, appRecord?.publicEnv);
|
|
958
|
+
}
|
|
959
|
+
catch {
|
|
960
|
+
// Silent — wrappers calling getPublicEnv throw clearly.
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const html = buildSelfContainedShell({
|
|
964
|
+
sessionId,
|
|
965
|
+
appId: session.appId,
|
|
966
|
+
...(isSystem
|
|
967
|
+
? { systemKind: top.kind }
|
|
968
|
+
: {
|
|
969
|
+
codeUrl: codeUrl,
|
|
970
|
+
...(codeHash !== undefined ? { codeHash } : {}),
|
|
971
|
+
}),
|
|
972
|
+
runtimeUrl: opts.runtimeUrl,
|
|
973
|
+
stackItemId: top.id,
|
|
974
|
+
...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
|
|
975
|
+
...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
|
|
976
|
+
...(view.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
|
|
977
|
+
...(view.actionNextSteps !== undefined
|
|
978
|
+
? { actionNextSteps: view.actionNextSteps }
|
|
979
|
+
: {}),
|
|
980
|
+
...(view.contextSlots !== undefined
|
|
981
|
+
? { contextSlots: view.contextSlots }
|
|
982
|
+
: {}),
|
|
983
|
+
...(view.permissionsPolicy !== undefined
|
|
984
|
+
? { permissionsPolicy: view.permissionsPolicy }
|
|
985
|
+
: {}),
|
|
986
|
+
...(view.gadgets !== undefined &&
|
|
987
|
+
view.gadgets.length > 0
|
|
988
|
+
? { gadgets: view.gadgets }
|
|
989
|
+
: {}),
|
|
990
|
+
...(view.compiledValidators !== undefined
|
|
991
|
+
? { compiledValidators: view.compiledValidators }
|
|
992
|
+
: {}),
|
|
993
|
+
...(resourcePublicEnv !== undefined &&
|
|
994
|
+
Object.keys(resourcePublicEnv).length > 0
|
|
995
|
+
? { publicEnv: resourcePublicEnv }
|
|
996
|
+
: {}),
|
|
997
|
+
});
|
|
998
|
+
// Augment per-call CSP with gadget-declared bundle / style /
|
|
999
|
+
// API origins. Without this, claude.ai's iframe CSP only allows
|
|
1000
|
+
// the publicBaseUrl origin, so Leaflet wrapper bundles fetched
|
|
1001
|
+
// from registry.ggui.ai, leaflet.css fetched from same, and
|
|
1002
|
+
// OSM tile requests to tile.openstreetmap.org all get blocked
|
|
1003
|
+
// → the component throws and the React error boundary renders
|
|
1004
|
+
// "Something went wrong." The /r/<shortCode> HTTP path already
|
|
1005
|
+
// derives these via deriveBundleOrigins; this is the per-call
|
|
1006
|
+
// resource mirror.
|
|
1007
|
+
const gadgetOrigins = deriveBundleOrigins(top.source);
|
|
1008
|
+
return shellContents(uri, html, augmentCspMeta(gadgetOrigins));
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// Registry-only fallback: session is gone (TTL / restart) but the
|
|
1012
|
+
// blueprint is still in the registry. Synthesize the shell from
|
|
1013
|
+
// the blueprint's componentCode + propsSpec defaults — strictly
|
|
1014
|
+
// worse than the live mount (no current props, no preserved
|
|
1015
|
+
// context state), but strictly better than the dead loading
|
|
1016
|
+
// shell.
|
|
1017
|
+
if (blueprint && opts.defaultAppIdFallback) {
|
|
1018
|
+
const html = await buildShellFromBlueprint({
|
|
1019
|
+
sessionId,
|
|
1020
|
+
appId: opts.defaultAppIdFallback,
|
|
1021
|
+
blueprint,
|
|
1022
|
+
runtimeUrl: opts.runtimeUrl,
|
|
1023
|
+
...(opts.themeId !== undefined ? { themeId: opts.themeId } : {}),
|
|
1024
|
+
...(opts.themeMode !== undefined ? { themeMode: opts.themeMode } : {}),
|
|
1025
|
+
...(opts.codeStore !== undefined ? { codeStore: opts.codeStore } : {}),
|
|
1026
|
+
...(opts.codeBaseUrl !== undefined ? { codeBaseUrl: opts.codeBaseUrl } : {}),
|
|
1027
|
+
});
|
|
1028
|
+
if (html !== undefined) {
|
|
1029
|
+
return shellContents(uri, html);
|
|
1030
|
+
}
|
|
1031
|
+
// Fallthrough to loading shell when codeStore isn't wired.
|
|
1032
|
+
}
|
|
1033
|
+
return loadingShell(uri, sessionId);
|
|
1034
|
+
}
|
|
1035
|
+
server.registerResource('ggui-session-self-contained', legacyTemplate, {
|
|
1036
|
+
title: 'ggui session (self-contained, legacy URI)',
|
|
1037
|
+
description: 'Per-session self-contained shell — single-segment URI shape predating the resume contract. Falls back to loading shell when the session is gone (no blueprintKey to do registry-only render).',
|
|
1038
|
+
mimeType: GGUI_SESSION_RESOURCE_MIME,
|
|
1039
|
+
}, handle);
|
|
1040
|
+
server.registerResource('ggui-session-self-contained-resume', resumeTemplate, {
|
|
1041
|
+
title: 'ggui session (self-contained, resume URI)',
|
|
1042
|
+
description: 'Per-session self-contained shell — two-segment URI shape carrying both sessionId AND blueprintKey. Resource handler runs Promise.all over session + registry; falls back to registry-only static render when the session has been evicted but the blueprint is still cached.',
|
|
1043
|
+
mimeType: GGUI_SESSION_RESOURCE_MIME,
|
|
1044
|
+
}, handle);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Synthesize a shell from a registry-only blueprint (no live session).
|
|
1048
|
+
* Used when chat-history rehydrate finds the session evicted but the
|
|
1049
|
+
* blueprint registry still holds the entry. Renders the same
|
|
1050
|
+
* componentCode the original push generated, seeded with the
|
|
1051
|
+
* contract's declared `propsSpec` defaults + `contextSpec` defaults.
|
|
1052
|
+
* Live state (the user's interactive edits, last-known context
|
|
1053
|
+
* values) is lost in this path; that's the cost of session eviction.
|
|
1054
|
+
*
|
|
1055
|
+
* Internal — exported nowhere because the only safe trigger path is
|
|
1056
|
+
* inside the resource handler with the resume URI shape (URI carries
|
|
1057
|
+
* the blueprintKey that bounds which blueprint we render).
|
|
1058
|
+
*/
|
|
1059
|
+
async function buildShellFromBlueprint(args) {
|
|
1060
|
+
const { blueprint } = args;
|
|
1061
|
+
if (!args.codeStore || !args.codeBaseUrl) {
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
const contract = blueprint.contract ?? {};
|
|
1065
|
+
const propsSpec = 'props' in contract && contract.props !== undefined
|
|
1066
|
+
? contract.props
|
|
1067
|
+
: undefined;
|
|
1068
|
+
const propsJson = propsSpec
|
|
1069
|
+
? JSON.stringify(deriveDefaultPropsValues(propsSpec))
|
|
1070
|
+
: undefined;
|
|
1071
|
+
const contextSlots = deriveDefaultContextSlots(contract.contextSpec);
|
|
1072
|
+
const actionNextSteps = 'actionSpec' in contract && contract.actionSpec !== undefined
|
|
1073
|
+
? deriveWiredActionToolsFromSpec(contract.actionSpec)
|
|
1074
|
+
: undefined;
|
|
1075
|
+
let codeUrl;
|
|
1076
|
+
let codeHash;
|
|
1077
|
+
try {
|
|
1078
|
+
codeHash = args.codeStore.hashOf(blueprint.componentCode);
|
|
1079
|
+
await args.codeStore.put(codeHash, blueprint.componentCode);
|
|
1080
|
+
const base = args.codeBaseUrl.replace(/\/$/, '');
|
|
1081
|
+
codeUrl = `${base}/code/${codeHash}.js`;
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
return undefined;
|
|
1085
|
+
}
|
|
1086
|
+
return buildSelfContainedShell({
|
|
1087
|
+
sessionId: args.sessionId,
|
|
1088
|
+
appId: args.appId,
|
|
1089
|
+
codeUrl,
|
|
1090
|
+
codeHash,
|
|
1091
|
+
runtimeUrl: args.runtimeUrl,
|
|
1092
|
+
...(args.themeId !== undefined ? { themeId: args.themeId } : {}),
|
|
1093
|
+
...(args.themeMode !== undefined ? { themeMode: args.themeMode } : {}),
|
|
1094
|
+
...(propsJson !== undefined ? { propsJson } : {}),
|
|
1095
|
+
...(contextSlots !== undefined ? { contextSlots } : {}),
|
|
1096
|
+
...(actionNextSteps !== undefined ? { actionNextSteps } : {}),
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
function deriveDefaultPropsValues(spec) {
|
|
1100
|
+
const out = {};
|
|
1101
|
+
for (const [name, entry] of Object.entries(spec.properties)) {
|
|
1102
|
+
if (entry.default !== undefined) {
|
|
1103
|
+
out[name] = entry.default;
|
|
1104
|
+
}
|
|
1105
|
+
else if (entry.schema && entry.schema.default !== undefined) {
|
|
1106
|
+
out[name] = entry.schema.default;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return out;
|
|
1110
|
+
}
|
|
1111
|
+
function deriveDefaultContextSlots(spec) {
|
|
1112
|
+
if (!spec)
|
|
1113
|
+
return undefined;
|
|
1114
|
+
const collected = [];
|
|
1115
|
+
for (const [name, entry] of Object.entries(spec)) {
|
|
1116
|
+
if (!entry || typeof entry !== 'object')
|
|
1117
|
+
continue;
|
|
1118
|
+
if (entry.schema === undefined || entry.schema === null)
|
|
1119
|
+
continue;
|
|
1120
|
+
if (typeof entry.schema !== 'object')
|
|
1121
|
+
continue;
|
|
1122
|
+
const fallback = deriveContextDefault(entry);
|
|
1123
|
+
collected.push({
|
|
1124
|
+
name,
|
|
1125
|
+
contextName: deriveContextName(name),
|
|
1126
|
+
schema: entry.schema,
|
|
1127
|
+
default: fallback === undefined ? null : fallback,
|
|
1128
|
+
...(entry.debounceMs !== undefined ? { debounceMs: entry.debounceMs } : {}),
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
return collected.length > 0 ? collected : undefined;
|
|
1132
|
+
}
|
|
1133
|
+
function deriveWiredActionToolsFromSpec(spec) {
|
|
1134
|
+
const collected = {};
|
|
1135
|
+
for (const [name, entry] of Object.entries(spec)) {
|
|
1136
|
+
if (entry &&
|
|
1137
|
+
typeof entry === 'object' &&
|
|
1138
|
+
typeof entry.nextStep === 'string' &&
|
|
1139
|
+
entry.nextStep.length > 0) {
|
|
1140
|
+
collected[name] = entry.nextStep;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return Object.keys(collected).length > 0 ? collected : undefined;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Apply the full MCP Apps outbound wiring to a fresh `McpServer` - both
|
|
1147
|
+
* the capability advertisement and the `ui://ggui/session` resource. The
|
|
1148
|
+
* single entry-point `build-mcp.ts` calls so request-path wiring stays
|
|
1149
|
+
* one line.
|
|
1150
|
+
*
|
|
1151
|
+
* When `selfContained` is supplied, ALSO registers the per-session
|
|
1152
|
+
* `ui://ggui/session/{sessionId}` resource template that serves the
|
|
1153
|
+
* self-contained shell (the path third-party MCP Apps hosts use). The
|
|
1154
|
+
* legacy static URI registration is unconditional — first-party hosts
|
|
1155
|
+
* (Studio, Portal, console) still rely on the postMessage path.
|
|
1156
|
+
*/
|
|
1157
|
+
export function installMcpAppsOutbound(server, opts = {}) {
|
|
1158
|
+
advertiseMcpAppsUiCapability(server);
|
|
1159
|
+
registerGguiSessionResource(server, opts.shellHtml, opts.publicBaseUrl);
|
|
1160
|
+
if (opts.selfContained) {
|
|
1161
|
+
registerGguiSessionResourceTemplate(server, opts.selfContained);
|
|
1162
|
+
}
|
|
1163
|
+
}
|