@ggui-ai/mcp-server 0.24.0 → 0.26.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 (70) 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 +82 -12
  11. package/dist/console-auth.d.ts +10 -10
  12. package/dist/console-auth.d.ts.map +1 -1
  13. package/dist/control-service.d.ts.map +1 -1
  14. package/dist/control-service.js +0 -3
  15. package/dist/first-seen.d.ts +14 -0
  16. package/dist/first-seen.d.ts.map +1 -0
  17. package/dist/first-seen.js +19 -0
  18. package/dist/ggui-session-channel/action-ingress.d.ts.map +1 -1
  19. package/dist/ggui-session-channel/action-ingress.js +96 -8
  20. package/dist/ggui-session-channel/channel-subscriptions.d.ts +0 -1
  21. package/dist/ggui-session-channel/channel-subscriptions.d.ts.map +1 -1
  22. package/dist/ggui-session-channel/channel-subscriptions.js +4 -1
  23. package/dist/ggui-session-channel/internal-types.d.ts +20 -12
  24. package/dist/ggui-session-channel/internal-types.d.ts.map +1 -1
  25. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  26. package/dist/ggui-session-channel/outbound.js +18 -6
  27. package/dist/ggui-session-channel/stream-epoch.d.ts +61 -0
  28. package/dist/ggui-session-channel/stream-epoch.d.ts.map +1 -0
  29. package/dist/ggui-session-channel/stream-epoch.js +47 -0
  30. package/dist/ggui-session-channel/subscribe.d.ts +40 -53
  31. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  32. package/dist/ggui-session-channel/subscribe.js +288 -124
  33. package/dist/ggui-session-channel/subscriber-lifecycle.d.ts.map +1 -1
  34. package/dist/ggui-session-channel/subscriber-lifecycle.js +17 -7
  35. package/dist/ggui-session-channel.d.ts +43 -7
  36. package/dist/ggui-session-channel.d.ts.map +1 -1
  37. package/dist/ggui-session-channel.js +7 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/instructions-presets.d.ts +22 -21
  42. package/dist/instructions-presets.d.ts.map +1 -1
  43. package/dist/instructions-presets.js +38 -38
  44. package/dist/llm-backed-negotiator.d.ts +4 -37
  45. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  46. package/dist/llm-backed-negotiator.js +28 -210
  47. package/dist/mcp-apps-outbound.d.ts +40 -91
  48. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  49. package/dist/mcp-apps-outbound.js +100 -15
  50. package/dist/mcp-endpoint-routes.d.ts +1 -0
  51. package/dist/mcp-endpoint-routes.d.ts.map +1 -1
  52. package/dist/mcp-endpoint-routes.js +38 -4
  53. package/dist/runtime-bundle-hash.d.ts +7 -0
  54. package/dist/runtime-bundle-hash.d.ts.map +1 -1
  55. package/dist/runtime-bundle-hash.js +8 -2
  56. package/dist/runtime-bundle-route.d.ts.map +1 -1
  57. package/dist/runtime-bundle-route.js +15 -0
  58. package/dist/server.d.ts +143 -85
  59. package/dist/server.d.ts.map +1 -1
  60. package/dist/server.js +224 -110
  61. package/dist/storage.d.ts +9 -1
  62. package/dist/storage.d.ts.map +1 -1
  63. package/dist/storage.js +4 -3
  64. package/dist/view-proof-gate.d.ts +96 -0
  65. package/dist/view-proof-gate.d.ts.map +1 -0
  66. package/dist/view-proof-gate.js +167 -0
  67. package/package.json +14 -15
  68. package/dist/render-read-gate.d.ts +0 -50
  69. package/dist/render-read-gate.d.ts.map +0 -1
  70. package/dist/render-read-gate.js +0 -36
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;AA4MD,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,CAkQX"}
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
  *
@@ -100,8 +101,10 @@ function classifyFailurePayload(payload) {
100
101
  };
101
102
  }
