@ggui-ai/mcp-server 0.25.0 → 0.27.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 (59) hide show
  1. package/README.md +2 -2
  2. package/dist/api-renders-routes.d.ts +46 -5
  3. package/dist/api-renders-routes.d.ts.map +1 -1
  4. package/dist/api-renders-routes.js +98 -39
  5. package/dist/api-renders-stream-route.d.ts +2 -1
  6. package/dist/api-renders-stream-route.d.ts.map +1 -1
  7. package/dist/api-renders-stream-route.js +14 -2
  8. package/dist/build-mcp.d.ts +16 -0
  9. package/dist/build-mcp.d.ts.map +1 -1
  10. package/dist/build-mcp.js +48 -20
  11. package/dist/console-auth.d.ts +10 -10
  12. package/dist/console-auth.d.ts.map +1 -1
  13. package/dist/first-seen.d.ts +14 -0
  14. package/dist/first-seen.d.ts.map +1 -0
  15. package/dist/first-seen.js +19 -0
  16. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  17. package/dist/ggui-session-channel/action-ingress.js +96 -8
  18. package/dist/ggui-session-channel/channel-subscriptions.d.ts +0 -1
  19. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  20. package/dist/ggui-session-channel/channel-subscriptions.js +4 -1
  21. package/dist/ggui-session-channel/internal-types.d.ts +20 -12
  22. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  23. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  24. package/dist/ggui-session-channel/outbound.js +18 -6
  25. package/dist/ggui-session-channel/stream-epoch.d.ts +61 -0
  26. package/dist/ggui-session-channel/stream-epoch.d.ts.map +1 -0
  27. package/dist/ggui-session-channel/stream-epoch.js +47 -0
  28. package/dist/ggui-session-channel/subscribe.d.ts +29 -17
  29. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  30. package/dist/ggui-session-channel/subscribe.js +247 -124
  31. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  32. package/dist/ggui-session-channel/subscriber-lifecycle.js +17 -7
  33. package/dist/ggui-session-channel.d.ts +13 -5
  34. package/dist/ggui-session-channel.d.ts.map +1 -1
  35. package/dist/ggui-session-channel.js +2 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/instructions-presets.d.ts +22 -21
  40. package/dist/instructions-presets.d.ts.map +1 -1
  41. package/dist/instructions-presets.js +38 -38
  42. package/dist/llm-backed-negotiator.d.ts +1 -36
  43. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  44. package/dist/llm-backed-negotiator.js +0 -196
  45. package/dist/mcp-apps-outbound.d.ts +39 -90
  46. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  47. package/dist/mcp-apps-outbound.js +45 -3
  48. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  49. package/dist/mcp-endpoint-routes.js +4 -1
  50. package/dist/server.d.ts +108 -33
  51. package/dist/server.d.ts.map +1 -1
  52. package/dist/server.js +181 -48
  53. package/dist/storage.d.ts +9 -1
  54. package/dist/storage.d.ts.map +1 -1
  55. package/dist/storage.js +4 -3
  56. package/dist/view-proof-gate.d.ts +96 -0
  57. package/dist/view-proof-gate.d.ts.map +1 -0
  58. package/dist/view-proof-gate.js +167 -0
  59. package/package.json +14 -15
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @ggui-ai/mcp-server
2
2
 
