@ggui-ai/mcp-server 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/api-renders-routes.d.ts.map +1 -1
  2. package/dist/api-renders-routes.js +32 -23
  3. package/dist/api-renders-stream-route.d.ts +80 -0
  4. package/dist/api-renders-stream-route.d.ts.map +1 -0
  5. package/dist/api-renders-stream-route.js +311 -0
  6. package/dist/build-mcp.d.ts +10 -0
  7. package/dist/build-mcp.d.ts.map +1 -1
  8. package/dist/build-mcp.js +64 -4
  9. package/dist/console-session-routes.d.ts.map +1 -1
  10. package/dist/console-session-routes.js +11 -0
  11. package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
  12. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  13. package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
  14. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  15. package/dist/ggui-session-channel/internal-types.d.ts +63 -9
  16. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  17. package/dist/ggui-session-channel/outbound.d.ts +15 -5
  18. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  19. package/dist/ggui-session-channel/outbound.js +64 -24
  20. package/dist/ggui-session-channel/socket-router.d.ts +8 -3
  21. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  22. package/dist/ggui-session-channel/socket-router.js +6 -1
  23. package/dist/ggui-session-channel/subscribe.d.ts +48 -3
  24. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  25. package/dist/ggui-session-channel/subscribe.js +97 -36
  26. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
  27. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  28. package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
  29. package/dist/ggui-session-channel.d.ts +58 -11
  30. package/dist/ggui-session-channel.d.ts.map +1 -1
  31. package/dist/ggui-session-channel.js +55 -19
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +7 -1
  35. package/dist/instructions-presets.js +10 -10
  36. package/dist/mcp-apps-outbound.d.ts +49 -4
  37. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  38. package/dist/mcp-apps-outbound.js +359 -63
  39. package/dist/mcp-endpoint-routes.d.ts +17 -5
  40. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  41. package/dist/mcp-endpoint-routes.js +2 -0
  42. package/dist/oauth-as-routes.d.ts.map +1 -1
  43. package/dist/oauth-as-routes.js +36 -0
  44. package/dist/oauth.d.ts.map +1 -1
  45. package/dist/oauth.js +8 -1
  46. package/dist/runtime-bundle-hash.d.ts +43 -0
  47. package/dist/runtime-bundle-hash.d.ts.map +1 -0
  48. package/dist/runtime-bundle-hash.js +66 -0
  49. package/dist/runtime-bundle-route.js +1 -1
  50. package/dist/server.d.ts +16 -5
  51. package/dist/server.d.ts.map +1 -1
  52. package/dist/server.js +93 -19
  53. package/package.json +12 -12