102
103
  /**
103
- * Per-session fields on the `tool_invoked` line of the three session-keyed
104
- * runtime tools (ggui#1377 for pull; ggui#1395 for consume and submit).
104
+ * Per-session fields on the `tool_invoked` line of the session-keyed tools:
105
+ * the three runtime tools (ggui#1377 for pull; ggui#1395 for consume and
106
+ * submit) and the mutation and render tools (ggui#1474 — amend, update,
107
+ * render), so an instrument joins a reaction to its tap by session.
105
108
  *
106
109
  * In a composition that wires no logger into these handlers, this line is
107
110
  * their whole trace. ggui#1376 had to be read per app and minute; these
@@ -111,7 +114,7 @@ function classifyFailurePayload(payload) {
111
114
  * never a spread.
112
115
  *
113
116
  * What a `sessionId` on a SUCCESS line proves, per tool — never raw caller
114
- * input in any of the three:
117
+ * input in any of them:
115
118
  * - pull and consume: a session the caller's app owns. Both throw on an
116
119
  * unknown or cross-app session before they return (the visibility gate),
117
120
  * so every success line of theirs qualifies.
@@ -122,23 +125,59 @@ function classifyFailurePayload(payload) {
122
125
  * so the claim stops there. An audit kind (`openLink`,
123
126
  * `requestDisplayMode`, an extension kind) touches no pipe and carries no
124
127
  * session at all: its `sessionId` was never read by anything.
128
+ * - amend and update: a session the caller's app owns, read from the
129
+ * OUTPUT. Both run the shared mutation core, which app-scope-gates the
130
+ * session (`renderStore.get` + an `appId` match) and throws
131
+ * `GguiSessionNotFoundError` on a missing or cross-app one before any
132
+ * return — so the output's `sessionId` is the gated one, including when
133
+ * an in-process caller threads it through the context rather than the
134
+ * input.
135
+ * - render: the session id this call MINTED for the caller's app, read
136
+ * from the OUTPUT (never caller input — render's input names no
137
+ * session). A `rendered` result carries it, and so does a `failed` one
138
+ * (on the in-result failure line); a `refused` result mints none. It
139
+ * proves the id is this call's, NOT that a row backs it: the handler
140
+ * swallows a render-store commit rejection (the placeholder, probe,
141
+ * cache-hit and error-record commits, and the cold-generation success
142
+ * commit, which then answers `failed`), so on those paths the id names
143
+ * no row, or only a provisional placeholder — the claim stops there,
144
+ * and a consumer that needs the row reads it from the store.
125
145
  *
126
146
  * Consume adds `eventCount`, `status`, `timeoutS` (the requested timeout; 0
127
147
  * when omitted, the handler's own default) and `aborted: true` when the
128
148
  * request's signal is aborted by the time the line is written — a superset
129
149
  * of "cancelled mid-poll", read at log time, never a false negative. A
130
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`).
131
157
  */
132
158
  const SESSION_TOOLS = new Set([
133
159
  'ggui_runtime_pull',
134
160
  'ggui_consume',
135
161
  'ggui_runtime_submit_action',
162
+ 'ggui_amend',
163
+ 'ggui_update',
164
+ 'ggui_render',
136
165
  ]);
166
+ /** ggui#1474 — session tools whose logged session is the OUTPUT's (the gated or committed one), not the input's. */
167
+ const OUTPUT_SESSION_TOOLS = new Set(['ggui_amend', 'ggui_update', 'ggui_render']);
137
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
+ }
138
177
  if (!SESSION_TOOLS.has(tool))
139
178
  return {};
140
- const sessionId = input['sessionId'];
141
- if (typeof sessionId !== 'string')
179
+ const sessionId = OUTPUT_SESSION_TOOLS.has(tool) ? output['sessionId'] : input['sessionId'];
180
+ if (typeof sessionId !== 'string' || sessionId === '')
142
181
  return {};