3
- Self-hosted [MCP](https://modelcontextprotocol.io) server for the **ggui protocol** —
3
+ Self-hosted [MCP](https://modelcontextprotocol.io) server for the **GGUI protocol** —
4
4
  the interface layer that lets AI agents describe UIs in natural language and have
5
- ggui generate ephemeral interfaces for them.
5
+ GGUI generate ephemeral interfaces for them.
6
6
 
7
7
  This package is the HTTP/MCP binding layer. It composes
8
8
  [`@ggui-ai/mcp-server-handlers`](https://www.npmjs.com/package/@ggui-ai/mcp-server-handlers)
@@ -13,7 +13,27 @@
13
13
  * wsToken-gated: same credential as the live-channel WS upgrade
14
14
  * (`?wsToken=<token>` on `/ws`). Drift-free with the WS surface —
15
15
  * the iframe-runtime already has the token from the bootstrap
16
- * envelope; no separate refresh path needed.
16
+ * envelope.
17
+ *
18
+ * Each read renews the token for its holder: a POSSESSION renewal
19
+ * (ggui#1496 part B, slice 2). The renewal carries its chain's root
20
+ * forward (`rootIat`: the presented token's, or its `iat` when it is
21
+ * itself a root) and its `exp` never passes `rootIat + refresh window`,
22
+ * so a chain of `/state` renewals ends one window after its root was
23
+ * minted, at the same second on every gate (for a token minted under the
24
+ * current window: one minted under a longer window, by an earlier config or
25
+ * another server sharing the secret, stays live on the other gates until
26
+ * its own `exp`). Past it, a new credential needs an authorized door, such
27
+ * as `ggui_runtime_refresh_ws_token`.
28
+ *
29
+ * Responses (/state):
30
+ * - 200 — the slice envelope, with the renewed trio and the
31
+ * token-bearing `pollingUrl` / `sseUrl`.
32
+ * - 401 — wsToken missing / invalid / wrong-scope.
33
+ * - 404 — sessionId does not resolve.
34
+ * - 410 — `wsToken expired`: past its `exp`, or its chain has no second
35
+ * left (the bound's second arrived after the expiry check, or the
36
+ * token was minted under a longer window).
17
37
  *
18
38
  * Distinct from `/r/:shortCode` (JSON branch):
19
39
  * - `/r/...` was shortCode-gated (bearer-by-obscurity; anyone with
@@ -42,6 +62,15 @@
42
62
  * `sinceSequence` is below the server's replay horizon OR strictly
43
63
  * greater than `lastSequence` (cursor is from a stale deployment
44
64
  * or the render was reset). Clients re-mount from /state.
65
+ *
66
+ * Log (/events): the first 200 per server process, session and kind logs
67
+ * `render_channel_polled_first {sessionId, appId, kind}`, so a view on the
68
+ * HTTP polling rung leaves a mount mark tagged with its app, as
69
+ * `render_channel_subscribed` does for the ws and sse rungs. `kind` is
70
+ * `cold-mount` for the boot fetch (`sinceSequence=0&limit=1`) and `poll`
71
+ * otherwise. It fires after every gate, so the `sessionId` is one the
72
+ * token's app owns, and never carries the query string (the wsToken
73
+ * rides there).
45
74
  */
46
75
  import type { AppMetadataStore, CodeStore, GguiSessionStore } from "@ggui-ai/mcp-server-core";
47
76
  import { type McpAppAiGguiRenderMeta } from "@ggui-ai/protocol/integrations/mcp-apps";
@@ -85,15 +114,27 @@ interface MountOptions {
85
114
  readonly hash: string;
86
115
  readonly base: string;
87
116
  }) => string | undefined;
88
- /** Live-mode credential minter — fresh trio on every /state read. */
89
- readonly mintBootstrap?: (sessionId: string, appId: string) => {
117
+ /**
118
+ * `/state`'s one live-credential minter (ggui#1496 part B, slice 2): a
119
+ * possession renewal. It mints a ws token that carries the presented
120
+ * chain's `rootIat` forward, with its `exp` clamped to
121
+ * `rootIat + refresh window`, so a chain of `/state` renewals ends there.
122
+ * `undefined` means the chain has no second left, answered as the ordinary
123
+ * expiry.
124
+ */
125
+ readonly mintChainedBootstrap: (sessionId: string, appId: string, rootIat: number) => {
90
126
  wsUrl: string;
91
127
  token: string;
92
128
  expiresAt: string;
93
- };
129
+ } | undefined;
94
130
  /** Per-request runtime-bundle URL resolver (tunnel/proxy aware). */
95
131
  readonly resolveRuntimeUrl: () => string;
96
- /** Structured logger for store-read failures. */
132
+ /**
133
+ * Structured logger for store-read failures, for the `/state`
134
+ * renewals refused because a chain expired (`state_chain_expired`),
135
+ * and for the first successful `/events` read per session and kind
136
+ * (`render_channel_polled_first`).
137
+ */
97
138
  readonly logger: Logger;
98
139
  }
99
140
  /**
@@ -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;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;AAG1C,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;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE;QAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,KAAK,MAAM,GAAG,SAAS,CAAC;IACzB,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,CAoX9D"}
1
+ {"version":3,"file":"api-renders-routes.d.ts","sourceRoot":"","sources":["../src/api-renders-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEG;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;AAEvC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAM1C,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;;;;;;;OAOG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE;QAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;KACvB,KAAK,MAAM,GAAG,SAAS,CAAC;IACzB;;;;;;;OAOG;IACH,QAAQ,CAAC,oBAAoB,EAAE,CAC7B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,KACZ;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IACrE,oEAAoE;IACpE,QAAQ,CAAC,iBAAiB,EAAE,MAAM,MAAM,CAAC;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAkZ9D"}
@@ -13,7 +13,27 @@
13
13
  * wsToken-gated: same credential as the live-channel WS upgrade
14
14
  * (`?wsToken=<token>` on `/ws`). Drift-free with the WS surface —
15
15
  * the iframe-runtime already has the token from the bootstrap
16
- * envelope; no separate refresh path needed.
16
+ * envelope.
17
+ *
18
+ * Each read renews the token for its holder: a POSSESSION renewal
19
+ * (ggui#1496 part B, slice 2). The renewal carries its chain's root
20
+ * forward (`rootIat`: the presented token's, or its `iat` when it is
21
+ * itself a root) and its `exp` never passes `rootIat + refresh window`,
22
+ * so a chain of `/state` renewals ends one window after its root was
23
+ * minted, at the same second on every gate (for a token minted under the
24
+ * current window: one minted under a longer window, by an earlier config or
25
+ * another server sharing the secret, stays live on the other gates until
26
+ * its own `exp`). Past it, a new credential needs an authorized door, such
27
+ * as `ggui_runtime_refresh_ws_token`.
28
+ *
29
+ * Responses (/state):
30
+ * - 200 — the slice envelope, with the renewed trio and the
31
+ * token-bearing `pollingUrl` / `sseUrl`.
32
+ * - 401 — wsToken missing / invalid / wrong-scope.
33
+ * - 404 — sessionId does not resolve.
34
+ * - 410 — `wsToken expired`: past its `exp`, or its chain has no second
35
+ * left (the bound's second arrived after the expiry check, or the
36
+ * token was minted under a longer window).
17
37
  *
18
38
  * Distinct from `/r/:shortCode` (JSON branch):
19
39
  * - `/r/...` was shortCode-gated (bearer-by-obscurity; anyone with
@@ -42,23 +62,60 @@
42
62
  * `sinceSequence` is below the server's replay horizon OR strictly
43
63
  * greater than `lastSequence` (cursor is from a stale deployment
44
64
  * or the render was reset). Clients re-mount from /state.
65
+ *
66
+ * Log (/events): the first 200 per server process, session and kind logs
67
+ * `render_channel_polled_first {sessionId, appId, kind}`, so a view on the
68
+ * HTTP polling rung leaves a mount mark tagged with its app, as
69
+ * `render_channel_subscribed` does for the ws and sse rungs. `kind` is
70
+ * `cold-mount` for the boot fetch (`sinceSequence=0&limit=1`) and `poll`
71
+ * otherwise. It fires after every gate, so the `sessionId` is one the
72
+ * token's app owns, and never carries the query string (the wsToken
73
+ * rides there).
45
74
  */
46
- import { verifyToken } from "@ggui-ai/mcp-server-core";
75
+ import { verifyToken, verifyWsTokenSignature } from "@ggui-ai/mcp-server-core";
47
76
  import { deriveContractBundle, derivePublicEnvProjection, deriveRenderMeta, spreadRenderMetaViewOntoSlice, } from "@ggui-ai/mcp-server-handlers/renders";
48
77
  import { composeSessionApiUrls, MCP_APP_AI_GGUI_RENDER_META_KEY, } from "@ggui-ai/protocol/integrations/mcp-apps";
78
+ import { createFirstSeen } from "./first-seen.js";
49
79
  import { createPublicReadPreflight } from "./browser-cors.js";
80
+ /** How many (kind, session) pairs `/events` remembers for its first-success line. */
81
+ const POLLED_FIRST_CAPACITY = 10_000;
50
82
  /**
51
83
  * Mount `GET /api/sessions/:sessionId/state` +
52
84
  * `GET /api/sessions/:sessionId/events` onto the express app.
53
85
  * Returns nothing — the routes self-register.
54
86
  */
55
87
  export function mountApiRendersRoutes(opts) {
56
- const { app, renderStore, secret, appMetadataStore, themeId, themeMode, codeStore, publicBaseUrl, codeBaseUrl, mintBootstrap, resolveRuntimeUrl, logger, } = opts;
88
+ const { app, renderStore, secret, appMetadataStore, themeId, themeMode, codeStore, publicBaseUrl, codeBaseUrl, mintChainedBootstrap, resolveRuntimeUrl, logger, } = opts;
57
89
  // The origin the content-addressable routes are reached at (asset
58
90
  // host if fronted separately, else the public origin); the request
59
91
  // host is the last resort for local/tunnel deployments.
60
92
  const staticBase = codeBaseUrl ?? publicBaseUrl;
93
+ // Which (kind, session) pairs `/events` has already logged a first
94
+ // success for. Bounded, so a long-running process never grows with its
95
+ // session count; a session forgotten under that bound logs again.
96
+ const polledFirst = createFirstSeen(POLLED_FIRST_CAPACITY);
61
97
  // ggui#1231 — a public `*` read owns its preflight (null-origin frames).
98
+ /**
99
+ * One line per `/state` renewal refused because its chain expired
100
+ * (ggui#1496 S6, ggui#1540): the presented token is past its `exp`, or
101
+ * its chain has no second left. Its signature is checked at any age, so
102
+ * only a token this server signed is counted, and the line carries ids
103
+ * only: the session and how old the chain's root is. A chain ends at its
104
+ * bound (the last renewal's `exp` is clamped to `rootIat + window`), so a
105
+ * `rootAgeSec` at or past the refresh window is a chain that reached it,
106
+ * and a smaller one a token that went unrenewed. Both answer the same 410,
107
+ * whose documented action is the authorized refresh.
108
+ */
109
+ function logStateChainExpired(token) {
110
+ const signed = verifyWsTokenSignature(token, secret);
111
+ if (!signed.ok)
112
+ return;
113
+ const rootIat = signed.claims.rootIat ?? signed.claims.iat;
114
+ logger.info("state_chain_expired", {
115
+ sessionId: signed.claims.sessionId,
116
+ rootAgeSec: Math.max(0, Math.floor(Date.now() / 1000) - rootIat),
117
+ });
118
+ }
62
119
  app.options("/api/sessions/:sessionId/state", createPublicReadPreflight());
63
120
  app.get("/api/sessions/:sessionId/state", async (req, res) => {
64
121
  // CORS on EVERY response, gates included: cross-origin frames can
@@ -85,6 +142,7 @@ export function mountApiRendersRoutes(opts) {
85
142
  // surface a refresh-vs-rehandshake prompt instead of treating
86
143
  // it as a hostile request.
87
144
  if (verify.reason === "expired") {
145
+ logStateChainExpired(wsToken);
88
146
  res.status(410).type("text/plain").send("wsToken expired");
89
147
  return;
90
148
  }
@@ -152,31 +210,36 @@ export function mountApiRendersRoutes(opts) {
152
210
  // Silent — wrappers calling getPublicEnv throw clearly.
153
211
  }
154
212
  }
155
- // Live-mode credential trio. Always minted fresh on /state reads
156
- // so the iframe-runtime gets a long-TTL token + the host learns
157
- // `wsUrl` (the CSP-permitted WebSocket origin). Without this,
158
- // any caller of /state — including the restore-bootstrap path in
159
- // every host (claude.ai, ChatGPT, our sample-agent) — receives a
160
- // render slice missing `wsUrl`. CSP `connect-src` then omits the
161
- // `ws://` scheme, the browser blocks the upgrade, and props_update
162
- // never reaches the iframe. The render-commit handler already
163
- // stamps this trio on its resultMeta; mirroring here closes the
164
- // drift.
165
- const liveTrio = mintBootstrap ? mintBootstrap(stored.id, stored.appId) : undefined;
213
+ // Live-mode credential trio, renewed on every /state read so the
214
+ // iframe-runtime gets a live token + the host learns `wsUrl` (the
215
+ // CSP-permitted WebSocket origin). Without `wsUrl`, any caller of
216
+ // /state — including the restore-bootstrap path in every host
217
+ // (claude.ai, ChatGPT, our sample-agent) — gets a slice whose CSP
218
+ // `connect-src` omits the `ws://` scheme, the browser blocks the
219
+ // upgrade, and props_update never reaches the iframe. The
220
+ // render-commit handler stamps the same trio on its resultMeta.
221
+ //
222
+ // This read is a POSSESSION renewal (ggui#1496 part B, slice 2): the
223
+ // presented token is the only credential, so the renewal carries the
224
+ // chain's root forward (its `rootIat`, or its `iat` when it is itself
225
+ // a root) and never outlives `rootIat + refresh window`. A chain with
226
+ // no second left is the ordinary expiry: the same 410 and body as a
227
+ // token past its `exp`, whose documented action is the authorized
228
+ // refresh (`ggui_runtime_refresh_ws_token`).
229
+ const liveTrio = mintChainedBootstrap(stored.id, stored.appId, verify.claims.rootIat ?? verify.claims.iat);
230
+ if (liveTrio === undefined) {
231
+ logStateChainExpired(wsToken);
232
+ res.status(410).type("text/plain").send("wsToken expired");
233
+ return;
234
+ }
166
235
  // Token-bearing session-API URL pair (pollingUrl → /events,
167
236
  // sseUrl → /stream), composed via the protocol's ONE composer so
168
237
  // this surface cannot drift from the render/update resultMeta
169
- // stamping. Stamped ONLY when the live trio above was minted —
170
- // both URLs embed the fresh long-TTL token, so a minter-less
171
- // deployment honestly omits them (the old unconditional
172
- // token-less `/state`-shaped pollingUrl could only 401 through
173
- // the iframe-runtime's /events composer).
238
+ // stamping. Both embed the renewed token.
174
239
  const sessionApiBase = publicBaseUrl
175
240
  ? publicBaseUrl.replace(/\/$/, "")
176
241
  : `${req.protocol}://${req.get("host") ?? ""}`;
177
- const sessionApiUrls = liveTrio !== undefined
178
- ? composeSessionApiUrls(sessionApiBase, stored.id, liveTrio.token)
179
- : undefined;
242
+ const sessionApiUrls = composeSessionApiUrls(sessionApiBase, stored.id, liveTrio.token);
180
243
  // Static-component delivery via codeUrl (the same content-addressable
181
244
  // channel the code routes serve). Polling clients are render-capable
182
245
  // and need the URL to mount/refresh the static-component variant.
@@ -210,13 +273,8 @@ export function mountApiRendersRoutes(opts) {
210
273
  catch {
211
274
  // Silent — the caller falls back to live-mode delivery, and
212
275
  // here that fallback is structural rather than hoped for:
213
- // `mintBootstrap` is optional on the options type, but these
214
- // routes mount only under `mcpAppsEnabled` + a render store
215
- // + a token secret, which are the same preconditions that
216
- // assign the minter. So a reader of THIS file cannot see the
217
- // guarantee — it lives at the mount site, and it is what
218
- // keeps the silence honest. Lose that pairing and this catch
219
- // becomes a swallow.
276
+ // `mintChainedBootstrap` is required on the options type and
277
+ // this response always carries the live trio minted above.
220
278
  }
221
279
  try {
222
280
  const bundle = await deriveContractBundle(render);
@@ -241,16 +299,11 @@ export function mountApiRendersRoutes(opts) {
241
299
  sessionId: stored.id,
242
300
  appId: stored.appId,
243
301
  runtimeUrl: resolveRuntimeUrl(),
244
- ...(liveTrio !== undefined
245
- ? {
246
- wsUrl: liveTrio.wsUrl,
247
- wsToken: liveTrio.token,
248
- expiresAt: liveTrio.expiresAt,
249
- }
250
- : {}),
251
- ...(sessionApiUrls !== undefined
252
- ? { pollingUrl: sessionApiUrls.pollingUrl, sseUrl: sessionApiUrls.sseUrl }
253
- : {}),
302
+ wsUrl: liveTrio.wsUrl,
303
+ wsToken: liveTrio.token,
304
+ expiresAt: liveTrio.expiresAt,
305
+ pollingUrl: sessionApiUrls.pollingUrl,
306
+ sseUrl: sessionApiUrls.sseUrl,
254
307
  ...(themeId !== undefined ? { themeId } : {}),
255
308
  ...(themeMode !== undefined ? { themeMode } : {}),
256
309
  ...(statePublicEnv !== undefined && Object.keys(statePublicEnv).length > 0
@@ -402,6 +455,12 @@ export function mountApiRendersRoutes(opts) {
402
455
  // (Wave 7 of flatten-render-identity, 2026-05-28). The store
403
456
  // returns events in protocol-canonical shape (seq + type +
404
457
  // timestamp[ISO] + data); no projection needed.
458
+ // The polling rung's mount mark (see the header): once per server
459
+ // process, session and kind, and only here, past every gate.
460
+ const kind = sinceSequence === 0 && limit === 1 ? "cold-mount" : "poll";
461
+ if (polledFirst.first(`${kind}:${sessionId}`)) {
462
+ logger.info("render_channel_polled_first", { sessionId, appId: stored.appId, kind });
463
+ }
405
464
  res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
406
465
  res.setHeader("Content-Type", "application/json; charset=utf-8");
407
466
  res.status(200).json({
@@ -31,7 +31,8 @@
31
31
  * Cursor spaces: `Last-Event-ID` (browser-stamped on auto-reconnect)
32
32
  * WINS over `?sinceSequence=`; the query seeds the first connect
33
33
  * (EventSource cannot set headers). `?fromSeq=` mirrors
34
- * `SubscribePayload.fromSeq` for the stream-buffer plane — note it is
34
+ * `SubscribePayload.fromSeq` for the stream-buffer plane (and
35
+ * `?fromEpoch=` its `fromEpoch`, ggui#1531) — note it is
35
36
  * frozen in the EventSource URL, so duplicate `data` frames after an
36
37
  * auto-reconnect are expected; ledger + stream deliveries are
37
38
  * documented at-least-once and clients dedupe by seq.
@@ -1 +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;AAG1C;;;;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,CA4MxF"}
1
+ {"version":3,"file":"api-renders-stream-route.d.ts","sourceRoot":"","sources":["../src/api-renders-stream-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;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;AAG1C;;;;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,CAuNxF"}
@@ -31,7 +31,8 @@
31
31
  * Cursor spaces: `Last-Event-ID` (browser-stamped on auto-reconnect)
32
32
  * WINS over `?sinceSequence=`; the query seeds the first connect
33
33
  * (EventSource cannot set headers). `?fromSeq=` mirrors
34
- * `SubscribePayload.fromSeq` for the stream-buffer plane — note it is
34
+ * `SubscribePayload.fromSeq` for the stream-buffer plane (and
35
+ * `?fromEpoch=` its `fromEpoch`, ggui#1531) — note it is
35
36
  * frozen in the EventSource URL, so duplicate `data` frames after an
36
37
  * auto-reconnect are expected; ledger + stream deliveries are
37
38
  * documented at-least-once and clients dedupe by seq.
@@ -172,6 +173,11 @@ export function mountApiRendersStreamRoute(opts) {
172
173
  }
173
174
  fromSeq = parsed;
174
175
  }
176
+ // The epoch `fromSeq` was counted in (ggui#1531). Opaque and
177
+ // server-minted: any non-empty value is compared as it is, and one
178
+ // that does not match the session's replays everything retained.
179
+ const fromEpochRaw = req.query["fromEpoch"];
180
+ const fromEpoch = typeof fromEpochRaw === "string" && fromEpochRaw.length > 0 ? fromEpochRaw : undefined;
175
181
  // Last-Event-ID (browser-stamped on auto-reconnect) WINS over the
176
182
  // query seed. Malformed header → warn + fall back to the query
177
183
  // (never a 4xx: the header is browser-controlled, and failing the
@@ -291,8 +297,11 @@ export function mountApiRendersStreamRoute(opts) {
291
297
  appId: stored.appId,
292
298
  identity,
293
299
  sink,
300
+ // The stream is wsToken-only; the identity above is synthetic.
301
+ credential: "ws_token",
294
302
  ...(sinceSequence !== undefined ? { sinceSequence } : {}),
295
303
  ...(fromSeq !== undefined ? { fromSeq } : {}),
304
+ ...(fromEpoch !== undefined ? { fromEpoch } : {}),
296
305
  });
297
306
  detachFn = detach;
298
307
  if (torn) {
@@ -305,7 +314,10 @@ export function mountApiRendersStreamRoute(opts) {
305
314
  // Lost race with render eviction (the pre-gate passed moments
306
315
  // ago) or a store failure inside the subscribe tail. Headers are
307
316
  // out, so no status rewrite — end the stream; the reconnect gets
308
- // the authoritative pre-gate verdict.
317
+ // the authoritative pre-gate verdict. A tail that failed after
318
+ // registering has already unregistered its subscriber and ended
319
+ // the sink with `internal_error` (ggui#1528); `teardown` has no
320
+ // handle to add, and a second `end` is a no-op.
309
321
  logger.warn("sse_attach_failed", { sessionId, error: String(err) });
310
322
  teardown();
311
323
  sink.end("service_restart");
@@ -13,12 +13,28 @@ import { type ZodRawShape } from 'zod';
13
13
  import { type HandlerContext, type SharedHandler } from '@ggui-ai/mcp-server-handlers';
14
14
  import type { Logger } from './logger.js';
15
15
  import { type LoadingIndicatorOption, type GguiRenderResourceTemplateOptions } from './mcp-apps-outbound.js';
16
+ import { type ViewProofGate } from './view-proof-gate.js';
16
17
  export interface ServerInfo {
17
18
  readonly name: string;
18
19
  readonly version: string;
19
20
  readonly description?: string;
20
21
  }
21
22
  export interface BuildMcpServerOptions {
23
+ /**
24
+ * The view-origin proof's measuring gate (ggui#1415), built once per
25
+ * server with the secret its ws envelopes are signed with. For a tool
26
+ * that declares a view proof, the gate verifies the call's proof before
27
+ * the handler runs, puts the verdict on the handler's context and names
28
+ * it on the `tool_invoked` line. It never refuses or fails a call.
29
+ * Absent: a declared call's line says `viewProofUnverifiable: true`.
30
+ */
31
+ readonly viewProofGate?: ViewProofGate;
32
+ /**
33
+ * Whether this mount's read door (`resources/read` of a render
34
+ * locator) may issue a view key (ggui#1415). Set only for the data
35
+ * plane, the mount that delivers a view to an app-credentialed caller.
36
+ */
37
+ readonly issueViewKeys?: boolean;
22
38
  /**
23
39
  * When set, register the MCP Apps outbound wiring on every fresh
24
40
  * server instance — advertises the `io.modelcontextprotocol/ui`
@@ -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;AAE1C,OAAO,EACL,KAAK,sBAAsB,EAE3B,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,8DAA8D;IAC9D,QAAQ,CAAC,gBAAgB,CAAC,EAAE,sBAAsB,CAAC;IACnD;;;;;;;;;;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;IAErE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;CACvC;AAwOD,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,CAmRX"}
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,EACL,KAAK,sBAAsB,EAE3B,KAAK,iCAAiC,EACvC,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE5E,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;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,gBAAgB,CAAC,EAAE,sBAAsB,CAAC;IACnD;;;;;;;;;;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;IAErE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;CACvC;AAqPD,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,CAkSX"}
package/dist/build-mcp.js CHANGED
@@ -15,6 +15,7 @@ import { z } from 'zod';
15
15
  import { isHandlerFailure, } from '@ggui-ai/mcp-server-handlers';
16
16
  import { GGUI_RENDER_RESOURCE_URI } from '@ggui-ai/protocol/integrations/mcp-apps';
17
17
  import { installMcpAppsOutbound, } from './mcp-apps-outbound.js';
18
+ import { runViewProofGate } from './view-proof-gate.js';
18
19
  /**
19
20
  * Build a fresh MCP server with every handler registered.
20
21
  *
@@ -147,6 +148,12 @@ function classifyFailurePayload(payload) {
147
148
  * request's signal is aborted by the time the line is written — a superset
148
149
  * of "cancelled mid-poll", read at log time, never a false negative. A
149
150
  * committed dispatch adds `consumerPresent`, the doorbell's gate.
151
+ *
152
+ * `ggui_runtime_sync_context` is not a session tool here: a refused sync's
153
+ * session is only claimed. Its line names a refusal (`ok: false` and the
154
+ * `code`) and nothing else from this function. A tool that declares a view
155
+ * proof (ggui#1415) also carries the measuring gate's fields and
156
+ * `authSource` on every one of its lines (`view-proof-gate.ts`).
150
157
  */
151
158
  const SESSION_TOOLS = new Set([
152
159
  'ggui_runtime_pull',
@@ -159,6 +166,14 @@ const SESSION_TOOLS = new Set([
159
166
  /** ggui#1474 — session tools whose logged session is the OUTPUT's (the gated or committed one), not the input's. */
160
167
  const OUTPUT_SESSION_TOOLS = new Set(['ggui_amend', 'ggui_update', 'ggui_render']);
161
168
  function sessionFields(tool, input, output, ctx) {
169
+ // ggui#1415: a sync names its refusal (`ok: false` and the code) so a
170
+ // refused call is countable. Its session is not logged as owned: a
171
+ // refused call's session is only claimed (the gate's `claimedSessionId`).
172
+ if (tool === 'ggui_runtime_sync_context') {
173
+ if (output['ok'] === true)
174
+ return {};
175
+ return typeof output['code'] === 'string' ? { ok: false, code: output['code'] } : { ok: false };
176
+ }
162
177
  if (!SESSION_TOOLS.has(tool))
163
178
  return {};
164
179
  const sessionId = OUTPUT_SESSION_TOOLS.has(tool) ? output['sessionId'] : input['sessionId'];
@@ -217,12 +232,14 @@ function claimedSessionField(tool, input) {
217
232
  };
218
233
  }
219
234
  export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
235
+ // `instructions` is a ServerOptions field (the SECOND argument): the
236
+ // SDK sends it in the `initialize` result. In the first argument (the
237
+ // Implementation) it was dropped, and no host ever received it (ggui#1579).
220
238
  const server = new McpServer({
221
239
  name: info.name,
222
240
  version: info.version,
223
241
  ...(info.description ? { description: info.description } : {}),
224
- ...(opts.instructions ? { instructions: opts.instructions } : {}),
225
- });
242
+ }, opts.instructions ? { instructions: opts.instructions } : undefined);
226
243
  // Content-addressed shell URI (stale-shell bust) — when MCP Apps
227
244
  // outbound wiring registers the shell, declarations advertising the
228
245
  // BARE `ui://ggui/render` are rewritten to the versioned twin below
@@ -240,7 +257,14 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
240
257
  // per-session resource handler's render-read gate sees the
241
258
  // caller (`renderReadAllowed`, @ggui-ai/mcp-server-handlers/renders).
242
259
  ...(opts.selfContained !== undefined
243
- ? { selfContained: { ...opts.selfContained, getContext, logger } }
260
+ ? {
261
+ selfContained: {
262
+ ...opts.selfContained,
263
+ getContext,
264
+ logger,
265
+ ...(opts.issueViewKeys === true ? { issueViewKeys: true } : {}),
266
+ },
267
+ }
244
268
  : {}),
245
269
  ...(opts.publicBaseUrl !== undefined
246
270
  ? { publicBaseUrl: opts.publicBaseUrl }
@@ -291,12 +315,24 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
291
315
  // an object, so narrow with the validating predicate and DROP
292
316
  // anything else rather than asserting.
293
317
  const requestMeta = extra?._meta;
294
- const ctx = {
318
+ const requestCtx = {
295
319
  ...baseCtx,
296
320
  ...(isRecord(requestMeta) ? { requestMeta } : {}),
297
321
  ...(extra?.signal !== undefined ? { signal: extra.signal } : {}),
298
322
  };
299
323
  const start = Date.now();
324
+ // ggui#1415: the measuring gate. For a tool that declares a view
325
+ // proof it verifies the proof, puts the verdict (and the request's
326
+ // row-read memo) on the context and returns the line's proof fields.
327
+ // Total: it never throws and never changes the handler's answer.
328
+ const gated = await runViewProofGate(opts.viewProofGate, handler, input, requestCtx);
329
+ const ctx = gated.ctx;
330
+ const proofFields = {
331
+ ...gated.fields,
332
+ ...(ctx.authSource !== undefined && (handler.viewProof !== undefined || handler.name === 'ggui_runtime_declare_tool_catalog')
333
+ ? { authSource: ctx.authSource }
334
+ : {}),
335
+ };
300
336
  try {
301
337
  const data = await handler.handler(input, ctx);
302
338
  // First-class in-result failure channel. A handler that
@@ -319,6 +355,7 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
319
355
  // malformed-row refusal, ggui#839), so its session is owned and
320
356
  // the per-session fields apply (ggui#1395).
321
357
  ...sessionFields(handler.name, input, validated, ctx),
358
+ ...proofFields,
322
359
  elapsedMs: Date.now() - start,
323
360
  });
324
361
  return {
@@ -341,6 +378,7 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
341
378
  appId: ctx.appId,
342
379
  outcome: 'success',
343
380
  ...sessionFields(handler.name, input, validated, ctx),
381
+ ...proofFields,
344
382
  elapsedMs: Date.now() - start,
345
383
  });
346
384
  // When the handler's output carries a `nextStep`, lead the
@@ -369,25 +407,14 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
369
407
  const gestureHint = nextStepHint !== undefined && nextStepHint.includes('ggui_consume')
370
408
  ? nextStepHint
371
409
  : undefined;
372
- // The consume → amend hint (ggui#1399 step 2) gets the same
373
- // plain-text lead, in its own words: the agent that drained a
374
- // gesture repaints THIS card rather than rendering a new one
375
- // (the replacement-instead-of-amend pattern ggui#1376 read on
376
- // prod). Gentle and bounded like the poll wording above.
377
- const amendHint = nextStepHint !== undefined && nextStepHint.includes('ggui_amend(')
378
- ? nextStepHint
379
- : undefined;
410
+ // The consume → amend hint (ggui#1399 step 2) rides `nextStep` in
411
+ // the structured result only, with NO plain-text lead (ggui#1397):
412
+ // a lead ("repaint … then re-call ggui_consume") was measured as the
413
+ // carrier of informational taps left unanswered on gpt-6-luna, and
414
+ // removing it held every repaint. Probes 3 and 4 are on #1397.
380
415
  return {
381
416
  structuredContent: validated,
382
417
  content: [
383
- ...(amendHint !== undefined
384
- ? [
385
- {
386
- type: 'text',
387
- text: `You drained a gesture. If it changed what the user is looking at, repaint the SAME card in place with ${amendHint} — do not render a new one — then re-call ggui_consume for the next gesture.`,
388
- },
389
- ]
390
- : []),
391
418
  ...(gestureHint !== undefined
392
419
  ? [
393
420
  {
@@ -417,6 +444,7 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
417
444
  outcome: 'error',
418
445
  errorClass: errorClassName(err),
419
446
  ...claimedSessionField(handler.name, input),
447
+ ...proofFields,
420
448
  elapsedMs: Date.now() - start,
421
449
  });
422
450
  throw err;
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Embedded-ui cookie auth plane.
3
3
  *
4
- * This module owns the THIRD of the three token kinds the server
5
- * tracks for the embedded UI:
4
+ * This module owns the second of the two token kinds the server mints
5
+ * for the embedded UI (a third, the reconnect "session" token, is minted
6
+ * by no server since ggui#1488):
6
7
  *
7
- * 1. bootstrap tokens — short-TTL, single-use, MCP Apps iframes.
8
- * 2. session tokens — longer-TTL, reusable, post-bootstrap
9
- * reconnect creds.
10
- * 3. console cookies — longer-TTL, reusable, same-origin
8
+ * 1. ws (bootstrap) tokens — short-TTL, reusable within TTL, MCP Apps
9
+ * iframes.
10
+ * 2. console cookies — longer-TTL, reusable, same-origin
11
11
  * browser-only, issued by this server
12
12
  * for ITS OWN console landing/viewer
13
13
  * pages. **Scoped narrowly**: consumed
@@ -17,10 +17,10 @@
17
17
  * `/threads`, or any other ingress.
18
18
  *
19
19
  * Isolation invariant (load-bearing). The cookie uses the SAME HMAC
20
- * shape as the bootstrap/session tokens but a distinct `kind` claim
20
+ * shape as the ws (bootstrap) token but a distinct `kind` claim
21
21
  * (`'console-session'`). That makes cross-kind confusion
22
- * impossible: a cookie value CAN'T verify as a bootstrap or session
23
- * token. Secrets are shared across kinds because they live in the
22
+ * impossible: a cookie value CAN'T verify as a ws token, nor any
23
+ * other kind as a cookie. Secrets are shared across kinds because they live in the
24
24
  * same trust domain (server-minted same-origin creds) and kind
25
25
  * discrimination is sufficient.
26
26
  *
@@ -63,7 +63,7 @@ export interface DevtoolCookieMint {
63
63
  export interface MintDevtoolCookieInput {
64
64
  readonly sessionId: string;
65
65
  readonly appId: string;
66
- /** HMAC secret. Shared with bootstrap + session tokens by design. */
66
+ /** HMAC secret. Shared with the ws (bootstrap) tokens by design. */
67
67
  readonly secret: string;
68
68
  /** Cookie TTL in seconds. Defaults to 8 hours. */
69
69
  readonly ttlSec?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"console-auth.d.ts","sourceRoot":"","sources":["../src/console-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAOrD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CAC/C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,sBAAsB,GAC5B,iBAAiB,CAqBnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,MAAM,GAAG,IAAI,CAcf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,mBAAmB,GAC3B,MAAM,GAAG,IAAI,CAIf;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,mBAAmB,GAAG,IAAI,CAO5B"}
1
+ {"version":3,"file":"console-auth.d.ts","sourceRoot":"","sources":["../src/console-auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAOrD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;CAC/C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,sBAAsB,GAC5B,iBAAiB,CAqBnB;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,MAAM,GAAG,IAAI,CAcf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,mBAAmB,GAC3B,MAAM,GAAG,IAAI,CAIf;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,mBAAmB,GAAG,IAAI,CAO5B"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A bounded "seen before?" set, for a log line that should fire once per key
3
+ * per server process without growing with the number of keys.
4
+ *
5
+ * Insertion-ordered: at capacity, the oldest key is forgotten, so a key
6
+ * offered again after `capacity` newer keys counts as new and its line fires
7
+ * a second time. A reader of such a line takes the earliest per key.
8
+ */
9
+ export interface FirstSeen {
10
+ /** True the first time `key` is offered (or once it has been forgotten); false after. */
11
+ first(key: string): boolean;
12
+ }
13
+ export declare function createFirstSeen(capacity: number): FirstSeen;
14
+ //# sourceMappingURL=first-seen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"first-seen.d.ts","sourceRoot":"","sources":["../src/first-seen.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,yFAAyF;IACzF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CAC7B;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAgB3D"}