@ggui-ai/mcp-server 0.6.3 → 0.8.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 (42) hide show
  1. package/dist/api-renders-routes.d.ts.map +1 -1
  2. package/dist/api-renders-routes.js +9 -1
  3. package/dist/browser-cors.d.ts +29 -0
  4. package/dist/browser-cors.d.ts.map +1 -0
  5. package/dist/browser-cors.js +64 -0
  6. package/dist/build-mcp.d.ts.map +1 -1
  7. package/dist/build-mcp.js +5 -1
  8. package/dist/code-store-fs.d.ts +3 -0
  9. package/dist/code-store-fs.d.ts.map +1 -1
  10. package/dist/code-store-fs.js +27 -3
  11. package/dist/console-session-routes.d.ts +10 -5
  12. package/dist/console-session-routes.d.ts.map +1 -1
  13. package/dist/console-session-routes.js +10 -5
  14. package/dist/ggui-session-channel/outbound.d.ts.map +1 -1
  15. package/dist/ggui-session-channel/outbound.js +12 -0
  16. package/dist/ggui-session-channel/socket-router.d.ts +33 -0
  17. package/dist/ggui-session-channel/socket-router.d.ts.map +1 -1
  18. package/dist/ggui-session-channel/socket-router.js +155 -0
  19. package/dist/ggui-session-channel/subscribe.d.ts.map +1 -1
  20. package/dist/ggui-session-channel/subscribe.js +43 -2
  21. package/dist/ggui-session-channel.d.ts +114 -0
  22. package/dist/ggui-session-channel.d.ts.map +1 -1
  23. package/dist/ggui-session-channel.js +77 -2
  24. package/dist/health-routes.d.ts +3 -0
  25. package/dist/health-routes.d.ts.map +1 -1
  26. package/dist/health-routes.js +11 -1
  27. package/dist/mcp-apps-outbound.d.ts +236 -31
  28. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  29. package/dist/mcp-apps-outbound.js +940 -261
  30. package/dist/origin-validation.d.ts +120 -0
  31. package/dist/origin-validation.d.ts.map +1 -0
  32. package/dist/origin-validation.js +199 -0
  33. package/dist/render-read-gate.d.ts +50 -0
  34. package/dist/render-read-gate.d.ts.map +1 -0
  35. package/dist/render-read-gate.js +36 -0
  36. package/dist/runtime-bundle-route.d.ts +15 -0
  37. package/dist/runtime-bundle-route.d.ts.map +1 -1
  38. package/dist/runtime-bundle-route.js +20 -4
  39. package/dist/server.d.ts +227 -9
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +292 -28
  42. package/package.json +12 -12
package/dist/server.js CHANGED
@@ -51,7 +51,8 @@ import { setLlmTraceSink } from "@ggui-ai/ui-gen/harness/llm-trace-sink";
51
51
  import { setValidatorTraceSink } from "@ggui-ai/ui-gen/harness/validator-trace-sink";
52
52
  import express from "express";
53
53
  import { AsyncLocalStorage } from "node:async_hooks";
54
- import { randomBytes } from "node:crypto";
54
+ import { createHash, randomBytes } from "node:crypto";
55
+ import fs from "node:fs";
55
56
  import path from "node:path";
56
57
  import { BoundedCacheTraceSink, mountConsoleCacheRoutes } from "./console-cache.js";
57
58
  import { applyDevtoolSecurityHeaders } from "./console-headers.js";
@@ -59,6 +60,7 @@ import { BoundedLlmTraceSink, mountConsoleLlmTraceRoutes } from "./console-llm-t
59
60
  import { BoundedPayloadTraceSink, mountConsolePayloadsRoutes } from "./console-payloads.js";
60
61
  import { mountDevtoolThemeRoutes, } from "./console-theme-routes.js";
61
62
  import { mountConsoleTimelineRoutes } from "./console-timeline.js";
63
+ import { buildInlineRenderShellHtml } from "./mcp-apps-outbound.js";
62
64
  import { BoundedValidatorTraceSink, mountConsoleValidatorRoutes } from "./console-validator.js";
63
65
  // Operator-class MCP handlers — twelve `ggui_ops_*` handlers across
64
66
  // four domains (apps / orgs / connector-keys / coupon). Every factory
@@ -96,7 +98,9 @@ import { mountEmailLoginRoutes } from "./email-login.js";
96
98
  import { resolveMcpInstructions } from "./instructions-presets.js";
97
99
  import { buildLlmCaller, createLlmBackedHandshakeNegotiator } from "./llm-backed-negotiator.js";
98
100
  import { createConsoleLogger } from "./logger.js";
