@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.
- package/dist/api-renders-routes.d.ts.map +1 -1
- package/dist/api-renders-routes.js +32 -23
- package/dist/api-renders-stream-route.d.ts +80 -0
- package/dist/api-renders-stream-route.d.ts.map +1 -0
- package/dist/api-renders-stream-route.js +311 -0
- package/dist/build-mcp.d.ts +10 -0
- package/dist/build-mcp.d.ts.map +1 -1
- package/dist/build-mcp.js +64 -4
- package/dist/console-session-routes.d.ts.map +1 -1
- package/dist/console-session-routes.js +11 -0
- package/dist/ggui-session-channel/action-ingress.d.ts +2 -2
- package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
- package/dist/ggui-session-channel/channel-subscriptions.d.ts +4 -4
- package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
- package/dist/ggui-session-channel/internal-types.d.ts +63 -9
- package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.d.ts +15 -5
- package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
- package/dist/ggui-session-channel/outbound.js +64 -24
- package/dist/ggui-session-channel/socket-router.d.ts +8 -3
- package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
- package/dist/ggui-session-channel/socket-router.js +6 -1
- package/dist/ggui-session-channel/subscribe.d.ts +48 -3
- package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscribe.js +97 -36
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts +23 -13
- package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
- package/dist/ggui-session-channel/subscriber-lifecycle.js +24 -11
- package/dist/ggui-session-channel.d.ts +58 -11
- package/dist/ggui-session-channel.d.ts.map +1 -1
- package/dist/ggui-session-channel.js +55 -19
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/instructions-presets.js +10 -10
- package/dist/mcp-apps-outbound.d.ts +49 -4
- package/dist/mcp-apps-outbound.d.ts.map +1 -1
- package/dist/mcp-apps-outbound.js +359 -63
- package/dist/mcp-endpoint-routes.d.ts +17 -5
- package/dist/mcp-endpoint-routes.d.ts.map +1 -1
- package/dist/mcp-endpoint-routes.js +2 -0
- package/dist/oauth-as-routes.d.ts.map +1 -1
- package/dist/oauth-as-routes.js +36 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/runtime-bundle-hash.d.ts +43 -0
- package/dist/runtime-bundle-hash.d.ts.map +1 -0
- package/dist/runtime-bundle-hash.js +66 -0
- package/dist/runtime-bundle-route.js +1 -1
- package/dist/server.d.ts +16 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +93 -19
- 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;
|
|
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
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
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}://${
|
|
160
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
+
}
|
package/dist/build-mcp.d.ts
CHANGED
|
@@ -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
|
package/dist/build-mcp.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
168
|
-
if (!isMcpUiToolMeta(
|
|
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 },
|