@guuey/create-agentic-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/dist/chunk-JD4RAKEG.js +203 -0
  4. package/dist/chunk-U7JDNY6B.js +149 -0
  5. package/dist/cli.cjs +293 -0
  6. package/dist/cli.d.cts +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +128 -0
  9. package/dist/index.cjs +233 -0
  10. package/dist/index.d.cts +76 -0
  11. package/dist/index.d.ts +76 -0
  12. package/dist/index.js +10 -0
  13. package/dist/templates/claude-agent-sdk/.env.example +4 -0
  14. package/dist/templates/claude-agent-sdk/.mcp.json +1 -0
  15. package/dist/templates/claude-agent-sdk/README.md +107 -0
  16. package/dist/templates/claude-agent-sdk/ggui/blueprints/.gitkeep +0 -0
  17. package/dist/templates/claude-agent-sdk/ggui/ggui.json +15 -0
  18. package/dist/templates/claude-agent-sdk/ggui/themes/.gitkeep +0 -0
  19. package/dist/templates/claude-agent-sdk/guuey.json +13 -0
  20. package/dist/templates/claude-agent-sdk/mcps/todo/Dockerfile +46 -0
  21. package/dist/templates/claude-agent-sdk/mcps/todo/package.json +22 -0
  22. package/dist/templates/claude-agent-sdk/mcps/todo/src/server.ts +173 -0
  23. package/dist/templates/claude-agent-sdk/mcps/todo/tsconfig.json +15 -0
  24. package/dist/templates/claude-agent-sdk/package.json +22 -0
  25. package/dist/templates/claude-agent-sdk/pnpm-workspace.yaml +24 -0
  26. package/dist/templates/claude-agent-sdk/prompts/system.md +4 -0
  27. package/dist/templates/claude-agent-sdk/scripts/dev.mjs +44 -0
  28. package/dist/templates/claude-agent-sdk/src/agent-config.ts +70 -0
  29. package/dist/templates/claude-agent-sdk/src/worker.ts +89 -0
  30. package/dist/templates/claude-agent-sdk/tsconfig.json +15 -0
  31. package/dist/templates/claude-agent-sdk/tsup.config.ts +9 -0
  32. package/dist/templates/claude-agent-sdk/web/.env.example +17 -0
  33. package/dist/templates/claude-agent-sdk/web/index.html +13 -0
  34. package/dist/templates/claude-agent-sdk/web/package.json +25 -0
  35. package/dist/templates/claude-agent-sdk/web/sandbox-proxy.ts +239 -0
  36. package/dist/templates/claude-agent-sdk/web/src/App.tsx +272 -0
  37. package/dist/templates/claude-agent-sdk/web/src/main.tsx +12 -0
  38. package/dist/templates/claude-agent-sdk/web/src/useAgentChat.ts +122 -0
  39. package/dist/templates/claude-agent-sdk/web/src/vite-env.d.ts +27 -0
  40. package/dist/templates/claude-agent-sdk/web/tsconfig.json +20 -0
  41. package/dist/templates/claude-agent-sdk/web/vite.config.ts +12 -0
  42. package/dist/templates/mcp-base/Dockerfile +46 -0
  43. package/dist/templates/mcp-base/package.json +22 -0
  44. package/dist/templates/mcp-base/src/server.ts +113 -0
  45. package/dist/templates/mcp-base/tsconfig.json +15 -0
  46. package/dist/templates/openai-agents-sdk/.env.example +4 -0
  47. package/dist/templates/openai-agents-sdk/.mcp.json +1 -0
  48. package/dist/templates/openai-agents-sdk/README.md +107 -0
  49. package/dist/templates/openai-agents-sdk/ggui/blueprints/.gitkeep +0 -0
  50. package/dist/templates/openai-agents-sdk/ggui/ggui.json +15 -0
  51. package/dist/templates/openai-agents-sdk/ggui/themes/.gitkeep +0 -0
  52. package/dist/templates/openai-agents-sdk/guuey.json +13 -0
  53. package/dist/templates/openai-agents-sdk/mcps/todo/Dockerfile +46 -0
  54. package/dist/templates/openai-agents-sdk/mcps/todo/package.json +22 -0
  55. package/dist/templates/openai-agents-sdk/mcps/todo/src/server.ts +173 -0
  56. package/dist/templates/openai-agents-sdk/mcps/todo/tsconfig.json +15 -0
  57. package/dist/templates/openai-agents-sdk/package.json +22 -0
  58. package/dist/templates/openai-agents-sdk/pnpm-workspace.yaml +24 -0
  59. package/dist/templates/openai-agents-sdk/prompts/system.md +4 -0
  60. package/dist/templates/openai-agents-sdk/scripts/dev.mjs +44 -0
  61. package/dist/templates/openai-agents-sdk/src/agent-config.ts +70 -0
  62. package/dist/templates/openai-agents-sdk/src/worker.ts +75 -0
  63. package/dist/templates/openai-agents-sdk/tsconfig.json +15 -0
  64. package/dist/templates/openai-agents-sdk/tsup.config.ts +9 -0
  65. package/dist/templates/openai-agents-sdk/web/.env.example +17 -0
  66. package/dist/templates/openai-agents-sdk/web/index.html +13 -0
  67. package/dist/templates/openai-agents-sdk/web/package.json +25 -0
  68. package/dist/templates/openai-agents-sdk/web/sandbox-proxy.ts +239 -0
  69. package/dist/templates/openai-agents-sdk/web/src/App.tsx +272 -0
  70. package/dist/templates/openai-agents-sdk/web/src/main.tsx +12 -0
  71. package/dist/templates/openai-agents-sdk/web/src/useAgentChat.ts +122 -0
  72. package/dist/templates/openai-agents-sdk/web/src/vite-env.d.ts +27 -0
  73. package/dist/templates/openai-agents-sdk/web/tsconfig.json +20 -0
  74. package/dist/templates/openai-agents-sdk/web/vite.config.ts +12 -0
  75. package/package.json +45 -0