99
- import { buildControlService } from "./control-service.js";
101
+ import { buildControlService, CONTROL_PATH } from "./control-service.js";
102
+ import { createBrowserCorsMiddleware } from "./browser-cors.js";
103
+ import { buildOriginHostPolicy, createOriginHostValidationMiddleware, validateOriginHost, } from "./origin-validation.js";
100
104
  import { composeHandlersWithMounts, validateMcpServices, validateServiceHandlers, } from "./mcp-mounts.js";
101
105
  import { mountOAuthLoginRoutes } from "./oauth-login.js";
102
106
  import { createOAuthProvidersStore } from "./oauth-providers-store.js";
@@ -202,6 +206,7 @@ export function defaultHandlers(deps) {
202
206
  embedding: deps.embedding,
203
207
  vectors: deps.vectors,
204
208
  ...(deps.blueprints ? { blueprints: deps.blueprints } : {}),
209
+ ...(deps.registrySearch ? { registry: deps.registrySearch } : {}),
205
210
  }),
206
211
  createListFeaturedBlueprintsHandler(deps.blueprints ? { blueprints: deps.blueprints } : {}),
207
212
  // Spec / discovery handlers — zero-deps. These may be tagged
@@ -272,6 +277,12 @@ export function defaultHandlers(deps) {
272
277
  if (deps.render) {
273
278
  handlers.push(createGguiSyncContextHandler({
274
279
  renderStore: deps.render.renderStore,
280
+ // Same store `ggui_render` writes identity records to — a
281
+ // snapshot sync re-commits the row, so the record's view of it
282
+ // is refreshed off the same seam.
283
+ ...(deps.render.renderIdentityStore
284
+ ? { renderIdentityStore: deps.render.renderIdentityStore }
285
+ : {}),
275
286
  }));
276
287
  // `ggui_runtime_refresh_ws_token` — G14 (2026-05-23) signed-
277
288
  // envelope refresh tool. Registered only when a refresh seam is
@@ -345,6 +356,9 @@ export function defaultHandlers(deps) {
345
356
  if (deps.update) {
346
357
  handlers.push(createGguiUpdateHandler({
347
358
  renderStore: deps.update.renderStore,
359
+ ...(deps.update.renderIdentityStore
360
+ ? { renderIdentityStore: deps.update.renderIdentityStore }
361
+ : {}),
348
362
  ...(deps.update.propsUpdateNotifier
349
363
  ? { propsUpdateNotifier: deps.update.propsUpdateNotifier }
350
364
  : {}),
@@ -486,6 +500,9 @@ export function defaultHandlers(deps) {
486
500
  if (deps.render) {
487
501
  handlers.push(createGguiRenderHandler({
488
502
  renderStore: deps.render.renderStore,
503
+ ...(deps.render.renderTtlMs !== undefined
504
+ ? { renderTtlMs: deps.render.renderTtlMs }
505
+ : {}),
489
506
  // Plugin slice Commit 3 — render reads App.gadgets to
490
507
  // gate `clientCapabilities.gadgets[*].hook` references via
491
508
  // `assertGadgetsRegistered`. Same instance the
@@ -505,6 +522,9 @@ export function defaultHandlers(deps) {
505
522
  : {}),
506
523
  ...(deps.render.rateLimiter ? { rateLimiter: deps.render.rateLimiter } : {}),
507
524
  ...(deps.render.shortCodeIndex ? { shortCodeIndex: deps.render.shortCodeIndex } : {}),
525
+ ...(deps.render.renderIdentityStore
526
+ ? { renderIdentityStore: deps.render.renderIdentityStore }
527
+ : {}),
508
528
  ...(deps.render.provisionalPreview
509
529
  ? { provisionalPreview: deps.render.provisionalPreview }
510
530
  : {}),
@@ -524,9 +544,11 @@ export function defaultHandlers(deps) {
524
544
  ? { checkRenderContracts: deps.render.checkRenderContracts }
525
545
  : {}),
526
546
  // Content-addressable code store. Both fields are forwarded
527
- // together; the render handler
528
- // requires both to emit `codeUrl`. Absent or partial =
529
- // inline-base64 fallback (see render.ts handler body).
547
+ // together; the render handler requires both to emit
548
+ // `codeUrl`. Absent or partial = no cache-addressable static
549
+ // channel; the bootstrap still carries the inline `codeB64`
550
+ // twin for under-cap renders and mounts through the live trio
551
+ // where minted (see render.ts handler body).
530
552
  ...(deps.render.codeStore && deps.render.codeBaseUrl
531
553
  ? {
532
554
  codeStore: deps.render.codeStore,
@@ -613,7 +635,7 @@ export function buildOpsBundleHandlers(deps) {
613
635
  const handlers = [];
614
636
  if (deps.opsApps) {
615
637
  const { apps, userDefaultApp } = deps.opsApps;
616
- handlers.push(createListAppsHandler({ apps }), createCreateAppHandler({ apps }), createUpdateAppHandler({ apps }), createSetAppThemeHandler({ apps }), createDeleteAppHandler({ apps }), createSetDefaultAppHandler({ apps, userDefaultApp }));
638
+ handlers.push(createListAppsHandler({ apps }), createCreateAppHandler({ apps }), createUpdateAppHandler({ apps }), createSetAppThemeHandler({ apps }), createDeleteAppHandler({ apps, userDefaultApp }), createSetDefaultAppHandler({ apps, userDefaultApp }));
617
639
  }
618
640
  if (deps.opsOrgs) {
619
641
  const { orgs, invites } = deps.opsOrgs;
@@ -752,7 +774,47 @@ export function createGguiServer(opts = {}) {
752
774
  const runtimeBundleFile = runtimeConfig.distDir !== undefined
753
775
  ? path.join(runtimeConfig.distDir, "iframe-runtime.js")
754
776
  : RUNTIME_BUNDLE_FILE;
755
- const runtimeBootstrapUrl = runtimeConfig.url ?? runtimePath;
777
+ // Bundle bytes, captured ONCE at composition. Feed three consumers:
778
+ // the content hash below, the immutable hashed route (which must
779
+ // serve exactly the bytes its name hashes — see runtime-bundle-
780
+ // route.ts), and the opt-in inline-runtime shell. Unreadable file ⇒
781
+ // undefined ⇒ every consumer falls back to its pre-#472 behavior.
782
+ let runtimeBundleBytes;
783
+ if (runtimeEnabled) {
784
+ try {
785
+ runtimeBundleBytes = fs.readFileSync(runtimeBundleFile);
786
+ }
787
+ catch {
788
+ // The plain route's own existsSync handles the missing-bundle
789
+ // warning; nothing extra to say here.
790
+ }
791
+ }
792
+ // Content-hashed runtime URL (#472). The stamped `runtimeUrl` (and
793
+ // the hashed route it points at) embeds sha256[0..12) of the bytes:
794
+ // clients cache one download per content version with zero
795
+ // revalidations, and a deploy rolls over by changing the URL. The
796
+ // rewrite applies to a configured absolute `runtime.url` ONLY when
797
+ // its filename matches the served bundle's route (a CDN fronting
798
+ // THIS server still resolves the hashed name at its origin); a URL
799
+ // pointing at a foreign copy is left untouched, and
800
+ // `runtime.hashedUrl: false` opts out entirely.
801
+ const runtimeBundleHash = runtimeBundleBytes !== undefined && runtimeConfig.hashedUrl !== false
802
+ ? createHash("sha256").update(runtimeBundleBytes).digest("hex").slice(0, 12)
803
+ : undefined;
804
+ const insertHash = (urlOrPath) => {
805
+ if (runtimeBundleHash === undefined)
806
+ return urlOrPath;
807
+ const plainName = runtimePath.slice(runtimePath.lastIndexOf("/") + 1);
808
+ const dot = plainName.lastIndexOf(".");
809
+ const hashedName = dot === -1
810
+ ? `${plainName}.${runtimeBundleHash}`
811
+ : `${plainName.slice(0, dot)}.${runtimeBundleHash}${plainName.slice(dot)}`;
812
+ if (!urlOrPath.endsWith(`/${plainName}`) && urlOrPath !== plainName)
813
+ return urlOrPath;
814
+ return `${urlOrPath.slice(0, urlOrPath.length - plainName.length)}${hashedName}`;
815
+ };
816
+ const hashedRuntimePath = runtimeBundleHash !== undefined ? insertHash(runtimePath) : undefined;
817
+ const runtimeBootstrapUrl = insertHash(runtimeConfig.url ?? runtimePath);
756
818
  // Lazy resolver: each render/update handler invocation looks up the
757
819
  // request-context-derived absolute base inside the request scope
758
820
  // (via AsyncLocalStorage). Static `publicBaseUrl` wins when set;
@@ -899,7 +961,19 @@ export function createGguiServer(opts = {}) {
899
961
  const generationWithCache = opts.generation
900
962
  ? {
901
963
  ...opts.generation,
902
- cache: opts.generation.cache ?? { embedding, vectorStore: vectors, index },
964
+ cache: opts.generation.cache ??
965
+ {
966
+ embedding,
967
+ vectorStore: vectors,
968
+ index,
969
+ // Durable write-through rides the cache bundle because
970
+ // registration is where it fires. Omitted entirely when
971
+ // unwired so the bundle stays byte-identical to before on
972
+ // deployments that bound no durable store.
973
+ ...(opts.durableBlueprints !== undefined
974
+ ? { durability: opts.durableBlueprints }
975
+ : {}),
976
+ },
903
977
  // Thread the shared/seed pools into the generation deps so the
904
978
  // render handler's §6 reuse point-read can fall back to them on a
905
979
  // per-app miss. Same `opts.seedPools` fed to the negotiator below;
@@ -1155,6 +1229,7 @@ export function createGguiServer(opts = {}) {
1155
1229
  // Registers `ggui_runtime_declare_tool_catalog`.
1156
1230
  toolIdentityCatalogStore,
1157
1231
  ...(opts.blueprintProvider ? { blueprints: opts.blueprintProvider } : {}),
1232
+ ...(opts.registrySearch ? { registrySearch: opts.registrySearch } : {}),
1158
1233
  // UI registry for `ggui_render_blueprint`. Absent = render tool
1159
1234
  // is NOT registered on this server (defaultHandlers' own opt-in
1160
1235
  // rule). OSS CLI wires a `LocalUiRegistry` here so manifest
@@ -1219,6 +1294,7 @@ export function createGguiServer(opts = {}) {
1219
1294
  ? {
1220
1295
  render: {
1221
1296
  renderStore,
1297
+ ...(opts.renderTtlMs !== undefined ? { renderTtlMs: opts.renderTtlMs } : {}),
1222
1298
  ...(mintBootstrap ? { mintBootstrap } : {}),
1223
1299
  // G14 (2026-05-23) refresh seam. Same `channelBootstrap`
1224
1300
  // the WS upgrade path uses — sharing it means one HMAC
@@ -1267,6 +1343,12 @@ export function createGguiServer(opts = {}) {
1267
1343
  // index. Absent = hosted cloud (DDB side-table) / no
1268
1344
  // console consumer.
1269
1345
  ...(opts.shortCodeIndex ? { shortCodeIndex: opts.shortCodeIndex } : {}),
1346
+ // Durable render-identity side record. Same opt-in
1347
+ // posture: absent = no records written, every read path
1348
+ // unchanged.
1349
+ ...(opts.renderIdentityStore
1350
+ ? { renderIdentityStore: opts.renderIdentityStore }
1351
+ : {}),
1270
1352
  // Provisional A2UI preview. When `opts.provisionalPreview.enabled`
1271
1353
  // flipped on above, the deps object owns
1272
1354
  // `sendEnvelope` (late-bound to the live channel) +
@@ -1339,9 +1421,11 @@ export function createGguiServer(opts = {}) {
1339
1421
  // handler along with the base
1340
1422
  // URL the code-blob route resolves to. We prefer the
1341
1423
  // explicit `--public-base-url` (so the URL is reachable
1342
- // from claude.ai's iframe sandbox); when absent we fall
1343
- // back to "no codeUrl emission" — the inline-base64
1344
- // path still mounts the iframe successfully via Path B.
1424
+ // from a remote host's iframe sandbox); when absent we
1425
+ // fall back to "no codeUrl emission" — the iframe then
1426
+ // mounts through the live trio and the WS subscribe
1427
+ // carries the render body, which is the delivery path a
1428
+ // render-channel deployment already has.
1345
1429
  ...(opts.codeStore && opts.publicBaseUrl
1346
1430
  ? {
1347
1431
  codeStore: opts.codeStore,
@@ -1413,6 +1497,11 @@ export function createGguiServer(opts = {}) {
1413
1497
  ? {
1414
1498
  update: {
1415
1499
  renderStore,
1500
+ // Same instance the render deps got — one record per
1501
+ // render, written by render and refreshed by update.
1502
+ ...(opts.renderIdentityStore
1503
+ ? { renderIdentityStore: opts.renderIdentityStore }
1504
+ : {}),
1416
1505
  propsUpdateNotifier: {
1417
1506
  sendPropsUpdate: async (sessionId, props) => {
1418
1507
  if (!channelForHealth)
@@ -1552,6 +1641,45 @@ export function createGguiServer(opts = {}) {
1552
1641
  // (render/update resultMeta.runtimeUrl) can adapt to the tunnel host.
1553
1642
  // Trust gate lives inside the middleware — see request-context.ts.
1554
1643
  app.use(buildRequestContextMiddleware());
1644
+ // DNS-rebinding defense (ggui#438a; Streamable HTTP spec §Security
1645
+ // Warning). Mounted BEFORE the body parsers so a rejected request
1646
+ // never allocates a parsed body, and before auth so the verdict does
1647
+ // not depend on credentials. Reads RAW headers — see the module
1648
+ // docstring for why the request-context-derived host is unsafe here.
1649
+ //
1650
+ // Origin enforcement is scoped to the MCP wire; the Host check is
1651
+ // global. NOTE: per-app routing WITHOUT a pathPrefix mounts at
1652
+ // `/:appId` — no distinguishing prefix exists, so those requests get
1653
+ // the Host check only (which is the actual rebinding defense);
1654
+ // enforcing Origin app-wide would 403 the public cross-origin read
1655
+ // surfaces (runtime-bundle, /code, /api/renders).
1656
+ const mcpOriginEnforcedPrefixes = [
1657
+ opts.universalMcpPath ?? "/mcp",
1658
+ CONTROL_PATH,
1659
+ ...mcpServices.map((svc) => svc.path),
1660
+ ...(opts.perAppRouting?.pathPrefix !== undefined ? [opts.perAppRouting.pathPrefix] : []),
1661
+ ];
1662
+ const originHostPolicy = buildOriginHostPolicy({
1663
+ bindHost: opts.host ?? "127.0.0.1",
1664
+ ...(opts.publicBaseUrl !== undefined ? { publicBaseUrl: opts.publicBaseUrl } : {}),
1665
+ ...(opts.browserOrigins !== undefined ? { browserOrigins: opts.browserOrigins } : {}),
1666
+ });
1667
+ app.use(createOriginHostValidationMiddleware({
1668
+ policy: originHostPolicy,
1669
+ enforceOriginPathPrefixes: mcpOriginEnforcedPrefixes,
1670
+ logger: logger.child({ middleware: "origin-validation" }),
1671
+ }));
1672
+ // Browser CORS (ggui#438b). Mounted app-wide and BEFORE auth:
1673
+ // preflights carry no Authorization by spec, so an auth-gated OPTIONS
1674
+ // would kill every authenticated browser session. One policy object
1675
+ // covers every MCP surface — universal /mcp, the per-app route,
1676
+ // /control, isolated services — and the OAuth discovery + token +
1677
+ // registration routes a browser client must fetch to authenticate.
1678
+ // Error responses (401/403/405) inherit the headers because the layer
1679
+ // runs before the handlers that produce them. Routes that set their
1680
+ // own ACAO (runtime-bundle, /code, /api/renders — all `*`) win: they
1681
+ // setHeader later, which replaces this layer's value.
1682
+ app.use(createBrowserCorsMiddleware({ policy: originHostPolicy }));
1555
1683
  app.use(express.json({ limit: bodyLimit }));
1556
1684
  // Form-urlencoded body parser for /oauth/token (RFC 6749 §4.1.3
1557
1685
  // requires application/x-www-form-urlencoded). JSON bodies still
@@ -1697,16 +1825,42 @@ export function createGguiServer(opts = {}) {
1697
1825
  oauthEnabled,
1698
1826
  ...(oauthConfig.issuerUrl !== undefined ? { oauthIssuerUrl: oauthConfig.issuerUrl } : {}),
1699
1827
  logger,
1828
+ corsOrigins: originHostPolicy.allowedOrigins,
1700
1829
  });
1830
+ // Inline-runtime shell (opt-in): embed the runtime bundle's source
1831
+ // into the static-resource shell so fetch-blocked hosts (iframe CSP
1832
+ // without external script-src) can still boot. Resolved ONCE at
1833
+ // composition time from the same bundle file the runtime mount
1834
+ // serves. An explicit `shellHtml` wins; a read failure warns and
1835
+ // falls back to the thin shell (the server still works everywhere
1836
+ // the thin shell worked).
1837
+ let inlineShellHtml;
1838
+ if (mcpAppsEnabled && mcpAppsConfig.inlineRuntimeShell === true) {
1839
+ if (runtimeBundleBytes !== undefined) {
1840
+ inlineShellHtml = buildInlineRenderShellHtml(runtimeBundleBytes.toString("utf8"));
1841
+ }
1842
+ else {
1843
+ // inlineRuntimeShell is on but the runtime bundle could not be
1844
+ // read at composition — serve the thin postMessage shell instead.
1845
+ logger.warn("mcp_apps_inline_shell_bundle_unreadable", {
1846
+ runtimeBundleFile,
1847
+ });
1848
+ }
1849
+ }
1701
1850
  // Per-boot `buildMcpServer` options — every input below is fixed at
1702
1851
  // composition time, so the bundle is assembled once and the MCP
1703
1852
  // endpoint family spreads a fresh copy per request.
1704
1853
  const buildMcpOptions = {
1705
1854
  mcpAppsOutbound: mcpAppsEnabled,
1706
- // Caller-provided `shellHtml` overrides the default;
1707
- // `installMcpAppsOutbound` falls back to its baked
1708
- // `GGUI_RENDER_SHELL_HTML` constant when absent.
1709
- ...(mcpAppsConfig.shellHtml !== undefined ? { shellHtml: mcpAppsConfig.shellHtml } : {}),
1855
+ // Caller-provided `shellHtml` overrides the default (and the
1856
+ // inline-runtime shell); `installMcpAppsOutbound` falls back to
1857
+ // its baked `GGUI_RENDER_SHELL_HTML` constant when both are
1858
+ // absent.
1859
+ ...(mcpAppsConfig.shellHtml !== undefined
1860
+ ? { shellHtml: mcpAppsConfig.shellHtml }
1861
+ : inlineShellHtml !== undefined
1862
+ ? { shellHtml: inlineShellHtml }
1863
+ : {}),
1710
1864
  // Forward the operator-supplied public origin so the static
1711
1865
  // `ui://ggui/render` resource declares `_meta.ui.csp` for
1712
1866
  // spec-compliant hosts (Claude Desktop / claude.ai Connector /
@@ -1740,14 +1894,21 @@ export function createGguiServer(opts = {}) {
1740
1894
  : {}),
1741
1895
  // Resume contract — registry-only fallback. Wired
1742
1896
  // when the blueprint vector store is available so the
1743
- // resource handler can render a render-evicted
1744
- // rehydrate from the registered blueprint instead of
1745
- // the dead loading shell. `defaultAppIdFallback`
1746
- // bounds the registry lookup to the OSS single-tenant
1747
- // identity; multi-tenant deployments leave this
1748
- // undefined to fail-safe back to the loading shell
1749
- // (no way to derive the right tenant from a missing
1750
- // render).
1897
+ // resource handler can rehydrate a render-evicted
1898
+ // locator from the registered blueprint instead of
1899
+ // failing the read. `defaultAppIdFallback` bounds the
1900
+ // registry lookup to the OSS single-tenant identity.
1901
+ //
1902
+ // Leaving it undefined does not soften the response —
1903
+ // there is no placeholder shell to fall back to any more.
1904
+ // It removes the fallback, and reads it would have served
1905
+ // fail typed instead. The reason to leave it undefined is
1906
+ // that the lookup answers "a blueprint with this key
1907
+ // exists under this scope" to whoever asks, which a
1908
+ // deployment serving several tenants from one scope may
1909
+ // not want, and which it could not scope correctly anyway
1910
+ // (a missing render carries no way to derive whose it
1911
+ // was).
1751
1912
  ...(vectors
1752
1913
  ? {
1753
1914
  vectorStore: vectors,
@@ -1760,9 +1921,15 @@ export function createGguiServer(opts = {}) {
1760
1921
  }
1761
1922
  : {}),
1762
1923
  // T3-1 (2026-05-13) — content-addressable code delivery
1763
- // for the MCP-resource shell. Without these the handler
1764
- // emits the loading shell for compiled components; with
1765
- // them, it inlines a `codeUrl` the iframe-runtime fetches.
1924
+ // for the MCP-resource shell. One of the handler's two
1925
+ // delivery channels: with these it inlines a `codeUrl` the
1926
+ // iframe-runtime fetches, and `mintWsToken` below is the
1927
+ // other. A compiled component needs at least one, and a
1928
+ // read that resolves a render this server can deliver by
1929
+ // neither fails NOT_MOUNTABLE rather than returning a
1930
+ // shell that would never paint. Wiring both (the shape
1931
+ // this factory produces) also means a fault on one
1932
+ // degrades to the other instead of failing the read.
1766
1933
  ...(opts.codeStore && opts.publicBaseUrl
1767
1934
  ? {
1768
1935
  codeStore: opts.codeStore,
@@ -1786,6 +1953,37 @@ export function createGguiServer(opts = {}) {
1786
1953
  // diagnosed live on 2026-05-18 against the cloudflared
1787
1954
  // tunnel.
1788
1955
  ...(opts.publicBaseUrl !== undefined ? { publicBaseUrl: opts.publicBaseUrl } : {}),
1956
+ // #430 slice 3 — the durable substrate, forwarded to the
1957
+ // READ side. Both stores are single top-level options with
1958
+ // two consumers: the write paths above spend them keeping
1959
+ // a record, and this is where the record is spent. A
1960
+ // factory that accepts them and forwards neither here
1961
+ // boots, compiles, and answers every evicted locator as
1962
+ // though the operator had wired nothing — which is what a
1963
+ // record-less server is supposed to do, so the regression
1964
+ // is invisible in the response.
1965
+ //
1966
+ // Forwarded independently, never as a pair: the template's
1967
+ // own accessor requires all three stores before it will
1968
+ // consult any of them, so a half-wired deployment is
1969
+ // already handled there, in one place.
1970
+ ...(opts.renderIdentityStore
1971
+ ? { renderIdentityStore: opts.renderIdentityStore }
1972
+ : {}),
1973
+ ...(opts.durableBlueprints !== undefined
1974
+ ? { durableBlueprints: opts.durableBlueprints }
1975
+ : {}),
1976
+ // Same retention knob `ggui_render` stamps new renders
1977
+ // with. The read path spends it on the row a re-mint
1978
+ // commits and on an expired-but-readable row it puts back
1979
+ // into service; unforwarded, both silently fall back to an
1980
+ // hour on a deployment whose renders live far longer.
1981
+ ...(opts.renderTtlMs !== undefined ? { renderTtlMs: opts.renderTtlMs } : {}),
1982
+ // #457 — the resurrection cap rides with the retention
1983
+ // knob; both surfaces it bounds live in the read path.
1984
+ ...(opts.maxRenderLifetimeMs !== undefined
1985
+ ? { maxRenderLifetimeMs: opts.maxRenderLifetimeMs }
1986
+ : {}),
1789
1987
  // Live-channel wsToken minter — when wired, every
1790
1988
  // per-render resource shell embeds `{wsUrl, wsToken}`
1791
1989
  // so the iframe-runtime opens a WebSocket on mount and
@@ -1836,7 +2034,15 @@ export function createGguiServer(opts = {}) {
1836
2034
  // `runtime.url`. The bootstrap still carries a `runtimeUrl` so
1837
2035
  // the shell knows where to look — just not our HTTP listener.
1838
2036
  if (runtimeEnabled) {
1839
- mountRuntimeBundleRoute({ app, runtimePath, runtimeBundleFile, logger });
2037
+ mountRuntimeBundleRoute({
2038
+ app,
2039
+ runtimePath,
2040
+ runtimeBundleFile,
2041
+ logger,
2042
+ ...(hashedRuntimePath !== undefined && runtimeBundleBytes !== undefined
2043
+ ? { hashed: { path: hashedRuntimePath, source: runtimeBundleBytes } }
2044
+ : {}),
2045
+ });
1840
2046
  }
1841
2047
  // R6 /state snapshot + R7 /events cursor-replay reads — see
1842
2048
  // `./api-renders-routes.ts` for the wsToken auth posture, tenancy
@@ -2567,6 +2773,18 @@ export function createGguiServer(opts = {}) {
2567
2773
  ? { extraReservedValidators: composedReservedValidators }
2568
2774
  : {}),
2569
2775
  ...(opts.versionPolicy !== undefined ? { versionPolicy: opts.versionPolicy } : {}),
2776
+ // Pre-subscribe caps (ggui#444). Forwarded only when set — the
2777
+ // channel resolves its own generous defaults otherwise.
2778
+ ...(opts.wsMaxPayloadBytes !== undefined ? { maxPayloadBytes: opts.wsMaxPayloadBytes } : {}),
2779
+ ...(opts.wsMaxPreSubscribePayloadBytes !== undefined
2780
+ ? { maxPreSubscribePayloadBytes: opts.wsMaxPreSubscribePayloadBytes }
2781
+ : {}),
2782
+ ...(opts.wsPreSubscribeIdleMs !== undefined
2783
+ ? { preSubscribeIdleMs: opts.wsPreSubscribeIdleMs }
2784
+ : {}),
2785
+ ...(opts.wsMaxPreSubscribeConnections !== undefined
2786
+ ? { maxPreSubscribeConnections: opts.wsMaxPreSubscribeConnections }
2787
+ : {}),
2570
2788
  ...(opts.onFirstSubscriber ? { onFirstSubscriber: opts.onFirstSubscriber } : {}),
2571
2789
  ...(opts.onLastSubscriberGone ? { onLastSubscriberGone: opts.onLastSubscriberGone } : {}),
2572
2790
  // Shared TelemetrySink — bound once at composition so future
@@ -2658,7 +2876,24 @@ export function createGguiServer(opts = {}) {
2658
2876
  blueprintStore,
2659
2877
  blueprintSelector,
2660
2878
  blueprintSearch,
2661
- async listen(port = 0, host = "127.0.0.1") {
2879
+ async listen(port = 0, host = opts.host ?? "127.0.0.1") {
2880
+ // The Origin/Host validation policy (ggui#438a) was built above
2881
+ // from `opts.host ?? "127.0.0.1"` — the declared bind. A caller
2882
+ // that passes an explicit, DIFFERENT host here binds somewhere
2883
+ // the policy doesn't know about (e.g. a loopback-believing
2884
+ // policy fronting a wide-open listener), silently reintroducing
2885
+ // the rebinding gap the policy exists to close. Not thrown —
2886
+ // some embedders call `listen()` directly without threading
2887
+ // `CreateGguiServerOptions.host` — but always logged loudly so
2888
+ // the divergence is visible in ops.
2889
+ const declaredHost = opts.host ?? "127.0.0.1";
2890
+ if (host !== declaredHost) {
2891
+ logger.error("listen_host_policy_mismatch", {
2892
+ declaredHost,
2893
+ listenHost: host,
2894
+ hint: "Pass the same host via CreateGguiServerOptions.host — the Origin/Host validation policy was built from it and now describes a different bind.",
2895
+ });
2896
+ }
2662
2897
  return new Promise((resolve, reject) => {
2663
2898
  const server = app.listen(port, host, () => {
2664
2899
  const addr = server.address();
@@ -2683,7 +2918,36 @@ export function createGguiServer(opts = {}) {
2683
2918
  // other paths (or a future second WS endpoint) are rejected.
2684
2919
  if (channel) {
2685
2920
  server.on("upgrade", (req, socket, head) => {
2921
+ // Second ingress (ggui#438a): upgrade requests never
2922
+ // traverse Express middleware, so the same validator runs
2923
+ // here explicitly. The channel is MCP-plane — Origin is
2924
+ // always enforced. WebSocket handshakes are exempt from
2925
+ // the same-origin policy (any page can open a socket), so
2926
+ // an unchecked upgrade is a cross-site WS hijack surface.
2927
+ //
2928
+ // Opaque-origin ("null") admission is narrowed to sockets
2929
+ // that declare bootstrap intent via `?wsToken=` on the
2930
+ // upgrade URL — the srcdoc-bootstrap topology it exists to
2931
+ // serve. A bare opaque-origin socket with no bootstrap
2932
+ // intent is validated as the ordinary "http" ingress and
2933
+ // gets the standard rejection. Admission here is NOT
2934
+ // authentication: the subscribe-time handler
2935
+ // (`ggui-session-channel/subscribe.ts`) still rejects an
2936
+ // admitted socket outright unless it goes on to present a
2937
+ // verifying credential on the `subscribe` message itself.
2686
2938
  const url = new URL(req.url ?? "/", "http://localhost");
2939
+ const bootstrapIntentDeclared = url.searchParams.has("wsToken");
2940
+ const wsRejection = validateOriginHost(req.headers.host, typeof req.headers.origin === "string" ? req.headers.origin : undefined, originHostPolicy, bootstrapIntentDeclared ? "ws-upgrade" : "http");
2941
+ if (wsRejection !== null) {
2942
+ logger.warn("origin_host_rejected", {
2943
+ header: wsRejection.header,
2944
+ value: wsRejection.value,
2945
+ transport: "websocket",
2946
+ });
2947
+ socket.write("HTTP/1.1 403 Forbidden\r\n" + "Connection: close\r\n\r\n");
2948
+ socket.destroy();
2949
+ return;
2950
+ }
2687
2951
  if (url.pathname !== channel.path) {
2688
2952
  socket.write("HTTP/1.1 404 Not Found\r\n" + "Connection: close\r\n\r\n");
2689
2953
  socket.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/mcp-server",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "Self-hosted MCP server for the ggui protocol. An HTTP/MCP binding layer over @ggui-ai/mcp-server-handlers and the reference adapters from @ggui-ai/mcp-server-core — run it via `ggui serve`.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -32,16 +32,16 @@
32
32
  "resend": "^6.12.2",
33
33
  "ws": "^8.20.1",
34
34
  "zod": "^4.3.6",
35
- "@ggui-ai/console": "0.6.3",
36
- "@ggui-ai/mcp-server-core": "0.6.3",
37
- "@ggui-ai/negotiator": "0.6.3",
38
- "@ggui-ai/iframe-runtime": "0.6.3",
39
- "@ggui-ai/mcp-server-handlers": "0.6.3",
40
- "@ggui-ai/preview-a2ui": "0.6.3",
41
- "@ggui-ai/project-config": "0.6.3",
42
- "@ggui-ai/ui-gen": "0.6.3",
43
- "@ggui-ai/ui-registry": "0.6.3",
44
- "@ggui-ai/protocol": "0.6.3"
35
+ "@ggui-ai/console": "0.8.0",
36
+ "@ggui-ai/mcp-server-handlers": "0.8.0",
37
+ "@ggui-ai/negotiator": "0.8.0",
38
+ "@ggui-ai/mcp-server-core": "0.8.0",
39
+ "@ggui-ai/iframe-runtime": "0.8.0",
40
+ "@ggui-ai/preview-a2ui": "0.8.0",
41
+ "@ggui-ai/protocol": "0.8.0",
42
+ "@ggui-ai/project-config": "0.8.0",
43
+ "@ggui-ai/ui-gen": "0.8.0",
44
+ "@ggui-ai/ui-registry": "0.8.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/express": "^5.0.0",
@@ -50,7 +50,7 @@
50
50
  "@types/ws": "^8.5.10",
51
51
  "typescript": "^5.0.0",
52
52
  "vitest": "^3.2.6",
53
- "@ggui-ai/protocol-conformance": "0.6.3"
53
+ "@ggui-ai/protocol-conformance": "0.8.0"
54
54
  },
55
55
  "repository": {
56
56
  "type": "git",