@@ -1 +1 @@
1
- {"version":3,"file":"api-renders-routes.d.ts","sourceRoot":"","sources":["../src/api-renders-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAO9F,OAAO,EAEL,KAAK,sBAAsB,EAC5B,MAAM,yCAAyC,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,+CAA+C;IAC/C,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAC7C,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,sBAAsB,CAAC,WAAW,CAAC,CAAC;IACzD,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,qEAAqE;IACrE,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,KACV;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACzD,oEAAoE;IACpE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,MAAM,CAAC;IACzC,iDAAiD;IACjD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAgV9D"}
1
+ {"version":3,"file":"api-renders-routes.d.ts","sourceRoot":"","sources":["../src/api-renders-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAQ9F,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,yCAAyC,CAAC;AACjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,UAAU,YAAY;IACpB,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,+CAA+C;IAC/C,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAC7C,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,sBAAsB,CAAC,WAAW,CAAC,CAAC;IACzD,sEAAsE;IACtE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,qEAAqE;IACrE,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,KACV;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACzD,oEAAoE;IACpE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,MAAM,CAAC;IACzC,iDAAiD;IACjD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CA0V9D"}
@@ -44,8 +44,8 @@
44
44
  * or the render was reset). Clients re-mount from /state.
45
45
  */
46
46
  import { verifyToken } from "@ggui-ai/mcp-server-core";
47
- import { deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, } from "@ggui-ai/mcp-server-handlers/renders";
48
- import { MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations/mcp-apps";
47
+ import { deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, spreadRenderMetaViewOntoSlice, } from "@ggui-ai/mcp-server-handlers/renders";
48
+ import { composeSessionApiUrls, MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations/mcp-apps";
49
49
  /**
50
50
  * Mount `GET /api/sessions/:sessionId/state` +
51
51
  * `GET /api/sessions/:sessionId/events` onto the express app.
@@ -54,6 +54,11 @@ import { MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations
54
54
  export function mountApiRendersRoutes(opts) {
55
55
  const { app, renderStore, secret, appMetadataStore, themeId, themeMode, codeStore, publicBaseUrl, mintBootstrap, resolveRuntimeUrl, logger, } = opts;
56
56
  app.get("/api/sessions/:sessionId/state", async (req, res) => {
57
+ // CORS on EVERY response, gates included: cross-origin frames can
58
+ // only read a status when the response carries ACAO. Without it a
59
+ // 410 (refresh the wsToken) is an opaque network error — the rung
60
+ // demotes as "structurally unreachable" instead of refreshing.
61
+ res.setHeader("Access-Control-Allow-Origin", "*");
57
62
  const sessionId = req.params["sessionId"];
58
63
  if (typeof sessionId !== "string" || sessionId.length === 0) {
59
64
  res.status(400).type("text/plain").send("sessionId required");
@@ -149,15 +154,20 @@ export function mountApiRendersRoutes(opts) {
149
154
  // stamps this trio on its resultMeta; mirroring here closes the
150
155
  // drift.
151
156
  const liveTrio = mintBootstrap ? mintBootstrap(stored.id, stored.appId) : undefined;
152
- // Polling URL — the iframe-runtime's R6 polling-fallback path
153
- // composes its fetch URL from `render.pollingUrl`. The /state
154
- // endpoint IS that URL, so we stamp it here; without it the
155
- // iframe-runtime can't fall back when the WS upgrade fails.
156
- const requestHostForPolling = req.get("host") ?? "";
157
- const pollingBase = publicBaseUrl
157
+ // Token-bearing session-API URL pair (pollingUrl → /events,
158
+ // sseUrl → /stream), composed via the protocol's ONE composer so
159
+ // this surface cannot drift from the render/update resultMeta
160
+ // stamping. Stamped ONLY when the live trio above was minted —
161
+ // both URLs embed the fresh long-TTL token, so a minter-less
162
+ // deployment honestly omits them (the old unconditional
163
+ // token-less `/state`-shaped pollingUrl could only 401 through
164
+ // the iframe-runtime's /events composer).
165
+ const sessionApiBase = publicBaseUrl
158
166
  ? publicBaseUrl.replace(/\/$/, "")
159
- : `${req.protocol}://${requestHostForPolling}`;
160
- const pollingUrl = `${pollingBase}/api/sessions/${encodeURIComponent(stored.id)}/state`;
167
+ : `${req.protocol}://${req.get("host") ?? ""}`;
168
+ const sessionApiUrls = liveTrio !== undefined
169
+ ? composeSessionApiUrls(sessionApiBase, stored.id, liveTrio.token)
170
+ : undefined;
161
171
  // Static-component delivery via codeUrl (the same content-addressable
162
172
  // channel the code routes serve). Polling clients are render-capable
163
173
  // and need the URL to mount/refresh the static-component variant.
@@ -219,20 +229,19 @@ export function mountApiRendersRoutes(opts) {
219
229
  expiresAt: liveTrio.expiresAt,
220
230
  }
221
231
  : {}),
222
- pollingUrl,
232
+ ...(sessionApiUrls !== undefined
233
+ ? { pollingUrl: sessionApiUrls.pollingUrl, sseUrl: sessionApiUrls.sseUrl }
234
+ : {}),
223
235
  ...(themeId !== undefined ? { themeId } : {}),
224
236
  ...(themeMode !== undefined ? { themeMode } : {}),
225
- // Per-app theme overlay projected by `deriveRenderMeta` from the
226
- // render's `theme` sidecar — same field the render result-meta
227
- // carries, so a /state read returns the identical overlay.
228
- ...(view?.theme !== undefined ? { theme: view.theme } : {}),
229
- ...(view?.gadgets !== undefined && view.gadgets.length > 0 ? { gadgets: view.gadgets } : {}),
230
237
  ...(statePublicEnv !== undefined && Object.keys(statePublicEnv).length > 0
231
238
  ? { publicEnv: statePublicEnv }
232
239
  : {}),
233
- ...(view?.permissionsPolicy !== undefined && view.permissionsPolicy.length > 0
234
- ? { permissionsPolicy: view.permissionsPolicy }
235
- : {}),
240
+ // State + policy view fields (theme overlay, gadgets,
241
+ // permissionsPolicy, propsJson, contextSlots, #483 epoch) — ONE
242
+ // shared spread so /state cannot drift from the result-meta
243
+ // emitters (a hand-copied list once silently dropped `epoch`).
244
+ ...spreadRenderMetaViewOntoSlice(view),
236
245
  // R6 — load-bearing ledger cursor. Always stamped on /state
237
246
  // reads so polling clients can position the R7 /events cursor.
238
247
  lastSequence: stored.eventSequence,
@@ -244,8 +253,6 @@ export function mountApiRendersRoutes(opts) {
244
253
  ...(renderCodeHash !== undefined ? { codeHash: renderCodeHash } : {}),
245
254
  }
246
255
  : {}),
247
- ...(view?.propsJson !== undefined ? { propsJson: view.propsJson } : {}),
248
- ...(view?.contextSlots !== undefined ? { contextSlots: view.contextSlots } : {}),
249
256
  ...(renderContractHash !== undefined && renderValidatorsUrl !== undefined
250
257
  ? {
251
258
  contractHash: renderContractHash,
@@ -253,7 +260,6 @@ export function mountApiRendersRoutes(opts) {
253
260
  }
254
261
  : {}),
255
262
  };
256
- res.setHeader("Access-Control-Allow-Origin", "*");
257
263
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
258
264
  res.setHeader("Content-Type", "application/json; charset=utf-8");
259
265
  res.status(200).json({
@@ -261,6 +267,10 @@ export function mountApiRendersRoutes(opts) {
261
267
  });
262
268
  });
263
269
  app.get("/api/sessions/:sessionId/events", async (req, res) => {
270
+ // CORS pre-gates — same contract as /state: the 410/401 verdicts
271
+ // are part of the polling rung's protocol and must be readable
272
+ // from cross-origin frames.
273
+ res.setHeader("Access-Control-Allow-Origin", "*");
264
274
  const sessionId = req.params["sessionId"];
265
275
  if (typeof sessionId !== "string" || sessionId.length === 0) {
266
276
  res.status(400).type("text/plain").send("sessionId required");
@@ -368,7 +378,6 @@ export function mountApiRendersRoutes(opts) {
368
378
  // (Wave 7 of flatten-render-identity, 2026-05-28). The store
369
379
  // returns events in protocol-canonical shape (seq + type +
370
380
  // timestamp[ISO] + data); no projection needed.
371
- res.setHeader("Access-Control-Allow-Origin", "*");
372
381
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
373
382
  res.setHeader("Content-Type", "application/json; charset=utf-8");
374
383
  res.status(200).json({
@@ -0,0 +1,80 @@
1
+ /**
2
+ * wsToken-gated SSE live stream (the ladder's middle rung).
3
+ *
4
+ * GET /api/sessions/:sessionId/stream?wsToken=<token>[&sinceSequence=N][&fromSeq=M]
5
+ *
6
+ * Server-Sent Events delivery of the SAME ChannelFrame `{type,
7
+ * payload}` JSON the live-channel WS pushes — for hosts whose CSP
8
+ * blocks WebSocket upgrades but allows same-origin HTTP. The route
9
+ * registers a transport-neutral subscriber into the channel server via
10
+ * `attachExternalSubscriber`, so every fan-out plane WS subscribers
11
+ * ride (StreamFanout live tail, `props_update` / `render` /
12
+ * `drain_ack` walks, external broadcasts) reaches SSE subscribers with
13
+ * zero per-frame branching.
14
+ *
15
+ * Framing contract:
16
+ * - Every `data:` line is EXACTLY one ChannelFrame JSON — byte-same
17
+ * shape as the WS push (default event type, so `EventSource`'s
18
+ * `onmessage` fires).
19
+ * - `id:` = decimal GguiSessionEvent ledger seq, stamped ONLY on
20
+ * ledger-backed `render_event` replay frames (and the
21
+ * REPLAY_HORIZON_PASSED error frame, which carries the fresh
22
+ * high-water mark). Live frames are id-less — same ephemeral
23
+ * posture as WS; reconnects re-mount state from the ack snapshot.
24
+ * The browser's `Last-Event-ID` therefore lands on the exact
25
+ * cursor space `/events?sinceSequence=N` reads.
26
+ * - First write after headers: `retry: 3000`.
27
+ * - Heartbeat: comment line `: hb` every {@link SSE_HEARTBEAT_MS}
28
+ * (under ALB's 60s idle default); each tick doubles as a render-
29
+ * expiry probe.
30
+ *
31
+ * Cursor spaces: `Last-Event-ID` (browser-stamped on auto-reconnect)
32
+ * WINS over `?sinceSequence=`; the query seeds the first connect
33
+ * (EventSource cannot set headers). `?fromSeq=` mirrors
34
+ * `SubscribePayload.fromSeq` for the stream-buffer plane — note it is
35
+ * frozen in the EventSource URL, so duplicate `data` frames after an
36
+ * auto-reconnect are expected; ledger + stream deliveries are
37
+ * documented at-least-once and clients dedupe by seq.
38
+ *
39
+ * Auth: wsToken query gate cloned verbatim from `/state`
40
+ * (`api-renders-routes.ts`) — 401 missing/invalid/wrong-scope, 410
41
+ * expired, 404 unknown render, plus 503 when the channel is not yet
42
+ * constructed (pre-listen only). All gates run BEFORE any event-stream
43
+ * byte: EventSource fails the connection permanently on non-200, which
44
+ * is the client ladder's SSE→polling demotion signal.
45
+ */
46
+ import type { GguiSessionStore } from "@ggui-ai/mcp-server-core";
47
+ import type { Express } from "express";
48
+ import type { GguiSessionChannelServer } from "./ggui-session-channel.js";
49
+ import type { Logger } from "./logger.js";
50
+ /**
51
+ * Heartbeat cadence (25s). Under ALB's 60s idle default with 2x+
52
+ * headroom; each tick also probes render expiry so an evicted /
53
+ * expired render ends the stream instead of idling forever.
54
+ */
55
+ export declare const SSE_HEARTBEAT_MS = 25000;
56
+ export interface MountApiRendersStreamRouteOptions {
57
+ /** Express app to mount onto. */
58
+ readonly app: Express;
59
+ /** GguiSession store the gate + expiry probe read from. */
60
+ readonly renderStore: GguiSessionStore;
61
+ /** Shared HMAC secret the wsToken query credential verifies against. */
62
+ readonly secret: string;
63
+ /**
64
+ * Late-bound channel accessor — `createGguiSessionChannelServer`
65
+ * runs after route mounting, so the route resolves the channel per
66
+ * request (same pattern as `stream.channelProvider`). `null` is
67
+ * possible pre-listen only; the route answers 503.
68
+ */
69
+ readonly channelProvider: () => GguiSessionChannelServer | null;
70
+ /** Heartbeat override for tests. Defaults to {@link SSE_HEARTBEAT_MS}. */
71
+ readonly heartbeatMs?: number;
72
+ /** Structured logger. */
73
+ readonly logger: Logger;
74
+ }
75
+ /**
76
+ * Mount `GET /api/sessions/:sessionId/stream` onto the express app.
77
+ * Returns nothing — the route self-registers.
78
+ */
79
+ export declare function mountApiRendersStreamRoute(opts: MountApiRendersStreamRouteOptions): void;
80
+ //# sourceMappingURL=api-renders-stream-route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-renders-stream-route.d.ts","sourceRoot":"","sources":["../src/api-renders-stream-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAEH,OAAO,KAAK,EAAc,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAG7E,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,SAAS,CAAC;AACjD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAE1E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,QAAS,CAAC;AA2DvC,MAAM,WAAW,iCAAiC;IAChD,iCAAiC;IACjC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,wBAAwB,GAAG,IAAI,CAAC;IAChE,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,yBAAyB;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,iCAAiC,GAAG,IAAI,CA0MxF"}
@@ -0,0 +1,311 @@
1
+ /**
2
+ * wsToken-gated SSE live stream (the ladder's middle rung).
3
+ *
4
+ * GET /api/sessions/:sessionId/stream?wsToken=<token>[&sinceSequence=N][&fromSeq=M]
5
+ *
6
+ * Server-Sent Events delivery of the SAME ChannelFrame `{type,
7
+ * payload}` JSON the live-channel WS pushes — for hosts whose CSP
8
+ * blocks WebSocket upgrades but allows same-origin HTTP. The route
9
+ * registers a transport-neutral subscriber into the channel server via
10
+ * `attachExternalSubscriber`, so every fan-out plane WS subscribers
11
+ * ride (StreamFanout live tail, `props_update` / `render` /
12
+ * `drain_ack` walks, external broadcasts) reaches SSE subscribers with
13
+ * zero per-frame branching.
14
+ *
15
+ * Framing contract:
16
+ * - Every `data:` line is EXACTLY one ChannelFrame JSON — byte-same
17
+ * shape as the WS push (default event type, so `EventSource`'s
18
+ * `onmessage` fires).
19
+ * - `id:` = decimal GguiSessionEvent ledger seq, stamped ONLY on
20
+ * ledger-backed `render_event` replay frames (and the
21
+ * REPLAY_HORIZON_PASSED error frame, which carries the fresh
22
+ * high-water mark). Live frames are id-less — same ephemeral
23
+ * posture as WS; reconnects re-mount state from the ack snapshot.
24
+ * The browser's `Last-Event-ID` therefore lands on the exact
25
+ * cursor space `/events?sinceSequence=N` reads.
26
+ * - First write after headers: `retry: 3000`.
27
+ * - Heartbeat: comment line `: hb` every {@link SSE_HEARTBEAT_MS}
28
+ * (under ALB's 60s idle default); each tick doubles as a render-
29
+ * expiry probe.
30
+ *
31
+ * Cursor spaces: `Last-Event-ID` (browser-stamped on auto-reconnect)
32
+ * WINS over `?sinceSequence=`; the query seeds the first connect
33
+ * (EventSource cannot set headers). `?fromSeq=` mirrors
34
+ * `SubscribePayload.fromSeq` for the stream-buffer plane — note it is
35
+ * frozen in the EventSource URL, so duplicate `data` frames after an
36
+ * auto-reconnect are expected; ledger + stream deliveries are
37
+ * documented at-least-once and clients dedupe by seq.
38
+ *
39
+ * Auth: wsToken query gate cloned verbatim from `/state`
40
+ * (`api-renders-routes.ts`) — 401 missing/invalid/wrong-scope, 410
41
+ * expired, 404 unknown render, plus 503 when the channel is not yet
42
+ * constructed (pre-listen only). All gates run BEFORE any event-stream
43
+ * byte: EventSource fails the connection permanently on non-200, which
44
+ * is the client ladder's SSE→polling demotion signal.
45
+ */
46
+ import { verifyToken } from "@ggui-ai/mcp-server-core";
47
+ /**
48
+ * Heartbeat cadence (25s). Under ALB's 60s idle default with 2x+
49
+ * headroom; each tick also probes render expiry so an evicted /
50
+ * expired render ends the stream instead of idling forever.
51
+ */
52
+ export const SSE_HEARTBEAT_MS = 25_000;
53
+ /**
54
+ * SSE-backed {@link SubscriberSink}. Frames serialize to
55
+ * `data: <ChannelFrame JSON>\n\n` (JSON.stringify never emits raw
56
+ * newlines, so a single `data:` line is safe); `resumeId` prepends an
57
+ * `id: <seq>\n` line so the browser's `Last-Event-ID` advances on
58
+ * ledger-backed frames only.
59
+ */
60
+ class SseSink {
61
+ res;
62
+ logger;
63
+ /**
64
+ * Invoked exactly once, from `end()`, BEFORE the response closes —
65
+ * the route assigns its teardown (clear heartbeat + detach) here so
66
+ * a channel-initiated `end` (pod shutdown, expiry probe) tears the
67
+ * subscriber down through the same single path as a client close.
68
+ */
69
+ onEnd;
70
+ constructor(res, logger) {
71
+ this.res = res;
72
+ this.logger = logger;
73
+ }
74
+ isOpen() {
75
+ return !this.res.writableEnded && !this.res.destroyed;
76
+ }
77
+ write(frame, opts) {
78
+ if (!this.isOpen())
79
+ return;
80
+ try {
81
+ const idLine = opts?.resumeId !== undefined ? `id: ${opts.resumeId}\n` : "";
82
+ this.res.write(`${idLine}data: ${JSON.stringify(frame)}\n\n`);
83
+ }
84
+ catch (err) {
85
+ // Same posture as the WS send guard: per-frame write failures
86
+ // are logged, never propagated — a dead transport must not fail
87
+ // the fan-out caller.
88
+ this.logger.warn("sse_write_failed", { error: String(err) });
89
+ }
90
+ }
91
+ end(reason) {
92
+ const teardown = this.onEnd;
93
+ this.onEnd = undefined;
94
+ teardown?.();
95
+ this.logger.info("sse_stream_ended", { reason });
96
+ if (!this.res.writableEnded && !this.res.destroyed) {
97
+ this.res.end();
98
+ }
99
+ }
100
+ }
101
+ /** Strict non-negative decimal integer parse; `null` on anything else. */
102
+ function parseNonNegativeInt(raw) {
103
+ const n = Number(raw);
104
+ return Number.isInteger(n) && n >= 0 ? n : null;
105
+ }
106
+ /**
107
+ * Mount `GET /api/sessions/:sessionId/stream` onto the express app.
108
+ * Returns nothing — the route self-registers.
109
+ */
110
+ export function mountApiRendersStreamRoute(opts) {
111
+ const { app, renderStore, secret, channelProvider, logger } = opts;
112
+ const heartbeatMs = opts.heartbeatMs ?? SSE_HEARTBEAT_MS;
113
+ app.get("/api/sessions/:sessionId/stream", async (req, res) => {
114
+ // ── Phase 1: plain-HTTP gates, BEFORE any event-stream byte ──
115
+ // (EventSource fails permanently on non-200 — the demotion signal.)
116
+ // CORS pre-gates: a gate verdict without ACAO is an OPAQUE error
117
+ // to a cross-origin frame — the 410 refresh-your-token signal
118
+ // becomes indistinguishable from a CSP block, so the runtime
119
+ // demotes off a perfectly healthy rung instead of refreshing.
120
+ res.setHeader("Access-Control-Allow-Origin", "*");
121
+ const sessionId = req.params["sessionId"];
122
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
123
+ res.status(400).type("text/plain").send("sessionId required");
124
+ return;
125
+ }
126
+ const wsTokenRaw = req.query["wsToken"];
127
+ const wsToken = typeof wsTokenRaw === "string" ? wsTokenRaw : "";
128
+ if (wsToken.length === 0) {
129
+ res.status(401).type("text/plain").send("wsToken query required");
130
+ return;
131
+ }
132
+ const verify = verifyToken(wsToken, secret, "ws");
133
+ if (!verify.ok) {
134
+ // 410 Gone for expired (matches `BOOTSTRAP_EXPIRED` semantics on
135
+ // the WS upgrade): once-valid but aged out — refresh, don't
136
+ // treat as hostile. 401 for tamper / wrong-kind / malformed.
137
+ if (verify.reason === "expired") {
138
+ res.status(410).type("text/plain").send("wsToken expired");
139
+ return;
140
+ }
141
+ res.status(401).type("text/plain").send("wsToken invalid");
142
+ return;
143
+ }
144
+ // Tenancy gate: the wsToken's claimed sessionId MUST match the
145
+ // URL's sessionId.
146
+ if (verify.claims.sessionId !== sessionId) {
147
+ res.status(401).type("text/plain").send("wsToken scope mismatch");
148
+ return;
149
+ }
150
+ // Cursor queries — validated pre-headers so a caller bug is an
151
+ // honest 400, not a silently-ignored replay.
152
+ const sinceSequenceRaw = req.query["sinceSequence"];
153
+ let sinceSequence;
154
+ if (typeof sinceSequenceRaw === "string" && sinceSequenceRaw.length > 0) {
155
+ const parsed = parseNonNegativeInt(sinceSequenceRaw);
156
+ if (parsed === null) {
157
+ res.status(400).type("text/plain").send("sinceSequence must be a non-negative integer");
158
+ return;
159
+ }
160
+ sinceSequence = parsed;
161
+ }
162
+ const fromSeqRaw = req.query["fromSeq"];
163
+ let fromSeq;
164
+ if (typeof fromSeqRaw === "string" && fromSeqRaw.length > 0) {
165
+ const parsed = parseNonNegativeInt(fromSeqRaw);
166
+ if (parsed === null) {
167
+ res.status(400).type("text/plain").send("fromSeq must be a non-negative integer");
168
+ return;
169
+ }
170
+ fromSeq = parsed;
171
+ }
172
+ // Last-Event-ID (browser-stamped on auto-reconnect) WINS over the
173
+ // query seed. Malformed header → warn + fall back to the query
174
+ // (never a 4xx: the header is browser-controlled, and failing the
175
+ // reconnect permanently over a polyfill quirk would strand the
176
+ // client on a working credential).
177
+ const lastEventIdRaw = req.get("last-event-id");
178
+ if (typeof lastEventIdRaw === "string" && lastEventIdRaw.length > 0) {
179
+ const parsed = parseNonNegativeInt(lastEventIdRaw);
180
+ if (parsed === null) {
181
+ logger.warn("sse_last_event_id_malformed", {
182
+ sessionId,
183
+ lastEventId: lastEventIdRaw,
184
+ });
185
+ }
186
+ else {
187
+ sinceSequence = parsed;
188
+ }
189
+ }
190
+ let stored;
191
+ try {
192
+ stored = await renderStore.get(sessionId);
193
+ }
194
+ catch (err) {
195
+ logger.warn("sse_stream_read_failed", { sessionId, error: String(err) });
196
+ res.status(500).type("text/plain").send("internal error");
197
+ return;
198
+ }
199
+ if (!stored) {
200
+ // 404: render evicted / never existed — the browser's
201
+ // auto-reconnect lands here after an expiry-probe stream end,
202
+ // failing the EventSource permanently (clean rung exit).
203
+ res.status(404).type("text/plain").send("render not found");
204
+ return;
205
+ }
206
+ // Tenancy gate (round 2): the wsToken's appId MUST match the
207
+ // render's appId.
208
+ if (verify.claims.appId !== stored.appId) {
209
+ res.status(401).type("text/plain").send("wsToken scope mismatch");
210
+ return;
211
+ }
212
+ const channel = channelProvider();
213
+ if (channel === null) {
214
+ // Pre-listen only: mcpApps ⇒ renderChannel is enforced at
215
+ // composition, so a running server always has a channel. Honest
216
+ // guard rather than a crash on a boot-race request.
217
+ logger.warn("sse_stream_channel_unavailable", { sessionId });
218
+ res.status(503).type("text/plain").send("live channel not ready");
219
+ return;
220
+ }
221
+ // ── Phase 2: open the event stream ──
222
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
223
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
224
+ res.setHeader("Connection", "keep-alive");
225
+ // Tell nginx-family proxies not to buffer the stream. Self-hosters
226
+ // behind proxies that ignore this must configure pass-through.
227
+ res.setHeader("X-Accel-Buffering", "no");
228
+ res.flushHeaders?.();
229
+ res.write("retry: 3000\n\n");
230
+ const sink = new SseSink(res, logger);
231
+ // Single idempotent teardown shared by every exit path: client
232
+ // close, channel-initiated sink.end (pod shutdown / expiry probe),
233
+ // attach failure.
234
+ let detachFn = null;
235
+ let torn = false;
236
+ const teardown = () => {
237
+ if (torn)
238
+ return;
239
+ torn = true;
240
+ clearInterval(heartbeat);
241
+ detachFn?.();
242
+ };
243
+ sink.onEnd = teardown;
244
+ res.on("close", teardown);
245
+ // Heartbeat: `: hb` comment (invisible to the EventSource JS API,
246
+ // visible to proxies/ALBs) + render-expiry probe. A vanished or
247
+ // expired render ends the stream; the auto-reconnect then hits the
248
+ // 404 pre-gate for the permanent verdict. Store-read failure on a
249
+ // tick keeps streaming (availability over freshness; next tick
250
+ // retries).
251
+ const heartbeat = setInterval(() => {
252
+ if (!sink.isOpen()) {
253
+ teardown();
254
+ return;
255
+ }
256
+ res.write(": hb\n\n");
257
+ void (async () => {
258
+ let probe;
259
+ try {
260
+ probe = await renderStore.get(sessionId);
261
+ }
262
+ catch (err) {
263
+ logger.warn("sse_expiry_probe_failed", { sessionId, error: String(err) });
264
+ return;
265
+ }
266
+ if (!probe || probe.expiresAt <= Date.now()) {
267
+ sink.end("session_expired");
268
+ }
269
+ })();
270
+ }, heartbeatMs);
271
+ heartbeat.unref?.();
272
+ // Identity synthesized from the verified wsToken claims — the same
273
+ // bootstrap shape the WS subscribe path builds: a render-scoped
274
+ // credential, not a person (see subscribe.ts on why it must never
275
+ // become the row's subject).
276
+ const identity = {
277
+ identity: {
278
+ kind: "user",
279
+ userId: sessionId,
280
+ workspaceId: stored.appId,
281
+ roles: [],
282
+ },
283
+ source: "apikey",
284
+ };
285
+ try {
286
+ const { detach } = await channel.attachExternalSubscriber({
287
+ sessionId,
288
+ appId: stored.appId,
289
+ identity,
290
+ sink,
291
+ ...(sinceSequence !== undefined ? { sinceSequence } : {}),
292
+ ...(fromSeq !== undefined ? { fromSeq } : {}),
293
+ });
294
+ detachFn = detach;
295
+ if (torn) {
296
+ // Client disconnected while the attach was in flight — the
297
+ // teardown ran with no handle; undo the registration now.
298
+ detach();
299
+ }
300
+ }
301
+ catch (err) {
302
+ // Lost race with render eviction (the pre-gate passed moments
303
+ // ago) or a store failure inside the subscribe tail. Headers are
304
+ // out, so no status rewrite — end the stream; the reconnect gets
305
+ // the authoritative pre-gate verdict.
306
+ logger.warn("sse_attach_failed", { sessionId, error: String(err) });
307
+ teardown();
308
+ sink.end("service_restart");
309
+ }
310
+ });
311
+ }
@@ -60,6 +60,16 @@ export interface BuildMcpServerOptions {
60
60
  * host that owns the iframe CSP itself.
61
61
  */
62
62
  readonly publicBaseUrl?: string;
63
+ /**
64
+ * Live-channel origins for the static shell's CSP declaration —
65
+ * forwarded to `installMcpAppsOutbound`. Deployments that set no
66
+ * `publicBaseUrl` (the cloud pod) pass their `wsUrl` + its ws→http
67
+ * origin flip here so the mounted iframe's `connect-src` covers the
68
+ * SSE / HTTP-polling session API and the WebSocket; otherwise
69
+ * cross-origin hosts CSP-block every network rung of the failover
70
+ * ladder (#471 round 11).
71
+ */
72
+ readonly extraConnectUrls?: readonly (string | undefined)[];
63
73
  /**
64
74
  * Identity-kind allowlist for tool registration. When set, handlers
65
75
  * whose `allowedFor` field is non-empty AND does NOT intersect this
@@ -1 +1 @@
1
- {"version":3,"file":"build-mcp.d.ts","sourceRoot":"","sources":["../src/build-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAIpE,OAAO,EAAK,KAAK,WAAW,EAAE,MAAM,KAAK,CAAC;AAC1C,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAEL,KAAK,iCAAiC,EACvC,MAAM,wBAAwB,CAAC;AAEhC,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,iCAAiC,CAAC;IAC3D;;;;;;;;;OASG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,aAAa,CAAC,KAAK,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IAElE;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAE/B;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,aAAa,CAAC,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC,CAAC;CACtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,EAChE,UAAU,EAAE,MAAM,cAAc,EAChC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,qBAA0B,GAC/B,SAAS,CAqLX"}
1
+ {"version":3,"file":"build-mcp.d.ts","sourceRoot":"","sources":["../src/build-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAIpE,OAAO,EAAK,KAAK,WAAW,EAAE,MAAM,KAAK,CAAC;AAC1C,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,OAAO,EAEL,KAAK,iCAAiC,EACvC,MAAM,wBAAwB,CAAC;AAEhC,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,iCAAiC,CAAC;IAC3D;;;;;;;;;OASG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;;;OAQG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,CAAC,MAAM,GAAG,SAAS,CAAC,EAAE,CAAC;IAC5D;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,aAAa,CAAC,KAAK,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IAElE;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAE/B;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,aAAa,CAAC,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,CAAC,CAAC;CACtE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,aAAa,CAAC,aAAa,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,EAChE,UAAU,EAAE,MAAM,cAAc,EAChC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,qBAA0B,GAC/B,SAAS,CAmPX"}
package/dist/build-mcp.js CHANGED
@@ -13,6 +13,7 @@ import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
13
13
  import { isRecord } from '@ggui-ai/protocol';
14
14
  import { z } from 'zod';
15
15
  import { isHandlerFailure, } from '@ggui-ai/mcp-server-handlers';
16
+ import { GGUI_RENDER_RESOURCE_URI } from '@ggui-ai/protocol/integrations/mcp-apps';
16
17
  import { installMcpAppsOutbound, } from './mcp-apps-outbound.js';
17
18
  /**
18
19
  * Build a fresh MCP server with every handler registered.
@@ -28,8 +29,13 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
28
29
  ...(info.description ? { description: info.description } : {}),
29
30
  ...(opts.instructions ? { instructions: opts.instructions } : {}),
30
31
  });
32
+ // Content-addressed shell URI (stale-shell bust) — when MCP Apps
33
+ // outbound wiring registers the shell, declarations advertising the
34
+ // BARE `ui://ggui/render` are rewritten to the versioned twin below
35
+ // so host prefetch caches key on content, not on a constant string.
36
+ let shellResourceUri;
31
37
  if (opts.mcpAppsOutbound) {
32
- installMcpAppsOutbound(server, {
38
+ ({ shellResourceUri } = installMcpAppsOutbound(server, {
33
39
  ...(opts.shellHtml !== undefined ? { shellHtml: opts.shellHtml } : {}),
34
40
  // Thread the same per-request context accessor + logger the tool
35
41
  // path uses (`getContext`, param 3 of `buildMcpServer`) so the
@@ -41,7 +47,10 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
41
47
  ...(opts.publicBaseUrl !== undefined
42
48
  ? { publicBaseUrl: opts.publicBaseUrl }
43
49
  : {}),
44
- });
50
+ ...(opts.extraConnectUrls !== undefined
51
+ ? { extraConnectUrls: opts.extraConnectUrls }
52
+ : {}),
53
+ }));
45
54
  }
46
55
  // Per-request resource registrars supplied by the host. Run BEFORE
47
56
  // tool registration so `tools/list` ordering is unaffected and any
@@ -127,9 +136,52 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
127
136
  outcome: 'success',
128
137
  elapsedMs: Date.now() - start,
129
138
  });
139
+ // When the handler's output carries a `nextStep`, lead the
140
+ // model-visible content with the imperative in PLAIN TEXT.
141
+ // Burying the chain cue inside the JSON block proved fragile
142
+ // on live hosts (the first claude.ai #471 test: the agent
143
+ // rendered, never noticed `nextStep`, ended its turn, and the
144
+ // user's click had no listener). The JSON stays second —
145
+ // structured consumers read `structuredContent` anyway.
146
+ const nextStepHint = validated !== null &&
147
+ typeof validated === 'object' &&
148
+ 'nextStep' in validated &&
149
+ validated.nextStep &&
150
+ typeof validated.nextStep
151
+ .example === 'string'
152
+ ? validated.nextStep.example
153
+ : undefined;
154
+ // The gesture-poll wrapper below describes ggui_consume's
155
+ // semantics ("catch an immediate gesture", "waits up to 25s")
156
+ // — it is ONLY true when the nextStep IS the consume hint.
157
+ // Ungated, it decorated ggui_handshake results too (whose
158
+ // nextStep is a ggui_render example), telling agents a
159
+ // not-yet-rendered UI "has interactive actions" — a live agent
160
+ // flagged the contradiction against an actions=∅ contract
161
+ // (2026-08-12).
162
+ const gestureHint = nextStepHint !== undefined && nextStepHint.includes('ggui_consume')
163
+ ? nextStepHint
164
+ : undefined;
130
165
  return {
131
166
  structuredContent: validated,
132
167
  content: [
168
+ ...(gestureHint !== undefined
169
+ ? [
170
+ {
171
+ type: 'text',
172
+ // Gentle + bounded, deliberately: a forcing
173
+ // imperative hijacked live agents into polling
174
+ // instead of acting (matrix scenario 6), and an
175
+ // unbounded "re-call on empty" looped them past
176
+ // their turn budget. The poll is a latency
177
+ // optimization, not the delivery guarantee — when
178
+ // nobody is polling, a gesture rings the chat via
179
+ // ui/message and arrives as a new user message
180
+ // carrying its own consume directive.
181
+ text: `The UI has interactive actions. After this turn's work, you may call ${gestureHint} once to catch an immediate gesture (waits up to 25s); if events is empty, end your turn — later gestures arrive as new user messages.`,
182
+ },
183
+ ]
184
+ : []),
133
185
  { type: 'text', text: JSON.stringify(validated) },
134
186
  ],
135
187
  ...(meta !== undefined ? { _meta: meta } : {}),
@@ -164,11 +216,19 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
164
216
  // error on the handler author's side — fail loud rather than
165
217
  // silently registering the tool without its UI surface.
166
218
  if (handler._meta && 'ui' in handler._meta) {
167
- const ui = handler._meta['ui'];
168
- if (!isMcpUiToolMeta(ui)) {
219
+ const uiRaw = handler._meta['ui'];
220
+ if (!isMcpUiToolMeta(uiRaw)) {
169
221
  throw new Error(`Tool ${handler.name} declares _meta.ui with an invalid shape — ` +
170
222
  `expected { resourceUri?: string; visibility?: ('model' | 'app')[] }.`);
171
223
  }
224
+ // Declarations author the STABLE `ui://ggui/render` constant;
225
+ // registration swaps in the content-addressed twin so hosts
226
+ // prefetch (and cache) the shell by its content hash. Handlers
227
+ // stay host-cache-agnostic; the swap lives in ONE place.
228
+ const ui = shellResourceUri !== undefined &&
229
+ uiRaw.resourceUri === GGUI_RENDER_RESOURCE_URI
230
+ ? { ...uiRaw, resourceUri: shellResourceUri }
231
+ : uiRaw;
172
232
  registerAppTool(server, handler.name, {
173
233
  ...baseConfig,
174
234
  _meta: { ...handler._meta, ui },