@@ -0,0 +1,24 @@
1
+ packages:
2
+ - "mcps/*"
3
+ - "web"
4
+
5
+ # pnpm 11 default `strictDepBuilds: true` blocks install scripts for any
6
+ # dependency with a build script (esbuild's native binary fetch, sharp,
7
+ # etc.) unless individually allowlisted, and fails the whole `install`
8
+ # with ERR_PNPM_IGNORED_BUILDS otherwise. Scaffolded apps pull in build-
9
+ # script deps transitively (tsup/vite -> esbuild, mcp/ui deps, etc.) that
10
+ # we can't enumerate up front, so match pnpm 9/10 behavior here.
11
+ strictDepBuilds: false
12
+
13
+ # pnpm 11 default `verifyDepsBeforeRun: 'install'` re-verifies the
14
+ # lockfile against supply-chain policies (including minimumReleaseAge)
15
+ # before every `pnpm run <script>`, hitting the registry even for
16
+ # already-installed deps. That check 404s for any dependency that only
17
+ # exists as a local tarball (not yet published), which is expected
18
+ # during local/dev/CI installs. Restore pnpm 9/10 behavior: only verify
19
+ # on an explicit `pnpm install`.
20
+ verifyDepsBeforeRun: false
21
+
22
+ # See above — same registry-lookback problem hits a plain `pnpm install`
23
+ # too when minimumReleaseAge is nonzero; disable it for scaffolded apps.
24
+ minimumReleaseAge: 0
@@ -0,0 +1,4 @@
1
+ You are the agentic-app-template assistant. You have a todo tool server —
2
+ use it to create, list, toggle, and delete the user's todos. When the ggui
3
+ server is available, prefer rendering rich UI for lists and forms over
4
+ plain text.
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // pnpm dev — boots the whole local stack. Ctrl-C tears everything down.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+
6
+ const procs = [];
7
+ function boot(name, command, args, opts = {}) {
8
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
9
+ const prefix = `[${name}]`.padEnd(9);
10
+ child.stdout.on("data", (d) => process.stdout.write(String(d).replace(/^/gm, prefix)));
11
+ child.stderr.on("data", (d) => process.stderr.write(String(d).replace(/^/gm, prefix)));
12
+ child.on("exit", (code) => {
13
+ if (code !== 0 && !shuttingDown) shutdown(`${name} exited (${code})`, 1);
14
+ });
15
+ procs.push(child);
16
+ }
17
+ let shuttingDown = false;
18
+ function shutdown(reason, code = 0) {
19
+ if (shuttingDown) return;
20
+ shuttingDown = true;
21
+ console.log(`\n${reason} — shutting down`);
22
+ for (const p of procs) p.kill("SIGTERM");
23
+ setTimeout(() => process.exit(code), 500);
24
+ }
25
+ process.on("SIGINT", () => shutdown("interrupted"));
26
+ process.on("SIGTERM", () => shutdown("terminated"));
27
+
28
+ if (!existsSync(".env.local"))
29
+ console.warn("hint: cp .env.example .env.local and set your LLM key");
30
+
31
+ boot("worker", "pnpm", ["exec", "tsup", "--watch"]); // rebuilds guuey.worker.js on change
32
+ boot("agent", "pnpm", ["exec", "guuey", "dev", "--serve", "--port", "6790"]);
33
+ boot("todo", "pnpm", ["--filter", "@agentic-app-template/todo-mcp", "dev"], {
34
+ env: { ...process.env, PORT: "6782" },
35
+ });
36
+ boot("ggui", "pnpm", ["exec", "ggui", "serve", "--mcp-only", "--dev-allow-all", "--port", "6781"], {
37
+ cwd: "ggui",
38
+ });
39
+ boot("web", "pnpm", ["--filter", "@agentic-app-template/web", "dev"], {
40
+ env: { ...process.env, PORT: "6890" },
41
+ });
42
+
43
+ console.log("\n agent http://localhost:6790 todo-mcp http://localhost:6782");
44
+ console.log(" ggui http://localhost:6781 web http://localhost:6890\n");
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Snapshot + system-prompt + MCP-endpoint resolution shared by both framework
3
+ * overlays. Duplicated verbatim in `templates-src/frameworks/openai-agents-sdk`
4
+ * by design — template code must be self-contained (no cross-overlay imports),
5
+ * so keep the two copies byte-identical when editing either one.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { parseGuueyJson, type GuueyAgent } from "@guuey/config";
10
+ import type { Invoke } from "@guuey/worker";
11
+
12
+ /** GUUEY_AGENT_SNAPSHOT (set by the platform pod and by `guuey dev`) wins; guuey.json is the fallback. */
13
+ export function loadAgent(invoke: Invoke): GuueyAgent {
14
+ const env = process.env.GUUEY_AGENT_SNAPSHOT;
15
+ if (env) return JSON.parse(env) as GuueyAgent;
16
+ const raw = JSON.parse(readFileSync(join(invoke.fs.app, "guuey.json"), "utf8"));
17
+ return parseGuueyJson(raw).agent;
18
+ }
19
+
20
+ export function systemPrompt(invoke: Invoke, agent: GuueyAgent): string | undefined {
21
+ const sp = agent.systemPrompt;
22
+ if (typeof sp === "string") return sp;
23
+ if (sp?.file) return readFileSync(join(invoke.fs.app, sp.file), "utf8");
24
+ return undefined;
25
+ }
26
+
27
+ /**
28
+ * One resolved MCP endpoint this worker may connect to. `transport` rides
29
+ * alongside `url`/`headers` (not hardcoded to `"http"`) because a federation
30
+ * credential file — `<session>/.guuey/credentials/<name>.json`, shape
31
+ * `{url, transport, headers}` per `@guuey/host`'s `CredentialFile` — may
32
+ * resolve a server onto the `sse` transport arm.
33
+ */
34
+ export interface McpEndpoint {
35
+ url: string;
36
+ transport: "http" | "sse";
37
+ headers: Record<string, string>;
38
+ }
39
+
40
+ /** Lowered entries are `external`; federation credentials (if any) arrive as per-session files. */
41
+ export function mcpEndpoints(invoke: Invoke, agent: GuueyAgent): Record<string, McpEndpoint> {
42
+ const out: Record<string, McpEndpoint> = {};
43
+ for (const [name, entry] of Object.entries(agent.mcpServers ?? {})) {
44
+ if (entry.kind !== "external") continue; // hosted/proxied are lowered to external before a worker ever runs
45
+ let url = entry.url;
46
+ let transport: "http" | "sse" = entry.transport ?? "http";
47
+ let headers: Record<string, string> = { ...(entry.headers ?? {}) };
48
+ try {
49
+ const cred = JSON.parse(
50
+ readFileSync(join(invoke.fs.session, ".guuey", "credentials", `${name}.json`), "utf8")
51
+ ) as { url: string; transport: "http" | "sse"; headers: Record<string, string> };
52
+ url = cred.url;
53
+ transport = cred.transport;
54
+ headers = cred.headers;
55
+ } catch {
56
+ // no credential file — plain external endpoint; static headers apply
57
+ }
58
+ out[name] = { url, transport, headers };
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Fold prior turns into a prompt preamble (v1 keep-it-simple history handling). */
64
+ export function withHistory(invoke: Invoke): string {
65
+ if (invoke.history.length === 0) return invoke.input;
66
+ const lines = invoke.history.map(
67
+ (h) => `${h.role === "agent" ? "Assistant" : "User"}: ${h.text}`
68
+ );
69
+ return `<conversation_history>\n${lines.join("\n")}\n</conversation_history>\n\n${invoke.input}`;
70
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * agentic-app-template's code-mode worker entry (`@openai/agents`). Mirrors
3
+ * `@guuey/host`'s `run-openai.ts` — the platform's own OpenAI wiring over this
4
+ * exact snapshot shape: build an `Agent` from the resolved system prompt +
5
+ * MCP endpoints, run it streamed, emit every raw `RunStreamEvent` to the
6
+ * Router via `emit.native`, and resolve the turn's final text from
7
+ * `stream.finalOutput`.
8
+ *
9
+ * `MaxTurnsExceededError` handling mirrors `run-openai.ts`'s INTENT — there
10
+ * max-turns is a soft terminal (`done(finalText, "max_turns")` after a
11
+ * sentinel), NOT a hard `error`; the partial output is still the turn's
12
+ * result. `serveNative`'s handler contract has neither a stopReason nor a
13
+ * sentinel channel (a return is always `done(.., "end_turn")`; a throw is a
14
+ * hard `error` that would discard the partial text), so the cutoff is made
15
+ * visible by appending a note to the returned result instead of rethrowing.
16
+ */
17
+ import { Agent, MaxTurnsExceededError, MCPServerStreamableHttp, run } from "@openai/agents";
18
+ import type { MCPServer } from "@openai/agents";
19
+ import { serveNative } from "@guuey/worker";
20
+ import { loadAgent, systemPrompt, mcpEndpoints, withHistory } from "./agent-config.js";
21
+
22
+ await serveNative(
23
+ async (invoke, emit) => {
24
+ const agent = loadAgent(invoke);
25
+ const endpoints = mcpEndpoints(invoke, agent);
26
+ const mcpServers: MCPServerStreamableHttp[] = Object.entries(endpoints).map(
27
+ ([name, ep]) =>
28
+ new MCPServerStreamableHttp({
29
+ url: ep.url,
30
+ name,
31
+ ...(Object.keys(ep.headers).length > 0 ? { requestInit: { headers: ep.headers } } : {}),
32
+ })
33
+ );
34
+
35
+ try {
36
+ for (const server of mcpServers) {
37
+ await server.connect();
38
+ }
39
+
40
+ const sdkAgent = new Agent({
41
+ name: "guuey-agent",
42
+ instructions: systemPrompt(invoke, agent) ?? "You are a helpful assistant.",
43
+ mcpServers: mcpServers as MCPServer[],
44
+ ...(agent.model ? { model: agent.model } : {}),
45
+ });
46
+
47
+ const stream = await run(sdkAgent, withHistory(invoke), {
48
+ stream: true,
49
+ ...(agent.runtime?.maxTurns ? { maxTurns: agent.runtime.maxTurns } : {}),
50
+ });
51
+
52
+ for await (const event of stream) {
53
+ emit.native(JSON.parse(JSON.stringify(event)));
54
+ }
55
+
56
+ try {
57
+ await stream.completed;
58
+ } catch (err) {
59
+ if (err instanceof MaxTurnsExceededError) {
60
+ // Soft terminal (see module doc): keep whatever partial output the
61
+ // stream produced and make the cutoff visible in the result text.
62
+ const partial = typeof stream.finalOutput === "string" ? stream.finalOutput : "";
63
+ const note = "[agent stopped: maxTurns limit reached]";
64
+ return partial ? `${partial}\n\n${note}` : note;
65
+ }
66
+ throw err;
67
+ }
68
+
69
+ return typeof stream.finalOutput === "string" ? stream.finalOutput : "";
70
+ } finally {
71
+ await Promise.allSettled(mcpServers.map((s) => s.close()));
72
+ }
73
+ },
74
+ { framework: "openai-agents-sdk", sdkName: "@openai/agents" }
75
+ );
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true
12
+ },
13
+ "include": ["src"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: { "guuey.worker": "src/worker.ts" },
5
+ format: ["esm"],
6
+ outDir: ".",
7
+ clean: false,
8
+ noExternal: [/./], // bundle everything — the deploy tarball must be runnable via `node guuey.worker.js`
9
+ });
@@ -0,0 +1,17 @@
1
+ # Vite env for the web chat client. Copy to `web/.env.local` and UNCOMMENT
2
+ # the vars you want to override. (Vite reads env files from this directory —
3
+ # not the project root. Keep unused vars commented out: an empty
4
+ # `VITE_X=` declaration loads as "" — not undefined.)
5
+
6
+ # Agent base URL. Defaults to the local `guuey dev` router (:6790).
7
+ # VITE_AGENT_ENDPOINT_URL=http://localhost:6790
8
+
9
+ # Second-origin MCP-Apps sandbox page for @mcp-ui/client's <AppRenderer>.
10
+ # The MCP Apps spec requires UI resources to mount from a DIFFERENT origin
11
+ # than the app itself (double-iframe sandbox). In local dev this is served
12
+ # automatically on http://127.0.0.1:6891/sandbox.html by the
13
+ # sandboxProxyPlugin in vite.config.ts — leave unset. For a DEPLOYED build,
14
+ # host ../sandbox-proxy.ts's sandbox page on its own origin and point this
15
+ # at it; if unset in production, UI resources show a plain-text notice
16
+ # instead of rendering.
17
+ # VITE_SANDBOX_URL=https://sandbox.example.com/sandbox.html
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>agentic-app-template</title>
7
+ <meta name="description" content="Local dev chat client for your guuey agent." />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@agentic-app-template/web",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --port 6890",
8
+ "build": "tsc && vite build",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@mcp-ui/client": "^7.1.1",
13
+ "@silverprotocol/core": "0.1.0",
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.0.0",
19
+ "@types/react": "^19.0.0",
20
+ "@types/react-dom": "^19.0.0",
21
+ "@vitejs/plugin-react": "^4.3.0",
22
+ "typescript": "^5.8.0",
23
+ "vite": "^6.0.0"
24
+ }
25
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * MCP-Apps sandbox proxy for local dev — a Vite plugin that serves the
3
+ * spec-canonical `sandbox.html` on a SECOND localhost port (:6891), i.e. a
4
+ * different origin from the SPA (:6890).
5
+ *
6
+ * # Why a second origin
7
+ *
8
+ * The MCP Apps spec's double-iframe sandbox architecture mandates that the
9
+ * host page and the sandbox iframe live on DIFFERENT origins: the host wraps
10
+ * the sandbox iframe, the sandbox iframe wraps the untrusted app HTML, and
11
+ * the origin split means a compromised app cannot reach the host's APIs via
12
+ * same-origin DOM access. `@mcp-ui/client`'s `<AppRenderer>` takes a
13
+ * `sandbox: { url }` prop pointing at this page. A different localhost PORT
14
+ * is a different origin, so one extra listener is all local dev needs.
15
+ *
16
+ * # Reference impl
17
+ *
18
+ * The HTML below is adapted from the spec's reference host
19
+ * (`github.com/modelcontextprotocol/ext-apps/examples/basic-host/` —
20
+ * sandbox.html + src/sandbox.ts + serve.ts), matching the pattern
21
+ * `@ggui-ai/agent-server`'s bundled sandbox proxy uses. CSP arrives as a
22
+ * `?csp=<urlencoded-json>` query param and is applied via HTTP HEADERS
23
+ * (tamper-proof — a `<meta>` CSP could be overridden by injected inline
24
+ * scripts), which is why this must be a real server, not a static file.
25
+ *
26
+ * # Deploying `web/`
27
+ *
28
+ * This plugin only runs under `vite` dev (`apply: "serve"`). A deployed
29
+ * build must point `VITE_SANDBOX_URL` at a hosted copy of this page on its
30
+ * own origin (see `.env.example`); without it the app falls back to a
31
+ * "UI resource received" notice instead of mounting UI same-origin.
32
+ */
33
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
34
+ import type { Plugin } from "vite";
35
+
36
+ /**
37
+ * The sandbox proxy's dev port. web/ runs on :6890; keep the `6891` literal
38
+ * in `src/App.tsx` (`DEV_SANDBOX_URL`) in sync — App.tsx cannot import this
39
+ * module (it would drag `node:http` into the browser bundle).
40
+ */
41
+ export const SANDBOX_PROXY_PORT = 6891;
42
+
43
+ /** CSP shape `<AppRenderer>` forwards on the sandbox URL's `?csp=` param. */
44
+ interface McpUiResourceCsp {
45
+ readonly resourceDomains?: readonly string[];
46
+ readonly connectDomains?: readonly string[];
47
+ readonly frameDomains?: readonly string[];
48
+ readonly baseUriDomains?: readonly string[];
49
+ }
50
+
51
+ /** Drop CSP entries carrying directive-injection characters (`;`, newlines, quotes, spaces). */
52
+ function sanitizeCspDomains(domains: readonly string[] | undefined): readonly string[] {
53
+ if (!domains) return [];
54
+ return domains.filter((d) => typeof d === "string" && !/[;\r\n'" ]/.test(d));
55
+ }
56
+
57
+ /** Build the CSP header value — mirrors the upstream basic-host `serve.ts`. */
58
+ function buildCspHeader(csp: McpUiResourceCsp | undefined): string {
59
+ const resourceDomains = sanitizeCspDomains(csp?.resourceDomains).join(" ");
60
+ const connectDomains = sanitizeCspDomains(csp?.connectDomains).join(" ");
61
+ const frameDomains = sanitizeCspDomains(csp?.frameDomains).join(" ");
62
+ const baseUriDomains = sanitizeCspDomains(csp?.baseUriDomains).join(" ");
63
+ return [
64
+ "default-src 'self' 'unsafe-inline'",
65
+ `script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ${resourceDomains}`.trim(),
66
+ `style-src 'self' 'unsafe-inline' blob: data: ${resourceDomains}`.trim(),
67
+ `img-src 'self' data: blob: ${resourceDomains}`.trim(),
68
+ `font-src 'self' data: blob: ${resourceDomains}`.trim(),
69
+ `media-src 'self' data: blob: ${resourceDomains}`.trim(),
70
+ `connect-src 'self' ${connectDomains}`.trim(),
71
+ `worker-src 'self' blob: ${resourceDomains}`.trim(),
72
+ frameDomains.length > 0 ? `frame-src ${frameDomains}` : "frame-src 'none'",
73
+ "object-src 'none'",
74
+ baseUriDomains.length > 0 ? `base-uri ${baseUriDomains}` : "base-uri 'none'",
75
+ ].join("; ");
76
+ }
77
+
78
+ /**
79
+ * The self-contained sandbox.html. The inlined script:
80
+ *
81
+ * 1. Asserts iframe isolation (throws if it can reach `window.top`).
82
+ * 2. Creates an inner iframe sandboxed with `allow-scripts
83
+ * allow-same-origin allow-forms`.
84
+ * 3. On `ui/notifications/sandbox-resource-ready` from the parent, writes
85
+ * the app HTML into the inner iframe.
86
+ * 4. Relays every other postMessage bidirectionally, origin-checked.
87
+ * 5. Posts `ui/notifications/sandbox-proxy-ready` once attached.
88
+ */
89
+ const SANDBOX_HTML = `<!doctype html>
90
+ <html>
91
+ <head>
92
+ <meta charset="utf-8" />
93
+ <meta name="color-scheme" content="light dark">
94
+ <title>sandbox proxy</title>
95
+ <style>
96
+ html, body { margin: 0; height: 100vh; height: 100dvh; width: 100%; background-color: transparent; }
97
+ body { display: flex; flex-direction: column; }
98
+ * { box-sizing: border-box; }
99
+ iframe {
100
+ background-color: transparent;
101
+ border: 0px none transparent;
102
+ padding: 0px;
103
+ overflow: hidden;
104
+ flex-grow: 1;
105
+ color-scheme: inherit;
106
+ }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <script>
111
+ (function(){
112
+ 'use strict';
113
+ if (window.self === window.top) {
114
+ throw new Error('This file is only to be used in an iframe sandbox.');
115
+ }
116
+ if (!document.referrer) {
117
+ throw new Error('No referrer, cannot validate embedding site.');
118
+ }
119
+ var EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
120
+ var OWN_ORIGIN = new URL(window.location.href).origin;
121
+ // Security self-test: top access MUST throw (sandbox attribute strips same-origin).
122
+ try {
123
+ window.top.alert('If you see this, the sandbox is not setup securely.');
124
+ throw 'FAIL';
125
+ } catch (e) {
126
+ if (e === 'FAIL') {
127
+ throw new Error('The sandbox is not setup securely.');
128
+ }
129
+ }
130
+ // Inner iframe — the untrusted app HTML lands here.
131
+ var inner = document.createElement('iframe');
132
+ inner.style = 'width:100%; height:100%; border:none;';
133
+ inner.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
134
+ document.body.appendChild(inner);
135
+ var RESOURCE_READY = 'ui/notifications/sandbox-resource-ready';
136
+ var PROXY_READY = 'ui/notifications/sandbox-proxy-ready';
137
+ window.addEventListener('message', function(event) {
138
+ if (event.source === window.parent) {
139
+ if (event.origin !== EXPECTED_HOST_ORIGIN) {
140
+ console.error('[Sandbox] Rejecting parent message from unexpected origin:', event.origin);
141
+ return;
142
+ }
143
+ if (event.data && event.data.method === RESOURCE_READY) {
144
+ var params = event.data.params || {};
145
+ var html = params.html;
146
+ var sandboxAttr = params.sandbox;
147
+ if (typeof sandboxAttr === 'string') {
148
+ inner.setAttribute('sandbox', sandboxAttr);
149
+ }
150
+ if (typeof html === 'string') {
151
+ var doc = inner.contentDocument || (inner.contentWindow && inner.contentWindow.document);
152
+ if (doc) {
153
+ doc.open();
154
+ doc.write(html);
155
+ doc.close();
156
+ } else {
157
+ inner.srcdoc = html;
158
+ }
159
+ }
160
+ } else {
161
+ if (inner && inner.contentWindow) {
162
+ inner.contentWindow.postMessage(event.data, '*');
163
+ }
164
+ }
165
+ } else if (event.source === inner.contentWindow) {
166
+ if (event.origin !== OWN_ORIGIN) {
167
+ console.error('[Sandbox] Rejecting inner message from unexpected origin:', event.origin);
168
+ return;
169
+ }
170
+ window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
171
+ }
172
+ });
173
+ window.parent.postMessage({
174
+ jsonrpc: '2.0',
175
+ method: PROXY_READY,
176
+ params: {},
177
+ }, EXPECTED_HOST_ORIGIN);
178
+ })();
179
+ </script>
180
+ </body>
181
+ </html>`;
182
+
183
+ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
184
+ const url = new URL(req.url ?? "/", "http://placeholder");
185
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/sandbox.html")) {
186
+ let csp: McpUiResourceCsp | undefined;
187
+ const cspParam = url.searchParams.get("csp");
188
+ if (cspParam !== null) {
189
+ try {
190
+ const parsed: unknown = JSON.parse(cspParam);
191
+ if (parsed !== null && typeof parsed === "object") {
192
+ csp = parsed as McpUiResourceCsp;
193
+ }
194
+ } catch {
195
+ // Malformed ?csp= — fall through to the default (self-only) header;
196
+ // an app loading external resources will surface a CSP violation in
197
+ // the console rather than silently widening the policy.
198
+ }
199
+ }
200
+ res.setHeader("Content-Security-Policy", buildCspHeader(csp));
201
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
202
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
203
+ res.setHeader("Access-Control-Allow-Origin", "*");
204
+ res.statusCode = 200;
205
+ res.end(SANDBOX_HTML);
206
+ return;
207
+ }
208
+ res.statusCode = 404;
209
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
210
+ res.end("sandbox proxy: only GET /sandbox.html is served here\n");
211
+ }
212
+
213
+ /**
214
+ * Vite plugin: boot the sandbox proxy alongside `vite` dev, tear it down
215
+ * with it. Dev-only (`apply: "serve"`); deployed builds set
216
+ * `VITE_SANDBOX_URL` instead.
217
+ */
218
+ export function sandboxProxyPlugin(): Plugin {
219
+ let server: Server | undefined;
220
+ return {
221
+ name: "agentic-app-template:mcp-apps-sandbox-proxy",
222
+ apply: "serve",
223
+ configureServer(vite) {
224
+ server = createServer(handleRequest);
225
+ server.on("error", (err) => {
226
+ vite.config.logger.error(`[sandbox-proxy] ${String(err)}`);
227
+ });
228
+ server.listen(SANDBOX_PROXY_PORT, "127.0.0.1", () => {
229
+ vite.config.logger.info(
230
+ ` sandbox proxy: http://127.0.0.1:${SANDBOX_PROXY_PORT}/sandbox.html`
231
+ );
232
+ });
233
+ vite.httpServer?.on("close", () => {
234
+ server?.close();
235
+ server = undefined;
236
+ });
237
+ },
238
+ };
239
+ }