@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,672 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool: preview (M4-05 / DRO-267).
|
|
3
|
+
*
|
|
4
|
+
* Returns a human-readable summary — the live viewer URL plus a local-only
|
|
5
|
+
* `file://` fallback — as `content`. The tool advertises the static
|
|
6
|
+
* `ui://genie/grid` app shell in tools/list for standards-compliant discovery;
|
|
7
|
+
* the host then delivers this call's input/result to that shell. The result
|
|
8
|
+
* also retains its query-bearing resource URI for legacy/result-level hosts.
|
|
9
|
+
*
|
|
10
|
+
* ── Contract (RFC §6.2.3 / §8.3; this issue's AC1–AC7) ───────────────────────
|
|
11
|
+
* input : { kitId, componentName?, group? } (AC2)
|
|
12
|
+
* output: { content: [{ type:"text", text }],
|
|
13
|
+
* _meta: { ui: { resourceUri: "ui://genie/grid?…" } } } (AC3/AC4)
|
|
14
|
+
* The `ui://genie/grid` RESOURCE itself is registered by M4-06. The static
|
|
15
|
+
* tools/list association and query-bearing result URI are emitted
|
|
16
|
+
* unconditionally; a host without MCP Apps support simply renders the text.
|
|
17
|
+
*
|
|
18
|
+
* ── Viewer boot seam (AC5/AC6) ───────────────────────────────────────────────
|
|
19
|
+
* Booting the Vite dev server is behind an injectable `ViewerBooter` so the
|
|
20
|
+
* tool has NO hard dependency on `@ambitresearch/genie-viewer` or Vite — the server core
|
|
21
|
+
* stays independent of the preview framework (CLAUDE.md; RFC §4). The default
|
|
22
|
+
* booter lazily `import()`s `@ambitresearch/genie-viewer` through a NON-LITERAL specifier
|
|
23
|
+
* (so `tsc` never hard-resolves it) and degrades gracefully when it is absent —
|
|
24
|
+
* exactly the OPTIONAL-peer-dependency pattern `refine` uses for Playwright.
|
|
25
|
+
* A failed boot (Vite missing, port unbindable, kit dir invalid) is caught and
|
|
26
|
+
* turned into AC6's local `file://<kitDir>/index.html` fallback; MCP Apps can
|
|
27
|
+
* instead receive a CSP-safe embedded manifest when a previews origin is
|
|
28
|
+
* configured. `_meta` is still emitted. A `ViewerRegistry` caches one running
|
|
29
|
+
* viewer per kit dir (AC5).
|
|
30
|
+
*/
|
|
31
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { pathToFileURL } from "node:url";
|
|
34
|
+
import { z } from "zod";
|
|
35
|
+
import { ensureManifest } from "../manifest/index.js";
|
|
36
|
+
import { getServerTransportKind } from "../transport.js";
|
|
37
|
+
import { filterManifest, GRID_RESOURCE_URI, normalizePreviewsBaseUrl, rewriteCardPaths, } from "../ui/grid-resource.js";
|
|
38
|
+
import { KIT_ID_PATTERN } from "./get_kit.js";
|
|
39
|
+
export const PREVIEW_TOOL_NAME = "mcp__genie__preview";
|
|
40
|
+
/**
|
|
41
|
+
* Vite dev-server default port. Duplicated as a local literal (rather than
|
|
42
|
+
* imported from `@ambitresearch/genie-viewer`) so this module carries no build-time edge to
|
|
43
|
+
* the viewer package — the whole point of the lazy boot seam below. Kept in
|
|
44
|
+
* sync with `@ambitresearch/genie-viewer`'s `DEFAULT_VIEWER_PORT` (RFC §6.9/§14).
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_VIEWER_PORT = 5173;
|
|
47
|
+
/**
|
|
48
|
+
* The MCP Apps extension identifier (ext-apps spec 2026-01-26, "Client<>Server
|
|
49
|
+
* Capability Negotiation"): a host that renders `ui://` app resources
|
|
50
|
+
* advertises `capabilities.extensions["io.modelcontextprotocol/ui"]` in its
|
|
51
|
+
* `initialize` request, with `mimeTypes` naming the profiles it can render.
|
|
52
|
+
*/
|
|
53
|
+
export const UI_EXTENSION_ID = "io.modelcontextprotocol/ui";
|
|
54
|
+
/** The MCP-Apps HTML profile the grid resource serves (spec §Resource Discovery). */
|
|
55
|
+
export const MCP_APP_MIME = "text/html;profile=mcp-app";
|
|
56
|
+
/** Widget-only tool-result metadata carrying CSP-authorized card routes. */
|
|
57
|
+
export const PREVIEW_EMBEDDED_MANIFEST_META_KEY = "genie/embeddedManifest";
|
|
58
|
+
/**
|
|
59
|
+
* Authoritative `ui://` capability check: true when the client's `initialize`
|
|
60
|
+
* capabilities carry the MCP Apps extension with a `mimeTypes` array that
|
|
61
|
+
* includes {@link MCP_APP_MIME}. This is the spec-defined negotiation. `caps`
|
|
62
|
+
* is typed `unknown` because the SDK's `ClientCapabilities` models
|
|
63
|
+
* `extensions` as a passthrough bag — every level is checked defensively.
|
|
64
|
+
*/
|
|
65
|
+
export function hasUiExtensionCapability(caps) {
|
|
66
|
+
return getUiExtensionCapability(caps) === true;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve MCP Apps negotiation without collapsing "not advertised" and
|
|
70
|
+
* "explicitly not supported." A missing `extensions` mechanism returns
|
|
71
|
+
* `undefined`. This is distinct from an explicit negative because metadata-linked
|
|
72
|
+
* MCP Apps hosts may omit the extension capability even when they advertise
|
|
73
|
+
* unrelated extensions. Only a present UI extension with a malformed or
|
|
74
|
+
* unsupported MIME declaration is a negotiated negative.
|
|
75
|
+
*/
|
|
76
|
+
export function getUiExtensionCapability(caps) {
|
|
77
|
+
if (typeof caps !== "object" || caps === null)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(caps, "extensions"))
|
|
80
|
+
return undefined;
|
|
81
|
+
const extensions = caps.extensions;
|
|
82
|
+
if (typeof extensions !== "object" || extensions === null)
|
|
83
|
+
return false;
|
|
84
|
+
if (!Object.prototype.hasOwnProperty.call(extensions, UI_EXTENSION_ID))
|
|
85
|
+
return undefined;
|
|
86
|
+
const ext = extensions[UI_EXTENSION_ID];
|
|
87
|
+
if (typeof ext !== "object" || ext === null)
|
|
88
|
+
return false;
|
|
89
|
+
const mimeTypes = ext.mimeTypes;
|
|
90
|
+
return Array.isArray(mimeTypes) && mimeTypes.includes(MCP_APP_MIME);
|
|
91
|
+
}
|
|
92
|
+
// ─── Structured stderr log (never stdout — it IS the stdio JSON-RPC stream) ───
|
|
93
|
+
/**
|
|
94
|
+
* Emit one structured audit/telemetry line to stderr. On the stdio transport
|
|
95
|
+
* stdout carries the JSON-RPC frames, so any diagnostic MUST go to stderr —
|
|
96
|
+
* the same convention `plan-guard`, `plan`, and the LLM verbs follow.
|
|
97
|
+
*/
|
|
98
|
+
function logStderr(payload) {
|
|
99
|
+
process.stderr.write(JSON.stringify(payload) + "\n");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the `ui://genie/grid?…` resource URI (AC3/AC4). The query string always
|
|
103
|
+
* carries `kitId` and appends the optional `componentName` / `group` filters in
|
|
104
|
+
* a STABLE order (kitId → componentName → group) so the URI — and thus the
|
|
105
|
+
* resource M4-06 resolves and any cache keyed on it — is deterministic for a
|
|
106
|
+
* given argument set. Values are percent-encoded via `URLSearchParams`.
|
|
107
|
+
*/
|
|
108
|
+
export function buildResourceUri(params) {
|
|
109
|
+
const uri = new URL(GRID_RESOURCE_URI);
|
|
110
|
+
uri.searchParams.set("kitId", params.kitId);
|
|
111
|
+
if (params.componentName !== undefined)
|
|
112
|
+
uri.searchParams.set("componentName", params.componentName);
|
|
113
|
+
if (params.group !== undefined)
|
|
114
|
+
uri.searchParams.set("group", params.group);
|
|
115
|
+
return uri.toString();
|
|
116
|
+
}
|
|
117
|
+
// ─── kitId → kit dir (path safety) ───────────────────────────────────────────
|
|
118
|
+
/** A `kitId` that fails the shared `KIT_ID_PATTERN` shape (traversal guard). */
|
|
119
|
+
export class InvalidKitIdError extends Error {
|
|
120
|
+
kitId;
|
|
121
|
+
code = "InvalidKitIdError";
|
|
122
|
+
constructor(kitId) {
|
|
123
|
+
super(`Invalid kitId "${kitId}": must be a 3-64 char lowercase slug ([a-z0-9-]). ` +
|
|
124
|
+
"This guards against path traversal into the kits root.");
|
|
125
|
+
this.kitId = kitId;
|
|
126
|
+
this.name = "InvalidKitIdError";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** A well-formed kitId that does not identify an existing kit directory. */
|
|
130
|
+
export class KitNotFoundError extends Error {
|
|
131
|
+
kitId;
|
|
132
|
+
code = "KitNotFoundError";
|
|
133
|
+
constructor(kitId) {
|
|
134
|
+
super(`Kit "${kitId}" does not exist. Call list_kits or create_kit first.`);
|
|
135
|
+
this.kitId = kitId;
|
|
136
|
+
this.name = "KitNotFoundError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a `kitId` to its on-disk kit directory under `kitsRoot`, rejecting
|
|
141
|
+
* any id that isn't a well-formed slug BEFORE the `join` — an unvalidated id
|
|
142
|
+
* like `../../etc` would otherwise escape the kits root (RFC §10 T-13, the same
|
|
143
|
+
* traversal class `write_files`/`read_file` guard). Only shape is checked here;
|
|
144
|
+
* Existence is checked by {@link runPreview} before compilation so a missing
|
|
145
|
+
* slug cannot create a phantom kit through manifest persistence.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveKitDir(kitsRoot, kitId) {
|
|
148
|
+
if (!KIT_ID_PATTERN.test(kitId)) {
|
|
149
|
+
throw new InvalidKitIdError(kitId);
|
|
150
|
+
}
|
|
151
|
+
return join(kitsRoot, kitId);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Caches one running viewer per kit directory so repeated `preview` calls reuse
|
|
155
|
+
* it instead of booting a fresh Vite each time (AC5). Keyed on the resolved kit
|
|
156
|
+
* dir; concurrent calls for the same dir share ONE in-flight boot (the promise
|
|
157
|
+
* is cached, not just the resolved value). A boot that REJECTS is evicted so a
|
|
158
|
+
* later call retries rather than replaying the dead promise forever.
|
|
159
|
+
*/
|
|
160
|
+
export class ViewerRegistry {
|
|
161
|
+
booter;
|
|
162
|
+
viewers = new Map();
|
|
163
|
+
constructor(booter) {
|
|
164
|
+
this.booter = booter;
|
|
165
|
+
}
|
|
166
|
+
queueBrowserOpen(entry, kitDir, target) {
|
|
167
|
+
const previous = entry.browserOpen?.promise ?? Promise.resolve();
|
|
168
|
+
const promise = Promise.all([entry.viewer, previous]).then(async ([viewer]) => {
|
|
169
|
+
try {
|
|
170
|
+
await viewer.open(target ?? undefined);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
logStderr({
|
|
174
|
+
event: "preview.browser.open-failed",
|
|
175
|
+
kitDir,
|
|
176
|
+
...(target !== null ? { target } : {}),
|
|
177
|
+
error: String(error),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
entry.browserOpen = { target, promise };
|
|
182
|
+
return promise;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Return the viewer for `kitDir`, booting it on first request. Subsequent
|
|
186
|
+
* calls for the same dir return the cached (in-flight or resolved) promise.
|
|
187
|
+
*
|
|
188
|
+
* Booting and browser opening are tracked separately. Direct callers can use
|
|
189
|
+
* `open:true` for the base URL, while {@link runPreview}'s local path boots or
|
|
190
|
+
* reuses headlessly and calls {@link ViewerRegistry.open} after constructing
|
|
191
|
+
* its filtered target. Repeated requests for the same target share one
|
|
192
|
+
* promise; a changed filter target is opened again so the requested component
|
|
193
|
+
* is not hidden behind an older tab.
|
|
194
|
+
*/
|
|
195
|
+
ensure(kitDir, port = DEFAULT_VIEWER_PORT, open = false) {
|
|
196
|
+
const existing = this.viewers.get(kitDir);
|
|
197
|
+
if (existing !== undefined) {
|
|
198
|
+
if (open) {
|
|
199
|
+
const browserOpen = existing.browserOpen?.target === null
|
|
200
|
+
? existing.browserOpen.promise
|
|
201
|
+
: this.queueBrowserOpen(existing, kitDir, null);
|
|
202
|
+
return Promise.all([existing.viewer, browserOpen]).then(([viewer]) => viewer);
|
|
203
|
+
}
|
|
204
|
+
return existing.viewer;
|
|
205
|
+
}
|
|
206
|
+
const booting = this.booter({ kitDir, port, open }).catch((error) => {
|
|
207
|
+
// Evict the failed boot so the next call retries with a fresh attempt
|
|
208
|
+
// rather than being handed this same rejected promise (AC5 durability).
|
|
209
|
+
this.viewers.delete(kitDir);
|
|
210
|
+
throw error;
|
|
211
|
+
});
|
|
212
|
+
const entry = { viewer: booting };
|
|
213
|
+
if (open) {
|
|
214
|
+
entry.browserOpen = { target: null, promise: booting.then(() => undefined) };
|
|
215
|
+
}
|
|
216
|
+
this.viewers.set(kitDir, entry);
|
|
217
|
+
return booting;
|
|
218
|
+
}
|
|
219
|
+
async open(kitDir, target) {
|
|
220
|
+
const entry = this.viewers.get(kitDir);
|
|
221
|
+
if (entry === undefined) {
|
|
222
|
+
throw new Error(`Viewer for "${kitDir}" must be booted before it can be opened.`);
|
|
223
|
+
}
|
|
224
|
+
const browserOpen = entry.browserOpen?.target === target
|
|
225
|
+
? entry.browserOpen.promise
|
|
226
|
+
: this.queueBrowserOpen(entry, kitDir, target);
|
|
227
|
+
await browserOpen;
|
|
228
|
+
}
|
|
229
|
+
/** Tear down every booted viewer (best-effort). For clean shutdown/tests. */
|
|
230
|
+
async closeAll() {
|
|
231
|
+
const entries = Array.from(this.viewers.values());
|
|
232
|
+
this.viewers.clear();
|
|
233
|
+
await Promise.allSettled(entries.map(async ({ viewer: viewerPromise, browserOpen }) => {
|
|
234
|
+
const viewer = await viewerPromise;
|
|
235
|
+
try {
|
|
236
|
+
await browserOpen?.promise;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await viewer.close();
|
|
240
|
+
}
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The default `ViewerBooter`. Lazily loads `@ambitresearch/genie-viewer` and boots its Vite
|
|
246
|
+
* dev server against the kit dir and honors {@link BootRequest.open} for direct
|
|
247
|
+
* callers. On its local path, {@link runPreview} deliberately passes `false`,
|
|
248
|
+
* constructs the filter-bearing viewer URL, and invokes
|
|
249
|
+
* {@link ViewerRegistry.open} separately so browser fallback never opens the
|
|
250
|
+
* unfiltered base URL. Absent → `false` (never opens), preserving the safe
|
|
251
|
+
* headless default.
|
|
252
|
+
* ALL of the viewer's own stdout is routed to STDERR — on the stdio transport
|
|
253
|
+
* the tool's stdout is the JSON-RPC stream, so the viewer's `Preview:` banner
|
|
254
|
+
* would corrupt framing if left on stdout.
|
|
255
|
+
*/
|
|
256
|
+
export const defaultViewerBooter = async ({ kitDir, port, open }) => {
|
|
257
|
+
// Non-literal specifier: keeps `tsc` from resolving the optional dep at build.
|
|
258
|
+
const specifier = "@ambitresearch/genie-viewer";
|
|
259
|
+
let mod;
|
|
260
|
+
try {
|
|
261
|
+
mod = (await import(specifier));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
logStderr({
|
|
265
|
+
event: "preview.viewer.unavailable",
|
|
266
|
+
reason: "viewer-package-not-installed",
|
|
267
|
+
error: String(error),
|
|
268
|
+
});
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
// Route the viewer's stdout to stderr; keep its stderr on stderr.
|
|
272
|
+
const io = {
|
|
273
|
+
stdout: (chunk) => process.stderr.write(chunk),
|
|
274
|
+
stderr: (chunk) => process.stderr.write(chunk),
|
|
275
|
+
};
|
|
276
|
+
const handle = await mod.bootViewer({ root: kitDir, port: port ?? DEFAULT_VIEWER_PORT, open: open ?? false }, io);
|
|
277
|
+
return {
|
|
278
|
+
url: handle.url,
|
|
279
|
+
port: handle.port,
|
|
280
|
+
open: async (target) => {
|
|
281
|
+
if (handle.open === undefined) {
|
|
282
|
+
logStderr({
|
|
283
|
+
event: "preview.browser.open-unavailable",
|
|
284
|
+
reason: "viewer-handle-does-not-support-open",
|
|
285
|
+
url: handle.url,
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await handle.open(target);
|
|
290
|
+
},
|
|
291
|
+
close: () => handle.close(),
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
/** Point filtered cards at one broker-owned kit origin without changing bytes. */
|
|
295
|
+
function rewriteCardPathsForBroker(manifest, registeredKit) {
|
|
296
|
+
return {
|
|
297
|
+
...manifest,
|
|
298
|
+
components: manifest.components.map((card) => ({
|
|
299
|
+
...card,
|
|
300
|
+
sourcePath: card.path,
|
|
301
|
+
path: registeredKit.urlFor(card.path),
|
|
302
|
+
})),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* True when the operator has opted OUT of preview's server-side browser
|
|
307
|
+
* auto-open by setting `GENIE_PREVIEW_NO_OPEN` to a truthy value (design
|
|
308
|
+
* 2026-07-05, piece B — auto-open is opt-OUT, on by default for non-`ui://`
|
|
309
|
+
* hosts). Any non-empty value other than `0`/`false` (case-insensitive) counts
|
|
310
|
+
* as set, so `GENIE_PREVIEW_NO_OPEN=1`, `=true`, `=yes` all disable it.
|
|
311
|
+
*/
|
|
312
|
+
export function autoOpenDisabledByEnv(env = process.env) {
|
|
313
|
+
const raw = env.GENIE_PREVIEW_NO_OPEN;
|
|
314
|
+
if (raw === undefined)
|
|
315
|
+
return false;
|
|
316
|
+
const v = raw.trim().toLowerCase();
|
|
317
|
+
return v !== "" && v !== "0" && v !== "false";
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Decide whether {@link runPreview} should ask the booter to open a browser.
|
|
321
|
+
* Only an explicit negotiated negative authorizes auto-open. An omitted
|
|
322
|
+
* capability is ambiguous (Codex Desktop and tools-only Codex currently look
|
|
323
|
+
* identical to a custom MCP server), so it receives a viewer URL but never a
|
|
324
|
+
* surprise system-browser launch.
|
|
325
|
+
*/
|
|
326
|
+
export function shouldAutoOpen(uiCapable, env = process.env, transportKind) {
|
|
327
|
+
if (uiCapable !== false)
|
|
328
|
+
return false;
|
|
329
|
+
if (transportKind !== "stdio")
|
|
330
|
+
return false;
|
|
331
|
+
return !autoOpenDisabledByEnv(env);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Core `preview` logic (exported for direct unit testing without the MCP
|
|
335
|
+
* transport). Compiles + persists the kit's manifest (piece A) so all three
|
|
336
|
+
* vehicles render real cards, resolves the kit dir, tries to boot/reuse the
|
|
337
|
+
* viewer — opening a browser server-side for non-`ui://` hosts (piece B) — and
|
|
338
|
+
* builds the text summary: the live URL when the viewer is up (AC5), else a
|
|
339
|
+
* `file://` fallback (AC6). `_meta.ui.resourceUri` is emitted either way
|
|
340
|
+
* (AC3/AC4), and one `preview.request` line is logged recording the client and
|
|
341
|
+
* its `ui://` support (AC7). Remote results never expose server-local file
|
|
342
|
+
* paths; a failed local viewer can deliver a CSP-safe manifest to an MCP App
|
|
343
|
+
* when a previews origin is configured.
|
|
344
|
+
*/
|
|
345
|
+
export async function runPreview(deps, args, ctx) {
|
|
346
|
+
// Path-safety BEFORE anything touches the filesystem or boots a server.
|
|
347
|
+
const kitDir = resolveKitDir(deps.kitsRoot, args.kitId);
|
|
348
|
+
let kitStat;
|
|
349
|
+
try {
|
|
350
|
+
kitStat = await lstat(kitDir);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
const code = error.code;
|
|
354
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
355
|
+
throw new KitNotFoundError(args.kitId);
|
|
356
|
+
}
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
if (!kitStat.isDirectory()) {
|
|
360
|
+
throw new KitNotFoundError(args.kitId);
|
|
361
|
+
}
|
|
362
|
+
// Piece A — compile + persist `.genie/manifest.json` at request time so the
|
|
363
|
+
// Vite / file:// / ui:// vehicles all render real cards (and list_components
|
|
364
|
+
// sees them) without depending on a prior resource read. An existing empty
|
|
365
|
+
// kit compiles to an empty manifest; genuine read/write failures propagate.
|
|
366
|
+
const compile = deps.ensureManifest ?? ensureManifest;
|
|
367
|
+
const manifest = await compile(kitDir);
|
|
368
|
+
const filteredManifest = filterManifest(manifest, {
|
|
369
|
+
componentName: args.componentName,
|
|
370
|
+
group: args.group,
|
|
371
|
+
});
|
|
372
|
+
const env = deps.env ?? process.env;
|
|
373
|
+
const locality = ctx.locality ?? (ctx.transportKind === "stdio" ? "local" : "remote");
|
|
374
|
+
const previewsBaseUrl = deps.previewsBaseUrl ?? env.GENIE_PREVIEWS_BASE_URL;
|
|
375
|
+
const normalizedPreviewsBaseUrl = normalizePreviewsBaseUrl(previewsBaseUrl);
|
|
376
|
+
const canDeliverEmbeddedManifest = normalizedPreviewsBaseUrl !== undefined || filteredManifest.components.length === 0;
|
|
377
|
+
// Keep the handshake tri-state. Explicit support is inline-only, explicit
|
|
378
|
+
// rejection is viewer-only, and omission gets BOTH so metadata-linked hosts
|
|
379
|
+
// can mount the canvas while tools-only hosts still have a usable URL.
|
|
380
|
+
const prepareInline = ctx.uiCapable !== false;
|
|
381
|
+
const prepareViewerFallback = ctx.uiCapable !== true;
|
|
382
|
+
const getCardAssetBroker = deps.getCardAssetBroker;
|
|
383
|
+
const useLocalCardBroker = locality === "local" &&
|
|
384
|
+
ctx.transportKind === "stdio" &&
|
|
385
|
+
prepareInline &&
|
|
386
|
+
normalizedPreviewsBaseUrl === undefined &&
|
|
387
|
+
getCardAssetBroker !== undefined;
|
|
388
|
+
const useConfiguredPreviewsOrigin = locality === "local" && prepareInline && normalizedPreviewsBaseUrl !== undefined;
|
|
389
|
+
const readPreviewBytes = deps.readPreviewBytes ??
|
|
390
|
+
(async (root, path) => {
|
|
391
|
+
try {
|
|
392
|
+
return await readFile(join(root, path));
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
let embeddedManifest;
|
|
399
|
+
let embeddedError;
|
|
400
|
+
if (locality === "remote") {
|
|
401
|
+
if (!prepareInline) {
|
|
402
|
+
embeddedError =
|
|
403
|
+
"Client does not support MCP Apps; remote-locality previews have no server-side browser fallback.";
|
|
404
|
+
}
|
|
405
|
+
else if (canDeliverEmbeddedManifest) {
|
|
406
|
+
embeddedManifest = await rewriteCardPaths(filteredManifest, {
|
|
407
|
+
kitId: args.kitId,
|
|
408
|
+
kitDir,
|
|
409
|
+
previewsBaseUrl: normalizedPreviewsBaseUrl?.toString(),
|
|
410
|
+
readPreviewBytes,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
embeddedError =
|
|
415
|
+
previewsBaseUrl !== undefined && previewsBaseUrl !== ""
|
|
416
|
+
? "Invalid GENIE_PREVIEWS_BASE_URL; remote embedded previews require an absolute http(s) origin."
|
|
417
|
+
: "Remote embedded previews require GENIE_PREVIEWS_BASE_URL; " +
|
|
418
|
+
"the query-bearing resource remains available to result-level hosts.";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const resourceUri = buildResourceUri({
|
|
422
|
+
kitId: args.kitId,
|
|
423
|
+
componentName: args.componentName,
|
|
424
|
+
group: args.group,
|
|
425
|
+
});
|
|
426
|
+
const autoOpen = locality === "local" && shouldAutoOpen(ctx.uiCapable, deps.env, ctx.transportKind);
|
|
427
|
+
// AC7 — one structured line per request; the sniffed client + its ui:// support.
|
|
428
|
+
logStderr({
|
|
429
|
+
event: "preview.request",
|
|
430
|
+
kitId: args.kitId,
|
|
431
|
+
client: ctx.clientName ?? null,
|
|
432
|
+
uiCapable: ctx.uiCapable ?? null,
|
|
433
|
+
uiCapability: ctx.uiCapable === true ? "supported" : ctx.uiCapable === false ? "unsupported" : "unknown",
|
|
434
|
+
inlineDelivery: prepareInline,
|
|
435
|
+
viewerFallback: prepareViewerFallback,
|
|
436
|
+
localCardBroker: useLocalCardBroker,
|
|
437
|
+
autoOpen,
|
|
438
|
+
transport: ctx.transportKind ?? null,
|
|
439
|
+
...(args.group !== undefined ? { group: args.group } : {}),
|
|
440
|
+
...(args.componentName !== undefined ? { componentName: args.componentName } : {}),
|
|
441
|
+
});
|
|
442
|
+
let text;
|
|
443
|
+
let viewerUrl;
|
|
444
|
+
let fileUrl;
|
|
445
|
+
if (locality === "remote") {
|
|
446
|
+
text =
|
|
447
|
+
embeddedManifest !== undefined
|
|
448
|
+
? "Preview ready in the inline MCP App."
|
|
449
|
+
: `Remote preview unavailable: ${embeddedError ?? "no declared previews origin"}`;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
fileUrl = pathToFileURL(join(kitDir, "index.html")).href;
|
|
453
|
+
if (prepareInline) {
|
|
454
|
+
if (useConfiguredPreviewsOrigin) {
|
|
455
|
+
embeddedManifest = await rewriteCardPaths(filteredManifest, {
|
|
456
|
+
kitId: args.kitId,
|
|
457
|
+
kitDir,
|
|
458
|
+
previewsBaseUrl: normalizedPreviewsBaseUrl.toString(),
|
|
459
|
+
readPreviewBytes,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
else if (useLocalCardBroker) {
|
|
463
|
+
// A local UI host already owns the visible MCP App canvas. Give it the
|
|
464
|
+
// filtered manifest with exact broker origins instead of nesting the
|
|
465
|
+
// dynamic Vite viewer (which the host's fixed CSP cannot authorize).
|
|
466
|
+
try {
|
|
467
|
+
const broker = await getCardAssetBroker();
|
|
468
|
+
const registeredKit = await broker.registerKit(args.kitId, kitDir);
|
|
469
|
+
embeddedManifest = rewriteCardPathsForBroker(filteredManifest, registeredKit);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
logStderr({
|
|
473
|
+
event: "preview.fallback",
|
|
474
|
+
kitId: args.kitId,
|
|
475
|
+
reason: "card-asset-broker-failed",
|
|
476
|
+
error: String(error),
|
|
477
|
+
});
|
|
478
|
+
embeddedError = "The local card asset broker could not start.";
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else if (canDeliverEmbeddedManifest) {
|
|
482
|
+
// An empty result has no card bytes or child-script hashes to authorize,
|
|
483
|
+
// so it is safe to deliver through an already-mounted tool-result shell.
|
|
484
|
+
embeddedManifest = await rewriteCardPaths(filteredManifest, {
|
|
485
|
+
kitId: args.kitId,
|
|
486
|
+
kitDir,
|
|
487
|
+
previewsBaseUrl: normalizedPreviewsBaseUrl?.toString(),
|
|
488
|
+
readPreviewBytes,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
embeddedError =
|
|
493
|
+
"No CSP-safe inline card origin is available; configure GENIE_PREVIEWS_BASE_URL.";
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (prepareViewerFallback) {
|
|
497
|
+
try {
|
|
498
|
+
const viewer = await deps.registry.ensure(kitDir, DEFAULT_VIEWER_PORT, false); // AC5 + piece B
|
|
499
|
+
const url = new URL(viewer.url);
|
|
500
|
+
if (args.componentName !== undefined)
|
|
501
|
+
url.searchParams.set("componentName", args.componentName);
|
|
502
|
+
if (args.group !== undefined)
|
|
503
|
+
url.searchParams.set("group", args.group);
|
|
504
|
+
viewerUrl = url.toString();
|
|
505
|
+
if (autoOpen)
|
|
506
|
+
await deps.registry.open(kitDir, viewerUrl);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
// AC6 — the local viewer could not boot (Vite/@ambitresearch/genie-viewer absent,
|
|
510
|
+
// port unbindable, …). Preserve any independently prepared inline
|
|
511
|
+
// manifest, otherwise degrade to the file:// vehicle.
|
|
512
|
+
logStderr({
|
|
513
|
+
event: "preview.fallback",
|
|
514
|
+
kitId: args.kitId,
|
|
515
|
+
reason: "viewer-boot-failed",
|
|
516
|
+
error: String(error),
|
|
517
|
+
});
|
|
518
|
+
if (embeddedManifest === undefined && embeddedError === undefined) {
|
|
519
|
+
embeddedError =
|
|
520
|
+
"The local preview viewer could not start; use the returned file URL or start it manually.";
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (embeddedManifest !== undefined && viewerUrl !== undefined) {
|
|
525
|
+
text =
|
|
526
|
+
`Preview ready in the inline MCP App.\n` +
|
|
527
|
+
`Tools-only fallback running at ${viewerUrl}\n` +
|
|
528
|
+
`Or open the kit directly: ${fileUrl}`;
|
|
529
|
+
}
|
|
530
|
+
else if (embeddedManifest !== undefined) {
|
|
531
|
+
text = `Preview ready in the inline MCP App.\nOr open the kit directly: ${fileUrl}`;
|
|
532
|
+
}
|
|
533
|
+
else if (viewerUrl !== undefined) {
|
|
534
|
+
text =
|
|
535
|
+
(embeddedError === undefined ? "" : `Inline preview unavailable: ${embeddedError}\n`) +
|
|
536
|
+
`Preview running at ${viewerUrl}\n` +
|
|
537
|
+
`Or open the kit directly: ${fileUrl}`;
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
text =
|
|
541
|
+
`Preview unavailable: ${embeddedError ?? "no supported delivery path"}\n` +
|
|
542
|
+
`Open the kit directly: ${fileUrl}`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
content: [{ type: "text", text }],
|
|
547
|
+
structuredContent: {
|
|
548
|
+
kitId: args.kitId,
|
|
549
|
+
...(args.componentName !== undefined ? { componentName: args.componentName } : {}),
|
|
550
|
+
...(args.group !== undefined ? { group: args.group } : {}),
|
|
551
|
+
...(viewerUrl !== undefined ? { viewerUrl } : {}),
|
|
552
|
+
...(fileUrl !== undefined ? { fileUrl } : {}),
|
|
553
|
+
...(ctx.transportKind !== undefined ? { transportKind: ctx.transportKind } : {}),
|
|
554
|
+
locality,
|
|
555
|
+
...(embeddedError !== undefined ? { embeddedError } : {}),
|
|
556
|
+
},
|
|
557
|
+
// Same resourceUri under both the MCP-Apps key (`ui.resourceUri`) and the
|
|
558
|
+
// ChatGPT Apps SDK key (`openai/outputTemplate`, AC6) — cross-vendor link.
|
|
559
|
+
_meta: {
|
|
560
|
+
ui: { resourceUri },
|
|
561
|
+
"openai/outputTemplate": resourceUri,
|
|
562
|
+
...(embeddedManifest !== undefined
|
|
563
|
+
? { [PREVIEW_EMBEDDED_MANIFEST_META_KEY]: embeddedManifest }
|
|
564
|
+
: {}),
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// ─── MCP registration ────────────────────────────────────────────────────────
|
|
569
|
+
const previewInputSchema = {
|
|
570
|
+
kitId: z
|
|
571
|
+
.string()
|
|
572
|
+
.regex(KIT_ID_PATTERN)
|
|
573
|
+
.describe("The kit to preview (a genie kitId — lowercase slug)."),
|
|
574
|
+
componentName: z
|
|
575
|
+
.string()
|
|
576
|
+
.min(1)
|
|
577
|
+
.optional()
|
|
578
|
+
.describe("Optional single-component focus; carried into the ui:// resource query."),
|
|
579
|
+
group: z
|
|
580
|
+
.string()
|
|
581
|
+
.min(1)
|
|
582
|
+
.optional()
|
|
583
|
+
.describe("Optional group filter; carried into the ui:// resource query. Empty string rejected."),
|
|
584
|
+
};
|
|
585
|
+
const previewOutputSchema = {
|
|
586
|
+
kitId: z.string(),
|
|
587
|
+
componentName: z.string().optional(),
|
|
588
|
+
group: z.string().optional(),
|
|
589
|
+
viewerUrl: z.string().optional(),
|
|
590
|
+
fileUrl: z.string().optional(),
|
|
591
|
+
transportKind: z.enum(["stdio", "http"]).optional(),
|
|
592
|
+
locality: z.enum(["local", "remote"]),
|
|
593
|
+
embeddedError: z.string().optional(),
|
|
594
|
+
};
|
|
595
|
+
/**
|
|
596
|
+
* Extract the requesting harness's client name from `params._meta.client.name`
|
|
597
|
+
* (AC7). MCP's `RequestMeta` is a loose object, so this custom key survives to
|
|
598
|
+
* the handler's `extra._meta`. Defensive about shape — any missing/oddly-typed
|
|
599
|
+
* level yields `undefined` (→ treated as a non-ui:// host).
|
|
600
|
+
*/
|
|
601
|
+
function sniffClientName(meta) {
|
|
602
|
+
if (typeof meta !== "object" || meta === null)
|
|
603
|
+
return undefined;
|
|
604
|
+
const client = meta.client;
|
|
605
|
+
if (typeof client !== "object" || client === null)
|
|
606
|
+
return undefined;
|
|
607
|
+
const name = client.name;
|
|
608
|
+
return typeof name === "string" ? name : undefined;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Register `mcp__genie__preview`. One {@link ViewerRegistry} is created per
|
|
612
|
+
* registration and shared across every call so viewers are reused (AC5). The
|
|
613
|
+
* handler resolves the client's identity (per-request `_meta` escape hatch,
|
|
614
|
+
* else the initialize handshake) and its MCP Apps capability, then delegates
|
|
615
|
+
* to {@link runPreview}.
|
|
616
|
+
*/
|
|
617
|
+
export function registerPreviewTool(server, options) {
|
|
618
|
+
const registry = new ViewerRegistry(options.booter ?? defaultViewerBooter);
|
|
619
|
+
const deps = {
|
|
620
|
+
kitsRoot: options.kitsRoot,
|
|
621
|
+
registry,
|
|
622
|
+
env: options.env,
|
|
623
|
+
ensureManifest: options.ensureManifest,
|
|
624
|
+
readPreviewBytes: options.readPreviewBytes,
|
|
625
|
+
previewsBaseUrl: options.previewsBaseUrl,
|
|
626
|
+
getCardAssetBroker: options.getCardAssetBroker,
|
|
627
|
+
};
|
|
628
|
+
server.registerTool(PREVIEW_TOOL_NAME, {
|
|
629
|
+
title: "Preview kit",
|
|
630
|
+
description: "Preview a UI kit as a live card grid — call this to SHOW the user a component " +
|
|
631
|
+
"(typically right after write_files persists one). Compiles + persists the kit " +
|
|
632
|
+
"manifest so the grid always reflects what's on disk, then returns the viewer URL " +
|
|
633
|
+
"(booting the Vite viewer on demand and reusing it across calls) plus a local-only " +
|
|
634
|
+
"file:// fallback. MCP Apps-capable hosts get the inline grid via _meta.ui.resourceUri. " +
|
|
635
|
+
"Local stdio clients that explicitly negotiate no UI support get a server-opened browser " +
|
|
636
|
+
"tab (disable with GENIE_PREVIEW_NO_OPEN=1); HTTP deployments never auto-open on " +
|
|
637
|
+
"the server machine. Optionally focus one component or group.",
|
|
638
|
+
inputSchema: previewInputSchema,
|
|
639
|
+
outputSchema: previewOutputSchema,
|
|
640
|
+
// Standards-compliant discovery happens in tools/list through this static
|
|
641
|
+
// app shell. The host sends tool input/result to the shell, which renders
|
|
642
|
+
// the invocation's viewer URL. Keep the query-bearing result URI below for
|
|
643
|
+
// legacy/result-level hosts and the self-contained embedded resource path.
|
|
644
|
+
_meta: {
|
|
645
|
+
ui: { resourceUri: GRID_RESOURCE_URI },
|
|
646
|
+
"openai/outputTemplate": GRID_RESOURCE_URI,
|
|
647
|
+
},
|
|
648
|
+
}, async (args, extra) => {
|
|
649
|
+
// Client identity: prefer the per-request `_meta.client.name` escape
|
|
650
|
+
// hatch (tests, exotic proxies), else the `initialize` handshake's
|
|
651
|
+
// clientInfo — what real hosts actually send (the per-request key was
|
|
652
|
+
// the original AC7 sniff's blind spot: no production host populates it).
|
|
653
|
+
const clientName = sniffClientName(extra._meta) ?? server.server.getClientVersion()?.name;
|
|
654
|
+
// Authoritative MCP Apps capability from the same handshake.
|
|
655
|
+
const uiCapable = getUiExtensionCapability(server.server.getClientCapabilities());
|
|
656
|
+
const transportKind = options.transportKind ?? getServerTransportKind(server);
|
|
657
|
+
const locality = options.locality ?? (transportKind === "stdio" ? "local" : "remote");
|
|
658
|
+
const result = await runPreview(deps, args, {
|
|
659
|
+
clientName,
|
|
660
|
+
uiCapable,
|
|
661
|
+
transportKind,
|
|
662
|
+
locality,
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
content: result.content,
|
|
666
|
+
structuredContent: result.structuredContent,
|
|
667
|
+
_meta: result._meta,
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
return registry;
|
|
671
|
+
}
|
|
672
|
+
//# sourceMappingURL=preview.js.map
|