@ambitresearch/genie 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/auth/bearer.d.ts +49 -0
- package/dist/auth/bearer.d.ts.map +1 -0
- package/dist/auth/bearer.js +159 -0
- package/dist/auth/bearer.js.map +1 -0
- package/dist/auth/oauth/authorize.d.ts +19 -0
- package/dist/auth/oauth/authorize.d.ts.map +1 -0
- package/dist/auth/oauth/authorize.js +111 -0
- package/dist/auth/oauth/authorize.js.map +1 -0
- package/dist/auth/oauth/config.d.ts +10 -0
- package/dist/auth/oauth/config.d.ts.map +1 -0
- package/dist/auth/oauth/config.js +16 -0
- package/dist/auth/oauth/config.js.map +1 -0
- package/dist/auth/oauth/dcr.d.ts +15 -0
- package/dist/auth/oauth/dcr.d.ts.map +1 -0
- package/dist/auth/oauth/dcr.js +38 -0
- package/dist/auth/oauth/dcr.js.map +1 -0
- package/dist/auth/oauth/jwt.d.ts +13 -0
- package/dist/auth/oauth/jwt.d.ts.map +1 -0
- package/dist/auth/oauth/jwt.js +46 -0
- package/dist/auth/oauth/jwt.js.map +1 -0
- package/dist/auth/oauth/metadata.d.ts +9 -0
- package/dist/auth/oauth/metadata.d.ts.map +1 -0
- package/dist/auth/oauth/metadata.js +19 -0
- package/dist/auth/oauth/metadata.js.map +1 -0
- package/dist/auth/oauth/router.d.ts +32 -0
- package/dist/auth/oauth/router.d.ts.map +1 -0
- package/dist/auth/oauth/router.js +159 -0
- package/dist/auth/oauth/router.js.map +1 -0
- package/dist/auth/oauth/store.d.ts +55 -0
- package/dist/auth/oauth/store.d.ts.map +1 -0
- package/dist/auth/oauth/store.js +63 -0
- package/dist/auth/oauth/store.js.map +1 -0
- package/dist/auth/oauth/token.d.ts +21 -0
- package/dist/auth/oauth/token.d.ts.map +1 -0
- package/dist/auth/oauth/token.js +77 -0
- package/dist/auth/oauth/token.js.map +1 -0
- package/dist/auth/oidc/group-policy.d.ts +43 -0
- package/dist/auth/oidc/group-policy.d.ts.map +1 -0
- package/dist/auth/oidc/group-policy.js +54 -0
- package/dist/auth/oidc/group-policy.js.map +1 -0
- package/dist/auth/oidc/verifier.d.ts +42 -0
- package/dist/auth/oidc/verifier.d.ts.map +1 -0
- package/dist/auth/oidc/verifier.js +145 -0
- package/dist/auth/oidc/verifier.js.map +1 -0
- package/dist/auth/token-cli.d.ts +9 -0
- package/dist/auth/token-cli.d.ts.map +1 -0
- package/dist/auth/token-cli.js +98 -0
- package/dist/auth/token-cli.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +149 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/redact.d.ts +20 -0
- package/dist/config/redact.d.ts.map +1 -0
- package/dist/config/redact.js +77 -0
- package/dist/config/redact.js.map +1 -0
- package/dist/config/secrets.d.ts +100 -0
- package/dist/config/secrets.d.ts.map +1 -0
- package/dist/config/secrets.js +130 -0
- package/dist/config/secrets.js.map +1 -0
- package/dist/framework/html.d.ts +103 -0
- package/dist/framework/html.d.ts.map +1 -0
- package/dist/framework/html.js +271 -0
- package/dist/framework/html.js.map +1 -0
- package/dist/framework/interface.d.ts +130 -0
- package/dist/framework/interface.d.ts.map +1 -0
- package/dist/framework/interface.js +112 -0
- package/dist/framework/interface.js.map +1 -0
- package/dist/framework/react.d.ts +133 -0
- package/dist/framework/react.d.ts.map +1 -0
- package/dist/framework/react.js +243 -0
- package/dist/framework/react.js.map +1 -0
- package/dist/framework/vue.d.ts +99 -0
- package/dist/framework/vue.d.ts.map +1 -0
- package/dist/framework/vue.js +375 -0
- package/dist/framework/vue.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/client.d.ts +81 -0
- package/dist/llm/client.d.ts.map +1 -0
- package/dist/llm/client.js +141 -0
- package/dist/llm/client.js.map +1 -0
- package/dist/llm/component-response.d.ts +111 -0
- package/dist/llm/component-response.d.ts.map +1 -0
- package/dist/llm/component-response.js +167 -0
- package/dist/llm/component-response.js.map +1 -0
- package/dist/llm/generated-files.d.ts +10 -0
- package/dist/llm/generated-files.d.ts.map +1 -0
- package/dist/llm/generated-files.js +26 -0
- package/dist/llm/generated-files.js.map +1 -0
- package/dist/llm/prompts/CHANGELOG.md +70 -0
- package/dist/llm/prompts/generate-component.system.md +99 -0
- package/dist/llm/prompts/refine-component.system.md +112 -0
- package/dist/llm/prompts.d.ts +66 -0
- package/dist/llm/prompts.d.ts.map +1 -0
- package/dist/llm/prompts.js +78 -0
- package/dist/llm/prompts.js.map +1 -0
- package/dist/llm/retry.d.ts +183 -0
- package/dist/llm/retry.d.ts.map +1 -0
- package/dist/llm/retry.js +475 -0
- package/dist/llm/retry.js.map +1 -0
- package/dist/llm/schema.d.ts +129 -0
- package/dist/llm/schema.d.ts.map +1 -0
- package/dist/llm/schema.js +147 -0
- package/dist/llm/schema.js.map +1 -0
- package/dist/llm/validate.d.ts +107 -0
- package/dist/llm/validate.d.ts.map +1 -0
- package/dist/llm/validate.js +174 -0
- package/dist/llm/validate.js.map +1 -0
- package/dist/manifest/compiler.d.ts +112 -0
- package/dist/manifest/compiler.d.ts.map +1 -0
- package/dist/manifest/compiler.js +306 -0
- package/dist/manifest/compiler.js.map +1 -0
- package/dist/manifest/ensure.d.ts +46 -0
- package/dist/manifest/ensure.d.ts.map +1 -0
- package/dist/manifest/ensure.js +49 -0
- package/dist/manifest/ensure.js.map +1 -0
- package/dist/manifest/index.d.ts +13 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +13 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/middleware/plan-guard.d.ts +167 -0
- package/dist/middleware/plan-guard.d.ts.map +1 -0
- package/dist/middleware/plan-guard.js +190 -0
- package/dist/middleware/plan-guard.js.map +1 -0
- package/dist/plans/index.d.ts +71 -0
- package/dist/plans/index.d.ts.map +1 -0
- package/dist/plans/index.js +252 -0
- package/dist/plans/index.js.map +1 -0
- package/dist/schemas/component.schema.json +114 -0
- package/dist/server.d.ts +96 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +258 -0
- package/dist/server.js.map +1 -0
- package/dist/store/empty-manifest.d.ts +58 -0
- package/dist/store/empty-manifest.d.ts.map +1 -0
- package/dist/store/empty-manifest.js +59 -0
- package/dist/store/empty-manifest.js.map +1 -0
- package/dist/store/git-host.d.ts +120 -0
- package/dist/store/git-host.d.ts.map +1 -0
- package/dist/store/git-host.js +796 -0
- package/dist/store/git-host.js.map +1 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +4 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/interface.d.ts +322 -0
- package/dist/store/interface.d.ts.map +1 -0
- package/dist/store/interface.js +100 -0
- package/dist/store/interface.js.map +1 -0
- package/dist/store/kit-files.d.ts +88 -0
- package/dist/store/kit-files.d.ts.map +1 -0
- package/dist/store/kit-files.js +199 -0
- package/dist/store/kit-files.js.map +1 -0
- package/dist/store/local.d.ts +70 -0
- package/dist/store/local.d.ts.map +1 -0
- package/dist/store/local.js +651 -0
- package/dist/store/local.js.map +1 -0
- package/dist/store/manifest.d.ts +44 -0
- package/dist/store/manifest.d.ts.map +1 -0
- package/dist/store/manifest.js +116 -0
- package/dist/store/manifest.js.map +1 -0
- package/dist/store/viewer-assets.d.ts +20 -0
- package/dist/store/viewer-assets.d.ts.map +1 -0
- package/dist/store/viewer-assets.js +118 -0
- package/dist/store/viewer-assets.js.map +1 -0
- package/dist/sync/anchor.d.ts +125 -0
- package/dist/sync/anchor.d.ts.map +1 -0
- package/dist/sync/anchor.js +210 -0
- package/dist/sync/anchor.js.map +1 -0
- package/dist/sync/index.d.ts +15 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +15 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/orchestrator.d.ts +160 -0
- package/dist/sync/orchestrator.d.ts.map +1 -0
- package/dist/sync/orchestrator.js +288 -0
- package/dist/sync/orchestrator.js.map +1 -0
- package/dist/tools/bind_kit.d.ts +29 -0
- package/dist/tools/bind_kit.d.ts.map +1 -0
- package/dist/tools/bind_kit.js +70 -0
- package/dist/tools/bind_kit.js.map +1 -0
- package/dist/tools/conjure.d.ts +141 -0
- package/dist/tools/conjure.d.ts.map +1 -0
- package/dist/tools/conjure.js +655 -0
- package/dist/tools/conjure.js.map +1 -0
- package/dist/tools/conjure_screen.d.ts +174 -0
- package/dist/tools/conjure_screen.d.ts.map +1 -0
- package/dist/tools/conjure_screen.js +458 -0
- package/dist/tools/conjure_screen.js.map +1 -0
- package/dist/tools/create_kit.d.ts +32 -0
- package/dist/tools/create_kit.d.ts.map +1 -0
- package/dist/tools/create_kit.js +131 -0
- package/dist/tools/create_kit.js.map +1 -0
- package/dist/tools/create_project.d.ts +267 -0
- package/dist/tools/create_project.d.ts.map +1 -0
- package/dist/tools/create_project.js +546 -0
- package/dist/tools/create_project.js.map +1 -0
- package/dist/tools/delete_files.d.ts +93 -0
- package/dist/tools/delete_files.d.ts.map +1 -0
- package/dist/tools/delete_files.js +276 -0
- package/dist/tools/delete_files.js.map +1 -0
- package/dist/tools/delete_project.d.ts +43 -0
- package/dist/tools/delete_project.d.ts.map +1 -0
- package/dist/tools/delete_project.js +118 -0
- package/dist/tools/delete_project.js.map +1 -0
- package/dist/tools/get_kit.d.ts +28 -0
- package/dist/tools/get_kit.d.ts.map +1 -0
- package/dist/tools/get_kit.js +95 -0
- package/dist/tools/get_kit.js.map +1 -0
- package/dist/tools/get_project.d.ts +37 -0
- package/dist/tools/get_project.d.ts.map +1 -0
- package/dist/tools/get_project.js +71 -0
- package/dist/tools/get_project.js.map +1 -0
- package/dist/tools/list_components.d.ts +42 -0
- package/dist/tools/list_components.d.ts.map +1 -0
- package/dist/tools/list_components.js +125 -0
- package/dist/tools/list_components.js.map +1 -0
- package/dist/tools/list_files.d.ts +32 -0
- package/dist/tools/list_files.d.ts.map +1 -0
- package/dist/tools/list_files.js +109 -0
- package/dist/tools/list_files.js.map +1 -0
- package/dist/tools/list_kits.d.ts +14 -0
- package/dist/tools/list_kits.d.ts.map +1 -0
- package/dist/tools/list_kits.js +41 -0
- package/dist/tools/list_kits.js.map +1 -0
- package/dist/tools/list_projects.d.ts +23 -0
- package/dist/tools/list_projects.d.ts.map +1 -0
- package/dist/tools/list_projects.js +66 -0
- package/dist/tools/list_projects.js.map +1 -0
- package/dist/tools/plan.d.ts +16 -0
- package/dist/tools/plan.d.ts.map +1 -0
- package/dist/tools/plan.js +146 -0
- package/dist/tools/plan.js.map +1 -0
- package/dist/tools/preview.d.ts +289 -0
- package/dist/tools/preview.d.ts.map +1 -0
- package/dist/tools/preview.js +672 -0
- package/dist/tools/preview.js.map +1 -0
- package/dist/tools/read_file.d.ts +40 -0
- package/dist/tools/read_file.d.ts.map +1 -0
- package/dist/tools/read_file.js +114 -0
- package/dist/tools/read_file.js.map +1 -0
- package/dist/tools/refine.d.ts +220 -0
- package/dist/tools/refine.d.ts.map +1 -0
- package/dist/tools/refine.js +501 -0
- package/dist/tools/refine.js.map +1 -0
- package/dist/tools/validate.d.ts +74 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +224 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/tools/write_files.d.ts +158 -0
- package/dist/tools/write_files.d.ts.map +1 -0
- package/dist/tools/write_files.js +541 -0
- package/dist/tools/write_files.js.map +1 -0
- package/dist/transport.d.ts +77 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +448 -0
- package/dist/transport.js.map +1 -0
- package/dist/ui/card-asset-broker.d.ts +42 -0
- package/dist/ui/card-asset-broker.d.ts.map +1 -0
- package/dist/ui/card-asset-broker.js +479 -0
- package/dist/ui/card-asset-broker.js.map +1 -0
- package/dist/ui/grid-resource.d.ts +252 -0
- package/dist/ui/grid-resource.d.ts.map +1 -0
- package/dist/ui/grid-resource.js +680 -0
- package/dist/ui/grid-resource.js.map +1 -0
- package/dist/ui/viewer-static/index.html +95 -0
- package/dist/ui/viewer-static/viewer.css +451 -0
- package/dist/ui/viewer-static/viewer.js +1405 -0
- package/dist/validate/full-scan.d.ts +87 -0
- package/dist/validate/full-scan.d.ts.map +1 -0
- package/dist/validate/full-scan.js +299 -0
- package/dist/validate/full-scan.js.map +1 -0
- package/dist/validate/index.d.ts +15 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +18 -0
- package/dist/validate/index.js.map +1 -0
- package/dist/validate/marker.d.ts +89 -0
- package/dist/validate/marker.d.ts.map +1 -0
- package/dist/validate/marker.js +87 -0
- package/dist/validate/marker.js.map +1 -0
- package/dist/validate/phash.d.ts +135 -0
- package/dist/validate/phash.d.ts.map +1 -0
- package/dist/validate/phash.js +307 -0
- package/dist/validate/phash.js.map +1 -0
- package/dist/validate/render.d.ts +38 -0
- package/dist/validate/render.d.ts.map +1 -0
- package/dist/validate/render.js +155 -0
- package/dist/validate/render.js.map +1 -0
- package/dist/watch/index.d.ts +12 -0
- package/dist/watch/index.d.ts.map +1 -0
- package/dist/watch/index.js +12 -0
- package/dist/watch/index.js.map +1 -0
- package/dist/watch/watcher.d.ts +65 -0
- package/dist/watch/watcher.d.ts.map +1 -0
- package/dist/watch/watcher.js +238 -0
- package/dist/watch/watcher.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-Apps resource: `ui://genie/grid` (M4-06 / DRO-268).
|
|
3
|
+
*
|
|
4
|
+
* Registers the embedded preview surface a `ui://`-capable harness (Claude,
|
|
5
|
+
* VS Code ≥Jan 2026, ChatGPT, Cursor, …) renders inside its own sandboxed
|
|
6
|
+
* iframe. The `preview` tool (M4-05 / DRO-267) advertises the bare URI as its
|
|
7
|
+
* standards-compliant app shell and retains a query-bearing result URI for
|
|
8
|
+
* legacy hosts. THIS module answers both resource forms.
|
|
9
|
+
*
|
|
10
|
+
* ── Contract (this issue's AC1–AC6; RFC §6.5) ────────────────────────────────
|
|
11
|
+
* AC1 register `ui://genie/grid`, MIME `text/html;profile=mcp-app`.
|
|
12
|
+
* AC2 handler resolves `?kitId=…`, compiles the manifest (M3-03), inlines it
|
|
13
|
+
* as `<script type="application/json" id="manifest">…</script>` — the
|
|
14
|
+
* sandboxed iframe needs NO fetch (its CSP is `connect-src 'none'`).
|
|
15
|
+
* AC3 viewer.js / viewer.css are inlined byte-for-byte into the one raw HTML
|
|
16
|
+
* resource a compliant MCP Apps host sends to its sandbox proxy.
|
|
17
|
+
* AC4 each card's iframe `src` is rewritten to an absolute `https://` URL on
|
|
18
|
+
* a separate-origin previews host, or (solo dev) a `data:` inline URL.
|
|
19
|
+
* AC5 the CSP allow-list (`connectDomains` / `resourceDomains` /
|
|
20
|
+
* `frameDomains`) is declared at canonical `_meta.ui.csp`.
|
|
21
|
+
* (AC6 — `_meta["openai/outputTemplate"]` on the *tool* result — lives on the
|
|
22
|
+
* `preview` tool in `../tools/preview.ts`, not here.)
|
|
23
|
+
*
|
|
24
|
+
* ── Deviation from the issue's Implementation Notes (flagged for review) ──────
|
|
25
|
+
* The notes say "Use `@modelcontextprotocol/ext-apps/server`". That package is
|
|
26
|
+
* NOT installed in this repo (only `@modelcontextprotocol/sdk@^1.29`), so this
|
|
27
|
+
* module registers through the SDK's own `server.registerResource`. The
|
|
28
|
+
* MCP-Apps CSP shape ext-apps would carry is reproduced verbatim in `_meta`
|
|
29
|
+
* (see {@link buildCspMeta}), so an ext-apps adoption later is a lift-and-shift,
|
|
30
|
+
* not a rewrite.
|
|
31
|
+
*
|
|
32
|
+
* ── Self-contained host contract ──────────────────────────────────────────────
|
|
33
|
+
* MCP Apps hosts receive raw HTML in `resources/read` and do not translate
|
|
34
|
+
* browser-relative URLs into additional MCP resource reads. The shipped shell's
|
|
35
|
+
* relative asset tags are therefore replaced with exact inline bytes, each
|
|
36
|
+
* allow-listed by SHA-256 in the injected CSP. Sibling resources remain
|
|
37
|
+
* registered only for backwards compatibility with older host experiments.
|
|
38
|
+
*
|
|
39
|
+
* ── Byte-identical cards (RFC G-5) ───────────────────────────────────────────
|
|
40
|
+
* Only the card *transport* differs per vehicle: `file://`/localhost fetch the
|
|
41
|
+
* manifest and use relative preview paths; the embedded `ui://` tier inlines
|
|
42
|
+
* the manifest and rewrites each preview path to an absolute/`data:` URL (AC4).
|
|
43
|
+
* The preview HTML bytes themselves are untouched — the card renders identically
|
|
44
|
+
* in all three vehicles.
|
|
45
|
+
*/
|
|
46
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
47
|
+
import { createRequire } from "node:module";
|
|
48
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
49
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
52
|
+
import { parse } from "parse5";
|
|
53
|
+
import { ensureManifest } from "../manifest/index.js";
|
|
54
|
+
import { KIT_ID_PATTERN } from "../tools/get_kit.js";
|
|
55
|
+
// ─── Public constants (AC1) ──────────────────────────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* The embedded preview resource URI. `v` identifies the wire contract while
|
|
58
|
+
* the process-scoped nonce prevents hosts from reusing a stale app document
|
|
59
|
+
* after the MCP server restarts. It remains stable for every read in one
|
|
60
|
+
* process, so resource registration and tool metadata always agree.
|
|
61
|
+
*/
|
|
62
|
+
export const GRID_RESOURCE_URI = `ui://genie/grid?v=2&instance=${randomBytes(16).toString("hex")}`;
|
|
63
|
+
/** The spec-mandated MCP-Apps MIME (stable spec 2026-01-26; RFC §6.5). */
|
|
64
|
+
export const GRID_RESOURCE_MIME = "text/html;profile=mcp-app";
|
|
65
|
+
/** The DOM id `viewer.js` reads the inlined manifest from (must match it). */
|
|
66
|
+
export const MANIFEST_ELEMENT_ID = "manifest";
|
|
67
|
+
export const TOOL_RESULT_SHELL_META = "genie-tool-result-shell";
|
|
68
|
+
/** Legacy sibling asset URIs retained for older experimental hosts. */
|
|
69
|
+
export const VIEWER_JS_URI = "ui://genie/viewer.js";
|
|
70
|
+
export const VIEWER_CSS_URI = "ui://genie/viewer.css";
|
|
71
|
+
// ─── kitId → kit dir (path-traversal guard, shared with `preview`) ────────────
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `kitId` to its on-disk dir under `kitsRoot`, or `null` when the id is
|
|
74
|
+
* absent or fails {@link KIT_ID_PATTERN}. Returning `null` (rather than
|
|
75
|
+
* throwing) lets the handler degrade to an EMPTY grid instead of erroring a
|
|
76
|
+
* host's `resources/read` — a hostile or malformed `kitId` can never escape the
|
|
77
|
+
* kits root (RFC §10 T-13, the same guard `preview`/`read_file` apply).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveKitDir(kitsRoot, kitId) {
|
|
80
|
+
if (kitId === undefined || !KIT_ID_PATTERN.test(kitId))
|
|
81
|
+
return null;
|
|
82
|
+
return join(kitsRoot, kitId);
|
|
83
|
+
}
|
|
84
|
+
// ─── AC2 manifest filter ─────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Narrow a manifest to an optional single component and/or group (the
|
|
87
|
+
* `componentName` / `group` query params `preview` forwards). Exact,
|
|
88
|
+
* case-sensitive matches — the fuzzy substring search is the viewer's job
|
|
89
|
+
* (`#q`); this pre-filter is deterministic so the inlined payload is stable for
|
|
90
|
+
* a given URI. `groups` is recomputed from what survives so no empty section is
|
|
91
|
+
* inlined. A filter that matches nothing yields an empty-but-valid manifest
|
|
92
|
+
* (the viewer renders its empty state).
|
|
93
|
+
*/
|
|
94
|
+
export function filterManifest(manifest, filter) {
|
|
95
|
+
const { componentName, group } = filter;
|
|
96
|
+
if (componentName === undefined && group === undefined)
|
|
97
|
+
return manifest;
|
|
98
|
+
const components = manifest.components.filter((c) => (group === undefined || c.group === group) &&
|
|
99
|
+
(componentName === undefined || c.name === componentName));
|
|
100
|
+
const survivingGroups = new Set(components.map((c) => c.group));
|
|
101
|
+
return {
|
|
102
|
+
...manifest,
|
|
103
|
+
groups: manifest.groups.filter((g) => survivingGroups.has(g)),
|
|
104
|
+
components,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// ─── AC4 card `src` rewrite (byte-identical content; transport differs) ───────
|
|
108
|
+
/**
|
|
109
|
+
* Validate + normalise a configured previews base URL. Returns the parsed
|
|
110
|
+
* `URL` (its `href` forced to end in `/` so a later relative-join appends
|
|
111
|
+
* rather than replacing the last path segment) when `raw` is a well-formed
|
|
112
|
+
* ABSOLUTE `http(s)` URL, else `undefined`. A malformed value (a bare host, a
|
|
113
|
+
* typo, a non-http scheme) degrades to `undefined` — the caller then falls
|
|
114
|
+
* back to the solo-dev `data:` transport instead of throwing. Centralising the
|
|
115
|
+
* one `new URL()` parse here is what keeps `rewriteCardPaths` /`buildCspMeta`
|
|
116
|
+
* from crashing `resources/read` or server startup on bad config (the exact
|
|
117
|
+
* failure the reviewer flagged): validate once, branch on the result.
|
|
118
|
+
*/
|
|
119
|
+
export function normalizePreviewsBaseUrl(raw) {
|
|
120
|
+
if (raw === undefined || raw === "")
|
|
121
|
+
return undefined;
|
|
122
|
+
let url;
|
|
123
|
+
try {
|
|
124
|
+
url = new URL(raw);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
130
|
+
return undefined;
|
|
131
|
+
if (!url.pathname.endsWith("/"))
|
|
132
|
+
url.pathname += "/";
|
|
133
|
+
return url;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Preserve every card's kit-relative identity as `sourcePath`, then rewrite
|
|
137
|
+
* `path` to the URL the EMBEDDED tier's iframe should load (AC4). With a valid
|
|
138
|
+
* `previewsBaseUrl`, that's an absolute `https://` URL on
|
|
139
|
+
* the separate previews origin (`${base}/${kitId}/${path}`) — the cross-origin
|
|
140
|
+
* isolation the RFC §6/T-09 recommends. Without one — OR when the configured
|
|
141
|
+
* base URL is malformed (see {@link normalizePreviewsBaseUrl}) — the preview's
|
|
142
|
+
* bytes are read and encoded as a `data:text/html;base64,…` URL so the grid is
|
|
143
|
+
* fully self-contained. A preview whose bytes can't be read keeps its relative
|
|
144
|
+
* `path` (harmless: that card simply won't resolve in the embedded tier — a
|
|
145
|
+
* strictly better degradation than dropping the card).
|
|
146
|
+
*
|
|
147
|
+
* Card CONTENT is never altered here — only the URL the iframe points at — so
|
|
148
|
+
* the card stays byte-identical to the `file://`/localhost vehicles (G-5).
|
|
149
|
+
*/
|
|
150
|
+
export async function rewriteCardPaths(manifest, opts) {
|
|
151
|
+
const { kitId, kitDir, previewsBaseUrl, readPreviewBytes, cardAssetKit, onPreviewHtml } = opts;
|
|
152
|
+
// Validate the previews base URL ONCE up front; a malformed value degrades to
|
|
153
|
+
// the solo-dev `data:` path rather than throwing per-card.
|
|
154
|
+
const base = normalizePreviewsBaseUrl(previewsBaseUrl);
|
|
155
|
+
const components = await Promise.all(manifest.components.map(async (card) => {
|
|
156
|
+
const sourcePath = card.path;
|
|
157
|
+
if (base !== undefined) {
|
|
158
|
+
// Absolute https:// on the separate previews origin. `base` already ends
|
|
159
|
+
// in `/` (normalizePreviewsBaseUrl) so the kitId segment is appended,
|
|
160
|
+
// not replacing the last path part; `URL` percent-encodes the join.
|
|
161
|
+
const src = new URL(`${encodeURIComponent(kitId)}/${card.path}`, base).toString();
|
|
162
|
+
return { ...card, sourcePath, path: src };
|
|
163
|
+
}
|
|
164
|
+
if (cardAssetKit !== undefined) {
|
|
165
|
+
return { ...card, sourcePath, path: cardAssetKit.urlFor(card.path) };
|
|
166
|
+
}
|
|
167
|
+
// Solo-dev fallback: inline the preview bytes as a data: URL.
|
|
168
|
+
const bytes = await readPreviewBytes(kitDir, card.path);
|
|
169
|
+
if (bytes === null)
|
|
170
|
+
return { ...card, sourcePath }; // keep relative path; degrade gracefully
|
|
171
|
+
onPreviewHtml?.(bytes.toString("utf8"));
|
|
172
|
+
const src = `data:text/html;base64,${bytes.toString("base64")}`;
|
|
173
|
+
return { ...card, sourcePath, path: src };
|
|
174
|
+
}));
|
|
175
|
+
return { ...manifest, components };
|
|
176
|
+
}
|
|
177
|
+
// ─── AC2 manifest inlining (XSS-safe) ────────────────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* Escape a JSON string for safe embedding inside `<script type="application/
|
|
180
|
+
* json">`. In JSON, `<`/`>`/`&` only ever occur INSIDE string literals, so
|
|
181
|
+
* replacing them with their `\uXXXX` escapes yields equivalent, still-valid
|
|
182
|
+
* JSON that no HTML parser can misread as a `</script>` terminator or a comment
|
|
183
|
+
* (`<!--`). U+2028/U+2029 are escaped too (defensive; they're legal in JSON but
|
|
184
|
+
* not in JS string literals, and some embedders re-parse). This is the
|
|
185
|
+
* canonical safe-embedding transform.
|
|
186
|
+
*/
|
|
187
|
+
export function escapeJsonForScript(json) {
|
|
188
|
+
return json
|
|
189
|
+
.replace(/</g, "\\u003c")
|
|
190
|
+
.replace(/>/g, "\\u003e")
|
|
191
|
+
.replace(/&/g, "\\u0026")
|
|
192
|
+
.replace(/\u2028/g, "\\u2028")
|
|
193
|
+
.replace(/\u2029/g, "\\u2029");
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Inject the compiled manifest into the viewer's `index.html` as an inline
|
|
197
|
+
* `<script type="application/json" id="manifest">` (AC2), placed just before
|
|
198
|
+
* `</head>` so it parses before `viewer.js` runs. If no `</head>` is present
|
|
199
|
+
* (defensive — the shipped shell always has one) the script is prepended, which
|
|
200
|
+
* still parses before the body script. The manifest is JSON-escaped so a
|
|
201
|
+
* hostile component name/path can't break out of the script element.
|
|
202
|
+
*/
|
|
203
|
+
export function inlineManifest(indexHtml, manifest) {
|
|
204
|
+
const json = escapeJsonForScript(JSON.stringify(manifest));
|
|
205
|
+
const tag = `<script type="application/json" id="${MANIFEST_ELEMENT_ID}">${json}</script>`;
|
|
206
|
+
const headClose = indexHtml.indexOf("</head>");
|
|
207
|
+
if (headClose === -1)
|
|
208
|
+
return tag + indexHtml;
|
|
209
|
+
return indexHtml.slice(0, headClose) + tag + indexHtml.slice(headClose);
|
|
210
|
+
}
|
|
211
|
+
function cspSha256(content) {
|
|
212
|
+
return `'sha256-${createHash("sha256").update(content, "utf8").digest("base64")}'`;
|
|
213
|
+
}
|
|
214
|
+
function rawText(node) {
|
|
215
|
+
if (node.nodeName === "#text")
|
|
216
|
+
return node.value ?? "";
|
|
217
|
+
return (node.childNodes ?? []).map(rawText).join("");
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Parse HTML and hash its trusted inline `<script>` / `<style>` text exactly as
|
|
221
|
+
* the browser sees it. External scripts have no inline bytes and are ignored.
|
|
222
|
+
*/
|
|
223
|
+
export function collectInlineCspHashes(html) {
|
|
224
|
+
const scripts = new Set();
|
|
225
|
+
const styles = new Set();
|
|
226
|
+
const root = parse(html);
|
|
227
|
+
function visit(node) {
|
|
228
|
+
if (node.tagName === "script") {
|
|
229
|
+
const hasSrc = node.attrs?.some((attr) => attr.name.toLowerCase() === "src") ?? false;
|
|
230
|
+
const content = rawText(node);
|
|
231
|
+
if (!hasSrc && content !== "")
|
|
232
|
+
scripts.add(cspSha256(content));
|
|
233
|
+
}
|
|
234
|
+
else if (node.tagName === "style") {
|
|
235
|
+
const content = rawText(node);
|
|
236
|
+
if (content !== "")
|
|
237
|
+
styles.add(cspSha256(content));
|
|
238
|
+
}
|
|
239
|
+
for (const child of node.childNodes ?? [])
|
|
240
|
+
visit(child);
|
|
241
|
+
}
|
|
242
|
+
visit(root);
|
|
243
|
+
return { scriptHashes: [...scripts], styleHashes: [...styles] };
|
|
244
|
+
}
|
|
245
|
+
function escapeRawTextEndTag(content, tagName) {
|
|
246
|
+
return content.replace(new RegExp(`</${tagName}`, "gi"), `<\\/${tagName}`);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Make the MCP App document self-contained. A host receives one raw HTML
|
|
250
|
+
* resource; browser-relative `ui://` siblings are not additional resources/read
|
|
251
|
+
* calls, so the exact viewer JS/CSS bytes must travel inside that document.
|
|
252
|
+
*/
|
|
253
|
+
export function inlineViewerAssets(indexHtml, viewerJs, viewerCss) {
|
|
254
|
+
const script = escapeRawTextEndTag(viewerJs, "script");
|
|
255
|
+
const style = escapeRawTextEndTag(viewerCss, "style");
|
|
256
|
+
const styleTag = `<style>${style}</style>`;
|
|
257
|
+
const scriptTag = `<script>${script}</script>`;
|
|
258
|
+
const cssLink = /<link\b[^>]*href=["']\.\/viewer\.css["'][^>]*>/i;
|
|
259
|
+
const jsScript = /<script\b[^>]*src=["']\.\/viewer\.js["'][^>]*><\/script>/i;
|
|
260
|
+
let html = cssLink.test(indexHtml)
|
|
261
|
+
? indexHtml.replace(cssLink, styleTag)
|
|
262
|
+
: injectBeforeClosingTag(indexHtml, "head", styleTag);
|
|
263
|
+
html = jsScript.test(html)
|
|
264
|
+
? html.replace(jsScript, scriptTag)
|
|
265
|
+
: injectBeforeClosingTag(html, "body", scriptTag);
|
|
266
|
+
return {
|
|
267
|
+
html,
|
|
268
|
+
hashes: {
|
|
269
|
+
scriptHashes: [cspSha256(script)],
|
|
270
|
+
styleHashes: [cspSha256(style)],
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function injectBeforeClosingTag(html, tagName, content) {
|
|
275
|
+
const close = `</${tagName}>`;
|
|
276
|
+
const at = html.indexOf(close);
|
|
277
|
+
return at === -1 ? html + content : html.slice(0, at) + content + html.slice(at);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Build the AC5 domain allow-list and strict hash-based document policy.
|
|
281
|
+
*
|
|
282
|
+
* The card iframes load from the previews origin when configured, else from
|
|
283
|
+
* `data:` (solo dev). `connectDomains` is always empty — `connect-src 'none'`
|
|
284
|
+
* — because the manifest travels inline (AC2); there is nothing to fetch.
|
|
285
|
+
* `img-src` permits `data:`/`https:` for card thumbnails. The domain shape
|
|
286
|
+
* matches the stable MCP Apps 2026-01-26 `_meta.ui.csp` contract.
|
|
287
|
+
*
|
|
288
|
+
* Hardening applied vs the pre-M4-07 policy:
|
|
289
|
+
* • Removed `'unsafe-inline'` / `'unsafe-eval'` (AC3). The exact inlined
|
|
290
|
+
* viewer assets and legitimate inline blocks inside data-backed cards are
|
|
291
|
+
* allow-listed by SHA-256. Event-handler attributes remain blocked because
|
|
292
|
+
* the policy does not opt into `'unsafe-hashes'`.
|
|
293
|
+
* • Added `base-uri 'none'` — no `<base href>` can redirect URLs.
|
|
294
|
+
* • Added `form-action 'none'` and `object-src 'none'` — deny `<form>` /
|
|
295
|
+
* `<object>` / `<embed>` per AC1's "deny form/object/embed" line.
|
|
296
|
+
* Resources/read has no HTTP-header channel, so no raw header policy or
|
|
297
|
+
* `frame-ancestors` claim is advertised.
|
|
298
|
+
*/
|
|
299
|
+
export function buildCspMeta(previewsBaseUrl, inlineHashes = { scriptHashes: [], styleHashes: [] }, exactFrameDomains) {
|
|
300
|
+
// Validate via the shared normaliser so a malformed GENIE_PREVIEWS_BASE_URL
|
|
301
|
+
// NEVER throws here — an invalid value degrades to the solo-dev `data:` frame
|
|
302
|
+
// origin exactly as `rewriteCardPaths` does, keeping the two in lockstep and
|
|
303
|
+
// never crashing `registerGridResource`/server startup (reviewer flag).
|
|
304
|
+
const base = normalizePreviewsBaseUrl(previewsBaseUrl);
|
|
305
|
+
const frameDomains = base !== undefined
|
|
306
|
+
? [base.origin]
|
|
307
|
+
: exactFrameDomains === undefined
|
|
308
|
+
? ["data:"]
|
|
309
|
+
: [...new Set(exactFrameDomains)];
|
|
310
|
+
const resourceDomains = [];
|
|
311
|
+
const frameSrc = frameDomains.length === 0 ? "'none'" : frameDomains.join(" ");
|
|
312
|
+
const scriptHashes = validCspHashes(inlineHashes.scriptHashes);
|
|
313
|
+
const styleHashes = validCspHashes(inlineHashes.styleHashes);
|
|
314
|
+
const scriptSrc = scriptHashes.length > 0 ? scriptHashes.join(" ") : "'none'";
|
|
315
|
+
const styleSrc = styleHashes.length > 0 ? styleHashes.join(" ") : "'none'";
|
|
316
|
+
const core = [
|
|
317
|
+
"default-src 'none'",
|
|
318
|
+
`script-src ${scriptSrc}`,
|
|
319
|
+
`style-src ${styleSrc}`,
|
|
320
|
+
"img-src data: https:",
|
|
321
|
+
`frame-src ${frameSrc}`,
|
|
322
|
+
"connect-src 'none'",
|
|
323
|
+
"object-src 'none'",
|
|
324
|
+
"base-uri 'none'",
|
|
325
|
+
"form-action 'none'",
|
|
326
|
+
];
|
|
327
|
+
const metaPolicy = core.join("; ");
|
|
328
|
+
return { connectDomains: [], resourceDomains, frameDomains, metaPolicy };
|
|
329
|
+
}
|
|
330
|
+
function validCspHashes(hashes) {
|
|
331
|
+
return [...new Set(hashes.filter((hash) => /^'sha256-[A-Za-z0-9+/]+={0,2}'$/.test(hash)))];
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Render a `<meta http-equiv="Content-Security-Policy" content="…">` tag for
|
|
335
|
+
* the given policy meta. Uses {@link GridCspMeta.metaPolicy}.
|
|
336
|
+
*
|
|
337
|
+
* The `content` attribute value is HTML-attribute-escaped (`&`, `"`, `<`, `>`)
|
|
338
|
+
* defensively. In current use the policy string is server-authored with no
|
|
339
|
+
* user input, so the escape is defence-in-depth against a future change that
|
|
340
|
+
* accidentally embeds a `"` — without it the attribute would terminate early
|
|
341
|
+
* and the policy would silently degrade.
|
|
342
|
+
*/
|
|
343
|
+
export function cspMetaTag(meta) {
|
|
344
|
+
const attr = escapeHtmlAttribute(meta.metaPolicy);
|
|
345
|
+
return `<meta http-equiv="Content-Security-Policy" content="${attr}">`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Escape a string for safe embedding inside a `"`-quoted HTML attribute value.
|
|
349
|
+
* Handles the four characters `&` (must be first, else it double-escapes
|
|
350
|
+
* subsequent `&`s), `"` (attribute delimiter), `<`, `>`. Defence-in-depth for
|
|
351
|
+
* {@link cspMetaTag} — the policy is server-authored today, but a future
|
|
352
|
+
* directive that legitimately contains a `"` (e.g. a hostname in quotes) must
|
|
353
|
+
* not break the attribute.
|
|
354
|
+
*/
|
|
355
|
+
function escapeHtmlAttribute(str) {
|
|
356
|
+
return str
|
|
357
|
+
.replace(/&/g, "&")
|
|
358
|
+
.replace(/"/g, """)
|
|
359
|
+
.replace(/</g, "<")
|
|
360
|
+
.replace(/>/g, ">");
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Inject the enforced CSP meta into an assembled document. Placed at the very
|
|
364
|
+
* top of `<head>` so every subsequent element — the manifest data island,
|
|
365
|
+
* inlined `viewer.js`/`viewer.css`, any inline attributes — is parsed under
|
|
366
|
+
* the policy. If the shell has no `<head>` (defensive; every shipped shell
|
|
367
|
+
* does) the tag is prepended so it still parses first.
|
|
368
|
+
*/
|
|
369
|
+
export function injectCspMeta(html, meta) {
|
|
370
|
+
const tag = cspMetaTag(meta);
|
|
371
|
+
const headOpen = html.indexOf("<head>");
|
|
372
|
+
if (headOpen === -1)
|
|
373
|
+
return tag + html;
|
|
374
|
+
return html.slice(0, headOpen + "<head>".length) + tag + html.slice(headOpen + "<head>".length);
|
|
375
|
+
}
|
|
376
|
+
/** Canonical MCP Apps resource metadata (stable 2026-01-26 shape). */
|
|
377
|
+
export function gridResourceMeta(previewsBaseUrl, exactFrameDomains) {
|
|
378
|
+
const { connectDomains, resourceDomains, frameDomains } = buildCspMeta(previewsBaseUrl, undefined, exactFrameDomains);
|
|
379
|
+
return {
|
|
380
|
+
ui: { csp: { connectDomains, resourceDomains, frameDomains } },
|
|
381
|
+
"openai/widgetCSP": {
|
|
382
|
+
connect_domains: connectDomains,
|
|
383
|
+
resource_domains: resourceDomains,
|
|
384
|
+
frame_domains: frameDomains,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
// ─── Default seams ───────────────────────────────────────────────────────────
|
|
389
|
+
/** Default compiler: the shared `ensureManifest` seam (compiles + persists). */
|
|
390
|
+
const defaultCompile = async (kitDir) => {
|
|
391
|
+
return ensureManifest(kitDir);
|
|
392
|
+
};
|
|
393
|
+
/**
|
|
394
|
+
* Default asset reader: prefer the viewer assets copied beside the compiled
|
|
395
|
+
* server module by `copy-viewer-assets.mjs`. This guarantees the published
|
|
396
|
+
* server's tool-result shell remains executable even when `@ambitresearch/genie-viewer` is
|
|
397
|
+
* not installed at runtime. Source/tsx development falls back to resolving the
|
|
398
|
+
* workspace viewer package without creating a build-time import edge. Results
|
|
399
|
+
* are cached per process.
|
|
400
|
+
*/
|
|
401
|
+
function makeDefaultAssetReader() {
|
|
402
|
+
const cache = new Map();
|
|
403
|
+
const require = createRequire(import.meta.url);
|
|
404
|
+
const bundledStaticDir = resolve(dirname(fileURLToPath(import.meta.url)), "viewer-static");
|
|
405
|
+
return async (name) => {
|
|
406
|
+
const cached = cache.get(name);
|
|
407
|
+
if (cached !== undefined)
|
|
408
|
+
return cached;
|
|
409
|
+
let text;
|
|
410
|
+
try {
|
|
411
|
+
text = await readFile(join(bundledStaticDir, name), "utf8");
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
const code = error.code;
|
|
415
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
416
|
+
throw error;
|
|
417
|
+
const pkgJson = require.resolve("@ambitresearch/genie-viewer/package.json");
|
|
418
|
+
const staticDir = resolve(dirname(pkgJson), "static");
|
|
419
|
+
text = await readFile(join(staticDir, name), "utf8");
|
|
420
|
+
}
|
|
421
|
+
cache.set(name, text);
|
|
422
|
+
return text;
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** Default preview reader: read `join(kitDir, relPath)`; null on any error. */
|
|
426
|
+
const defaultReadPreviewBytes = async (kitDir, relPath) => {
|
|
427
|
+
try {
|
|
428
|
+
// `relPath` originates from the compiled manifest (already inside the kit);
|
|
429
|
+
// still re-check containment defensively before reading. Uses the SAME
|
|
430
|
+
// segment-aware guard as `store/local.ts`'s `safePath` (path.relative + sep
|
|
431
|
+
// + isAbsolute) rather than a `startsWith(root + "/")` prefix test, which
|
|
432
|
+
// is wrong on Windows (`\` separator) and can false-negative a legitimate
|
|
433
|
+
// sibling dir sharing a name prefix.
|
|
434
|
+
const root = resolve(kitDir);
|
|
435
|
+
const abs = resolve(root, relPath);
|
|
436
|
+
if (!isInside(root, abs))
|
|
437
|
+
return null;
|
|
438
|
+
return await readFile(abs);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* True when `child` is `parent` itself or a descendant of it, using the
|
|
446
|
+
* repo's established segment-aware containment test (mirrors `safePath` in
|
|
447
|
+
* `store/local.ts`): the relative path escapes only when it IS `..`, starts
|
|
448
|
+
* with `..` + a path separator, or is absolute. Cross-platform (honours the
|
|
449
|
+
* OS `sep`), unlike a `startsWith(parent + "/")` prefix check.
|
|
450
|
+
*/
|
|
451
|
+
function isInside(parent, child) {
|
|
452
|
+
const rel = relative(parent, child);
|
|
453
|
+
if (rel === "")
|
|
454
|
+
return true; // child === parent
|
|
455
|
+
return rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
|
|
456
|
+
}
|
|
457
|
+
/** A last-resort, dependency-free shell used only when both the packaged assets
|
|
458
|
+
* and source-workspace fallback are unreadable (or a test injects a failing
|
|
459
|
+
* reader). Normal built deployments always carry executable assets beside this
|
|
460
|
+
* module. The emergency shell keeps `resources/read` valid and carries the SAME
|
|
461
|
+
* enforced CSP meta: corruption must not downgrade security (M4-07 AC1). */
|
|
462
|
+
function fallbackShell(manifest, cspMeta) {
|
|
463
|
+
const json = escapeJsonForScript(JSON.stringify(manifest));
|
|
464
|
+
return ('<!doctype html><html lang="en"><head>' +
|
|
465
|
+
cspMetaTag(cspMeta) +
|
|
466
|
+
'<meta charset="utf-8" />' +
|
|
467
|
+
`<title>genie — preview</title>` +
|
|
468
|
+
`<script type="application/json" id="${MANIFEST_ELEMENT_ID}">${json}</script>` +
|
|
469
|
+
'</head><body><main id="grid"></main>' +
|
|
470
|
+
"<p>genie viewer assets unavailable in this environment.</p>" +
|
|
471
|
+
"</body></html>");
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Produce the embedded grid HTML for a resource URI's query params. Resolves
|
|
475
|
+
* the kit (path-guarded), compiles + filters the manifest, rewrites card paths
|
|
476
|
+
* for the embedded tier (AC4), inlines the result into the viewer shell
|
|
477
|
+
* (AC2/AC3), and injects the enforced CSP meta (M4-07 AC1) so browsers apply
|
|
478
|
+
* the hardened policy at parse time. Every failure mode degrades to a still-
|
|
479
|
+
* valid, still-hardened document: an absent/invalid `kitId` or an
|
|
480
|
+
* uncompilable kit yields an EMPTY manifest (the viewer's empty state);
|
|
481
|
+
* unreadable viewer assets yield the {@link fallbackShell} (also CSP-injected).
|
|
482
|
+
*/
|
|
483
|
+
export async function buildGridDocument(deps, params) {
|
|
484
|
+
const kitDir = deps.denyRequestedKit ? null : resolveKitDir(deps.kitsRoot, params.kitId);
|
|
485
|
+
const scriptHashes = new Set();
|
|
486
|
+
const styleHashes = new Set();
|
|
487
|
+
const addHashes = (html) => {
|
|
488
|
+
const hashes = collectInlineCspHashes(html);
|
|
489
|
+
for (const hash of hashes.scriptHashes)
|
|
490
|
+
scriptHashes.add(hash);
|
|
491
|
+
for (const hash of hashes.styleHashes)
|
|
492
|
+
styleHashes.add(hash);
|
|
493
|
+
};
|
|
494
|
+
const currentHashes = () => ({
|
|
495
|
+
scriptHashes: [...scriptHashes],
|
|
496
|
+
styleHashes: [...styleHashes],
|
|
497
|
+
});
|
|
498
|
+
let manifest = emptyManifest();
|
|
499
|
+
if (kitDir !== null) {
|
|
500
|
+
try {
|
|
501
|
+
manifest = await deps.compile(kitDir);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
manifest = emptyManifest(); // uncompiled/missing kit → empty grid, not an error
|
|
505
|
+
}
|
|
506
|
+
manifest = filterManifest(manifest, {
|
|
507
|
+
componentName: params.componentName,
|
|
508
|
+
group: params.group,
|
|
509
|
+
});
|
|
510
|
+
manifest = await rewriteCardPaths(manifest, {
|
|
511
|
+
kitId: params.kitId, // non-null: resolveKitDir only returns a dir for a valid id
|
|
512
|
+
kitDir,
|
|
513
|
+
previewsBaseUrl: deps.previewsBaseUrl,
|
|
514
|
+
readPreviewBytes: deps.readPreviewBytes,
|
|
515
|
+
cardAssetKit: deps.cardAssetKit,
|
|
516
|
+
onPreviewHtml: addHashes,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
let indexHtml;
|
|
520
|
+
let viewerJs;
|
|
521
|
+
let viewerCss;
|
|
522
|
+
try {
|
|
523
|
+
[indexHtml, viewerJs, viewerCss] = await Promise.all([
|
|
524
|
+
deps.readAsset("index.html"),
|
|
525
|
+
deps.readAsset("viewer.js"),
|
|
526
|
+
deps.readAsset("viewer.css"),
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
const fallback = fallbackShell(manifest, buildCspMeta(deps.previewsBaseUrl, currentHashes(), deps.exactFrameDomains));
|
|
531
|
+
return params.kitId === undefined ? markToolResultShell(fallback) : fallback;
|
|
532
|
+
}
|
|
533
|
+
const inlinedAssets = inlineViewerAssets(indexHtml, viewerJs, viewerCss);
|
|
534
|
+
for (const hash of inlinedAssets.hashes.scriptHashes)
|
|
535
|
+
scriptHashes.add(hash);
|
|
536
|
+
for (const hash of inlinedAssets.hashes.styleHashes)
|
|
537
|
+
styleHashes.add(hash);
|
|
538
|
+
const cspMeta = buildCspMeta(deps.previewsBaseUrl, currentHashes(), deps.exactFrameDomains);
|
|
539
|
+
const document = inlineManifest(inlinedAssets.html, manifest);
|
|
540
|
+
return injectCspMeta(params.kitId === undefined ? markToolResultShell(document) : document, cspMeta);
|
|
541
|
+
}
|
|
542
|
+
function markToolResultShell(html) {
|
|
543
|
+
const marker = `<meta name="${TOOL_RESULT_SHELL_META}" content="true">`;
|
|
544
|
+
const headOpen = html.indexOf("<head>");
|
|
545
|
+
if (headOpen === -1)
|
|
546
|
+
return marker + html;
|
|
547
|
+
return (html.slice(0, headOpen + "<head>".length) + marker + html.slice(headOpen + "<head>".length));
|
|
548
|
+
}
|
|
549
|
+
/** An empty-but-valid manifest (viewer renders its empty state). */
|
|
550
|
+
function emptyManifest() {
|
|
551
|
+
return { version: 1, name: "", generatedAt: "", groups: [], components: [] };
|
|
552
|
+
}
|
|
553
|
+
// ─── MCP registration (AC1 + AC3) ────────────────────────────────────────────
|
|
554
|
+
/** Parse the three recognised query params off a resolved resource `URL`. */
|
|
555
|
+
function paramsFromUri(uri) {
|
|
556
|
+
const get = (k) => uri.searchParams.get(k) ?? undefined;
|
|
557
|
+
return { kitId: get("kitId"), componentName: get("componentName"), group: get("group") };
|
|
558
|
+
}
|
|
559
|
+
async function registerRequestedCardAssetKit(broker, kitsRoot, kitId) {
|
|
560
|
+
const kitDir = resolveKitDir(kitsRoot, kitId);
|
|
561
|
+
if (kitDir === null)
|
|
562
|
+
return undefined;
|
|
563
|
+
let info;
|
|
564
|
+
try {
|
|
565
|
+
info = await lstat(kitDir);
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
if (error instanceof Error &&
|
|
569
|
+
"code" in error &&
|
|
570
|
+
(error.code === "ENOENT" ||
|
|
571
|
+
error.code === "ENOTDIR")) {
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
if (!info.isDirectory())
|
|
577
|
+
return undefined;
|
|
578
|
+
return await broker.registerKit(kitId, kitDir);
|
|
579
|
+
}
|
|
580
|
+
async function prepareCardBroker(deps, params) {
|
|
581
|
+
if (deps.getCardAssetBroker === undefined ||
|
|
582
|
+
normalizePreviewsBaseUrl(deps.previewsBaseUrl) !== undefined) {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
let broker;
|
|
586
|
+
try {
|
|
587
|
+
broker = await deps.getCardAssetBroker();
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return {
|
|
591
|
+
exactFrameDomains: [],
|
|
592
|
+
denyRequestedKit: params.kitId !== undefined,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
if (params.kitId === undefined) {
|
|
596
|
+
return { exactFrameDomains: broker.frameOrigins() };
|
|
597
|
+
}
|
|
598
|
+
if (!KIT_ID_PATTERN.test(params.kitId)) {
|
|
599
|
+
return { exactFrameDomains: broker.frameOrigins() };
|
|
600
|
+
}
|
|
601
|
+
const cardAssetKit = await registerRequestedCardAssetKit(broker, deps.kitsRoot, params.kitId);
|
|
602
|
+
if (cardAssetKit === undefined) {
|
|
603
|
+
return {
|
|
604
|
+
exactFrameDomains: broker.frameOrigins(),
|
|
605
|
+
denyRequestedKit: true,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return { cardAssetKit, exactFrameDomains: broker.frameOrigins() };
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Register `ui://genie/grid` and its sibling assets on `server`.
|
|
612
|
+
*
|
|
613
|
+
* Routing (verified against the installed SDK): the SDK matches a static
|
|
614
|
+
* resource by EXACT `uri.toString()` and a template via `UriTemplate.match`.
|
|
615
|
+
* `preview` emits `ui://genie/grid?kitId=…` with optional extra params, so a
|
|
616
|
+
* single static registration (no query) or a rigid `{?kitId,…}` template (all-
|
|
617
|
+
* or-nothing) would miss most real URIs. We therefore register BOTH a static
|
|
618
|
+
* `ui://genie/grid` (the bare URI) and a `{+rest}` catch-all template that
|
|
619
|
+
* matches any query-bearing URI; both dispatch to the same handler, which reads
|
|
620
|
+
* its params off the full `URL`. Legacy sibling assets remain plain statics.
|
|
621
|
+
*/
|
|
622
|
+
export function registerGridResource(server, options) {
|
|
623
|
+
const deps = {
|
|
624
|
+
kitsRoot: options.kitsRoot,
|
|
625
|
+
compile: options.compile ?? defaultCompile,
|
|
626
|
+
readAsset: options.readAsset ?? makeDefaultAssetReader(),
|
|
627
|
+
readPreviewBytes: options.readPreviewBytes ?? defaultReadPreviewBytes,
|
|
628
|
+
previewsBaseUrl: options.previewsBaseUrl ?? process.env.GENIE_PREVIEWS_BASE_URL,
|
|
629
|
+
getCardAssetBroker: options.getCardAssetBroker,
|
|
630
|
+
};
|
|
631
|
+
const hasLocalCardBroker = deps.getCardAssetBroker !== undefined &&
|
|
632
|
+
normalizePreviewsBaseUrl(deps.previewsBaseUrl) === undefined;
|
|
633
|
+
// Resource-list metadata is necessarily static. A broker is lazy, so its
|
|
634
|
+
// concrete origins are attached to resources/read after discovery; publish
|
|
635
|
+
// an empty (deny-all) exact list here rather than a wildcard or `data:`.
|
|
636
|
+
const registrationMeta = gridResourceMeta(deps.previewsBaseUrl, hasLocalCardBroker ? [] : undefined);
|
|
637
|
+
const baseGridConfig = {
|
|
638
|
+
title: "genie preview grid",
|
|
639
|
+
description: "Embedded MCP-Apps preview of a genie UI kit — a card grid rendered inside " +
|
|
640
|
+
"the host's sandboxed iframe. Referenced by the `preview` tool's " +
|
|
641
|
+
"_meta.ui.resourceUri.",
|
|
642
|
+
mimeType: GRID_RESOURCE_MIME,
|
|
643
|
+
};
|
|
644
|
+
const readGrid = async (uri) => {
|
|
645
|
+
const params = paramsFromUri(uri);
|
|
646
|
+
const prepared = await prepareCardBroker(deps, params);
|
|
647
|
+
const readDeps = prepared === undefined
|
|
648
|
+
? deps
|
|
649
|
+
: {
|
|
650
|
+
...deps,
|
|
651
|
+
cardAssetKit: prepared.cardAssetKit,
|
|
652
|
+
exactFrameDomains: prepared.exactFrameDomains,
|
|
653
|
+
denyRequestedKit: prepared.denyRequestedKit,
|
|
654
|
+
};
|
|
655
|
+
const meta = gridResourceMeta(deps.previewsBaseUrl, prepared?.exactFrameDomains);
|
|
656
|
+
const html = await buildGridDocument(readDeps, params);
|
|
657
|
+
return {
|
|
658
|
+
contents: [{ uri: uri.toString(), mimeType: GRID_RESOURCE_MIME, text: html, _meta: meta }],
|
|
659
|
+
};
|
|
660
|
+
};
|
|
661
|
+
// Bare URI (exact match) — e.g. a host that reads `ui://genie/grid` directly.
|
|
662
|
+
server.registerResource("genie-grid", GRID_RESOURCE_URI, { ...baseGridConfig, _meta: registrationMeta }, (uri) => readGrid(uri));
|
|
663
|
+
// Query-bearing URIs (`?kitId=…[&componentName=…][&group=…]`) via a catch-all
|
|
664
|
+
// template. `list: undefined` — the bare static above is what `resources/list`
|
|
665
|
+
// advertises; the template is a routing device, not a discoverable resource.
|
|
666
|
+
const template = new ResourceTemplate(`${GRID_RESOURCE_URI}{+rest}`, { list: undefined });
|
|
667
|
+
server.registerResource("genie-grid-query", template, { ...baseGridConfig, _meta: registrationMeta }, (uri) => readGrid(uri));
|
|
668
|
+
// Compatibility for older experimental hosts. The standard MCP Apps payload
|
|
669
|
+
// is self-contained and does not reference these sibling resources.
|
|
670
|
+
registerAsset(server, "genie-viewer-js", VIEWER_JS_URI, "text/javascript", () => deps.readAsset("viewer.js"));
|
|
671
|
+
registerAsset(server, "genie-viewer-css", VIEWER_CSS_URI, "text/css", () => deps.readAsset("viewer.css"));
|
|
672
|
+
}
|
|
673
|
+
/** Register one static text asset resource (viewer.js / viewer.css). */
|
|
674
|
+
function registerAsset(server, name, uri, mimeType, read) {
|
|
675
|
+
server.registerResource(name, uri, { title: name, mimeType }, async (u) => {
|
|
676
|
+
const text = await read();
|
|
677
|
+
return { contents: [{ uri: u.toString(), mimeType, text }] };
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
//# sourceMappingURL=grid-resource.js.map
|