143
182
  if (tool === 'ggui_runtime_submit_action') {
144
183
  if (input['kind'] !== 'dispatch')
@@ -168,8 +207,10 @@ function sessionFields(tool, input, output, ctx) {
168
207
  /** Bound on `claimedSessionId`: a real id is a UUID; anything longer is not one. */
169
208
  const CLAIMED_SESSION_ID_MAX_CHARS = 128;
170
209
  /**
171
- * The caller's CLAIMED session on an `outcome: 'error'` line of the same
172
- * three tools (ggui#1395). A refused consume — unknown or cross-app
210
+ * The caller's CLAIMED session on an `outcome: 'error'` line of the session
211
+ * tools (ggui#1395; amend and update since ggui#1474). Render's input schema
212
+ * declares no `sessionId` and the MCP SDK strips undeclared keys before the
213
+ * handler runs, so a render line never carries a claim. A refused consume — unknown or cross-app
173
214
  * session, the visibility gate — is the one failure of the tap → consume
174
215
  * chain that is otherwise invisible per session. It is raw caller input,
175
216
  * the first on this line: named as a claim so it never reads as an owned
@@ -191,12 +232,14 @@ function claimedSessionField(tool, input) {
191
232
  };
192
233
  }
193
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).
194
238
  const server = new McpServer({
195
239
  name: info.name,
196
240
  version: info.version,
197
241
  ...(info.description ? { description: info.description } : {}),
198
- ...(opts.instructions ? { instructions: opts.instructions } : {}),
199
- });
242
+ }, opts.instructions ? { instructions: opts.instructions } : undefined);
200
243
  // Content-addressed shell URI (stale-shell bust) — when MCP Apps
201
244
  // outbound wiring registers the shell, declarations advertising the
202
245
  // BARE `ui://ggui/render` are rewritten to the versioned twin below
@@ -212,9 +255,16 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
212
255
  // Thread the same per-request context accessor + logger the tool
213
256
  // path uses (`getContext`, param 3 of `buildMcpServer`) so the
214
257
  // per-session resource handler's render-read gate sees the
215
- // caller (render-read-gate.ts).
258
+ // caller (`renderReadAllowed`, @ggui-ai/mcp-server-handlers/renders).
216
259
  ...(opts.selfContained !== undefined
217
- ? { selfContained: { ...opts.selfContained, getContext, logger } }
260
+ ? {
261
+ selfContained: {
262
+ ...opts.selfContained,
263
+ getContext,
264
+ logger,
265
+ ...(opts.issueViewKeys === true ? { issueViewKeys: true } : {}),
266
+ },
267
+ }
218
268
  : {}),
219
269
  ...(opts.publicBaseUrl !== undefined
220
270
  ? { publicBaseUrl: opts.publicBaseUrl }
@@ -265,12 +315,24 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
265
315
  // an object, so narrow with the validating predicate and DROP
266
316
  // anything else rather than asserting.
267
317
  const requestMeta = extra?._meta;
268
- const ctx = {
318
+ const requestCtx = {
269
319
  ...baseCtx,
270
320
  ...(isRecord(requestMeta) ? { requestMeta } : {}),
271
321
  ...(extra?.signal !== undefined ? { signal: extra.signal } : {}),
272
322
  };
273
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
+ };
274
336
  try {
275
337
  const data = await handler.handler(input, ctx);
276
338
  // First-class in-result failure channel. A handler that
@@ -293,6 +355,7 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
293
355
  // malformed-row refusal, ggui#839), so its session is owned and
294
356
  // the per-session fields apply (ggui#1395).
295
357
  ...sessionFields(handler.name, input, validated, ctx),
358
+ ...proofFields,
296
359
  elapsedMs: Date.now() - start,
297
360
  });
298
361
  return {
@@ -315,6 +378,7 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
315
378
  appId: ctx.appId,
316
379
  outcome: 'success',
317
380
  ...sessionFields(handler.name, input, validated, ctx),
381
+ ...proofFields,
318
382
  elapsedMs: Date.now() - start,
319
383
  });
320
384
  // When the handler's output carries a `nextStep`, lead the
@@ -343,6 +407,11 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
343
407
  const gestureHint = nextStepHint !== undefined && nextStepHint.includes('ggui_consume')
344
408
  ? nextStepHint
345
409
  : 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.
346
415
  return {
347
416
  structuredContent: validated,
348
417
  content: [
@@ -375,6 +444,7 @@ export function buildMcpServer(info, handlers, getContext, logger, opts = {}) {
375
444
  outcome: 'error',
376
445
  errorClass: errorClassName(err),
377
446
  ...claimedSessionField(handler.name, input),
447
+ ...proofFields,
378
448
  elapsedMs: Date.now() - start,
379
449
  });
380
450
  throw err;