@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.
Files changed (141) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/admin-blueprints-transport.d.ts +114 -0
  4. package/dist/admin-blueprints-transport.d.ts.map +1 -0
  5. package/dist/admin-blueprints-transport.js +118 -0
  6. package/dist/admin-oauth-providers-transport.d.ts +40 -0
  7. package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
  8. package/dist/admin-oauth-providers-transport.js +263 -0
  9. package/dist/auth.d.ts +39 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +75 -0
  12. package/dist/build-mcp.d.ts +128 -0
  13. package/dist/build-mcp.d.ts.map +1 -0
  14. package/dist/build-mcp.js +113 -0
  15. package/dist/code-store-fs.d.ts +19 -0
  16. package/dist/code-store-fs.d.ts.map +1 -0
  17. package/dist/code-store-fs.js +98 -0
  18. package/dist/console-auth.d.ts +139 -0
  19. package/dist/console-auth.d.ts.map +1 -0
  20. package/dist/console-auth.js +102 -0
  21. package/dist/console-cache.d.ts +78 -0
  22. package/dist/console-cache.d.ts.map +1 -0
  23. package/dist/console-cache.js +105 -0
  24. package/dist/console-headers.d.ts +124 -0
  25. package/dist/console-headers.d.ts.map +1 -0
  26. package/dist/console-headers.js +49 -0
  27. package/dist/console-llm-trace.d.ts +66 -0
  28. package/dist/console-llm-trace.d.ts.map +1 -0
  29. package/dist/console-llm-trace.js +105 -0
  30. package/dist/console-payloads.d.ts +67 -0
  31. package/dist/console-payloads.d.ts.map +1 -0
  32. package/dist/console-payloads.js +105 -0
  33. package/dist/console-theme-routes.d.ts +111 -0
  34. package/dist/console-theme-routes.d.ts.map +1 -0
  35. package/dist/console-theme-routes.js +202 -0
  36. package/dist/console-timeline.d.ts +45 -0
  37. package/dist/console-timeline.d.ts.map +1 -0
  38. package/dist/console-timeline.js +169 -0
  39. package/dist/console-validator.d.ts +67 -0
  40. package/dist/console-validator.d.ts.map +1 -0
  41. package/dist/console-validator.js +105 -0
  42. package/dist/console-welcome.d.ts +7 -0
  43. package/dist/console-welcome.d.ts.map +1 -0
  44. package/dist/console-welcome.js +221 -0
  45. package/dist/csrf-middleware.d.ts +55 -0
  46. package/dist/csrf-middleware.d.ts.map +1 -0
  47. package/dist/csrf-middleware.js +138 -0
  48. package/dist/email-login.d.ts +174 -0
  49. package/dist/email-login.d.ts.map +1 -0
  50. package/dist/email-login.js +254 -0
  51. package/dist/email-resend.d.ts +29 -0
  52. package/dist/email-resend.d.ts.map +1 -0
  53. package/dist/email-resend.js +71 -0
  54. package/dist/email-sender-from-env.d.ts +34 -0
  55. package/dist/email-sender-from-env.d.ts.map +1 -0
  56. package/dist/email-sender-from-env.js +112 -0
  57. package/dist/email-smtp.d.ts +42 -0
  58. package/dist/email-smtp.d.ts.map +1 -0
  59. package/dist/email-smtp.js +81 -0
  60. package/dist/index.d.ts +102 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +122 -0
  63. package/dist/instructions-presets.d.ts +112 -0
  64. package/dist/instructions-presets.d.ts.map +1 -0
  65. package/dist/instructions-presets.js +195 -0
  66. package/dist/llm-backed-negotiator.d.ts +178 -0
  67. package/dist/llm-backed-negotiator.d.ts.map +1 -0
  68. package/dist/llm-backed-negotiator.js +579 -0
  69. package/dist/logger.d.ts +23 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +41 -0
  72. package/dist/mcp-apps-inbound.d.ts +86 -0
  73. package/dist/mcp-apps-inbound.d.ts.map +1 -0
  74. package/dist/mcp-apps-inbound.js +278 -0
  75. package/dist/mcp-apps-outbound.d.ts +448 -0
  76. package/dist/mcp-apps-outbound.d.ts.map +1 -0
  77. package/dist/mcp-apps-outbound.js +1163 -0
  78. package/dist/mcp-mounts.d.ts +239 -0
  79. package/dist/mcp-mounts.d.ts.map +1 -0
  80. package/dist/mcp-mounts.js +222 -0
  81. package/dist/oauth-login-types.d.ts +160 -0
  82. package/dist/oauth-login-types.d.ts.map +1 -0
  83. package/dist/oauth-login-types.js +9 -0
  84. package/dist/oauth-login.d.ts +77 -0
  85. package/dist/oauth-login.d.ts.map +1 -0
  86. package/dist/oauth-login.js +455 -0
  87. package/dist/oauth-providers/github.d.ts +17 -0
  88. package/dist/oauth-providers/github.d.ts.map +1 -0
  89. package/dist/oauth-providers/github.js +89 -0
  90. package/dist/oauth-providers/google.d.ts +18 -0
  91. package/dist/oauth-providers/google.d.ts.map +1 -0
  92. package/dist/oauth-providers/google.js +59 -0
  93. package/dist/oauth-providers-store.d.ts +32 -0
  94. package/dist/oauth-providers-store.d.ts.map +1 -0
  95. package/dist/oauth-providers-store.js +291 -0
  96. package/dist/oauth.d.ts +347 -0
  97. package/dist/oauth.d.ts.map +1 -0
  98. package/dist/oauth.js +686 -0
  99. package/dist/pairing-transport.d.ts +99 -0
  100. package/dist/pairing-transport.d.ts.map +1 -0
  101. package/dist/pairing-transport.js +223 -0
  102. package/dist/rate-limit-middleware.d.ts +36 -0
  103. package/dist/rate-limit-middleware.d.ts.map +1 -0
  104. package/dist/rate-limit-middleware.js +57 -0
  105. package/dist/render-gate.d.ts +87 -0
  106. package/dist/render-gate.d.ts.map +1 -0
  107. package/dist/render-gate.js +77 -0
  108. package/dist/render-rate-limit.d.ts +59 -0
  109. package/dist/render-rate-limit.d.ts.map +1 -0
  110. package/dist/render-rate-limit.js +73 -0
  111. package/dist/render-signing.d.ts +98 -0
  112. package/dist/render-signing.d.ts.map +1 -0
  113. package/dist/render-signing.js +113 -0
  114. package/dist/request-context.d.ts +113 -0
  115. package/dist/request-context.d.ts.map +1 -0
  116. package/dist/request-context.js +154 -0
  117. package/dist/reserved-validators.d.ts +22 -0
  118. package/dist/reserved-validators.d.ts.map +1 -0
  119. package/dist/reserved-validators.js +101 -0
  120. package/dist/schema-compat.d.ts +167 -0
  121. package/dist/schema-compat.d.ts.map +1 -0
  122. package/dist/schema-compat.js +187 -0
  123. package/dist/security-headers-middleware.d.ts +38 -0
  124. package/dist/security-headers-middleware.d.ts.map +1 -0
  125. package/dist/security-headers-middleware.js +30 -0
  126. package/dist/server.d.ts +2060 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +6338 -0
  129. package/dist/session-channel.d.ts +651 -0
  130. package/dist/session-channel.d.ts.map +1 -0
  131. package/dist/session-channel.js +1756 -0
  132. package/dist/storage.d.ts +89 -0
  133. package/dist/storage.d.ts.map +1 -0
  134. package/dist/storage.js +171 -0
  135. package/dist/thread-transport.d.ts +118 -0
  136. package/dist/thread-transport.d.ts.map +1 -0
  137. package/dist/thread-transport.js +478 -0
  138. package/dist/user-session-auth.d.ts +167 -0
  139. package/dist/user-session-auth.d.ts.map +1 -0
  140. package/dist/user-session-auth.js +148 -0
  141. 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, '&amp;')
677
+ .replace(/"/g, '&quot;')
678
+ .replace(/</g, '&lt;')
679
+ .replace(/>/g, '&gt;');
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, '&amp;')
707
+ .replace(/"/g, '&quot;')
708
+ .replace(/</g, '&lt;')
709
+ .replace(/>/g, '&gt;')}">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
+ }