READ-ONLY PACKAGE PREVIEW

openai-chatgpt-apps/scripts/scaffold_node_ext_apps.mjs

Version 49f948fa.bb1 · Apache-2.0. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.

← Return to resource and package checksum

#!/usr/bin/env node

import { mkdirSync, writeFileSync, existsSync, readdirSync, lstatSync } from "node:fs";
import path from "node:path";

function toSlug(value) {
  const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
  return normalized || "example-chatgpt-app";
}

function toToolName(value) {
  const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
  return normalized || "show_example";
}

function toTitle(value) {
  const parts = value.split(/[-_]+/).filter(Boolean);
  return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join(" ") || "Example";
}

function fillTemplate(template, mapping) {
  let result = template;
  for (const [key, value] of Object.entries(mapping)) {
    result = result.replaceAll(key, value);
  }
  return result;
}

function writeFile(filePath, content) {
  mkdirSync(path.dirname(filePath), { recursive: true });
  writeFileSync(filePath, content, "utf8");
}

function ensureTargetDir(targetPath, force) {
  if (existsSync(targetPath)) {
    if (!lstatSync(targetPath).isDirectory()) {
      throw new Error(`Output path exists and is not a directory: ${targetPath}`);
    }
    if (readdirSync(targetPath).length > 0 && !force) {
      throw new Error(
        `Refusing to write into non-empty directory: ${targetPath}\nRe-run with --force to overwrite generated files.`
      );
    }
  }

  mkdirSync(targetPath, { recursive: true });
}

function buildPackageJson(appSlug) {
  const packageJson = {
    name: appSlug,
    private: true,
    type: "module",
    scripts: {
      dev: "tsx watch src/server.ts",
      start: "tsx src/server.ts",
      check: "tsc --noEmit",
    },
    dependencies: {
      "@modelcontextprotocol/ext-apps": "^1.0.1",
      "@modelcontextprotocol/sdk": "^1.20.2",
      zod: "^3.25.76",
    },
    devDependencies: {
      "@types/node": "^24.3.0",
      tsx: "^4.19.4",
      typescript: "^5.9.2",
    },
  };

  return `${JSON.stringify(packageJson, null, 2)}\n`;
}

function buildTsconfig() {
  return `{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"],
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}
`;
}

const WIDGET_TEMPLATE = `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>__APP_TITLE__</title>
    <style>
      :root {
        color: #0b0f19;
        font-family: "Inter", system-ui, sans-serif;
      }

      * {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        min-height: 100vh;
        padding: 16px;
        background:
          radial-gradient(circle at top right, #d8f3ff 0, transparent 40%),
          linear-gradient(180deg, #f7fbff 0%, #edf3fb 100%);
      }

      main {
        width: 100%;
        max-width: 420px;
        margin: 0 auto;
        padding: 20px;
        border-radius: 18px;
        background: rgba(255, 255, 255, 0.92);
        border: 1px solid rgba(11, 15, 25, 0.08);
        box-shadow: 0 14px 32px rgba(11, 15, 25, 0.08);
      }

      .eyebrow {
        margin: 0 0 8px;
        font-size: 12px;
        font-weight: 700;
        letter-spacing: 0.12em;
        text-transform: uppercase;
        color: #4f5d75;
      }

      h1 {
        margin: 0 0 10px;
        font-size: 24px;
        line-height: 1.15;
      }

      p {
        margin: 0;
        line-height: 1.5;
      }

      .stack {
        display: grid;
        gap: 12px;
      }

      button {
        border: 0;
        border-radius: 999px;
        padding: 10px 14px;
        font: inherit;
        font-weight: 600;
        color: white;
        background: #0f62fe;
        cursor: pointer;
      }

      button[hidden] {
        display: none;
      }

      button.secondary {
        background: #0b0f19;
      }

      .meta {
        padding: 12px;
        border-radius: 14px;
        background: #f5f8fc;
        color: #4f5d75;
        font-size: 13px;
      }
    </style>
  </head>
  <body>
    <main class="stack">
      <p class="eyebrow">__APP_TITLE__ starter</p>
      <h1 id="headline">Waiting for tool output</h1>
      <p id="message">Call the __TOOL_NAME__ tool to hydrate this widget.</p>
      <button id="tool-button" type="button">Call __TOOL_NAME__ from the widget</button>
      <button id="follow-up-button" class="secondary" type="button">
        Ask the host to explain this app
      </button>
      <div class="meta" id="meta">
        This widget uses the MCP Apps bridge by default.
      </div>
    </main>

    <script type="module">
      const headlineEl = document.querySelector("#headline");
      const messageEl = document.querySelector("#message");
      const metaEl = document.querySelector("#meta");
      const toolButtonEl = document.querySelector("#tool-button");
      const followUpButtonEl = document.querySelector("#follow-up-button");

      let toolOutput = null;
      let rpcId = 0;
      const pendingRequests = new Map();

      const render = () => {
        const headline = toolOutput?.headline ?? "__APP_TITLE__";
        const message =
          toolOutput?.message ??
          "Call the __TOOL_NAME__ tool to hydrate this widget.";

        headlineEl.textContent = headline;
        messageEl.textContent = message;

        const theme = window.openai?.theme ?? "bridge-only";
        metaEl.textContent =
          "Runtime: " +
          (window.openai ? "MCP Apps bridge + optional window.openai" : "MCP Apps bridge only") +
          " | Theme: " +
          theme;
      };

      const rpcNotify = (method, params) => {
        window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
      };

      const rpcRequest = (method, params) =>
        new Promise((resolve, reject) => {
          const id = ++rpcId;
          pendingRequests.set(id, { resolve, reject });
          window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
        });

      window.addEventListener(
        "message",
        (event) => {
          if (event.source !== window.parent) {
            return;
          }

          const message = event.data;
          if (!message || message.jsonrpc !== "2.0") {
            return;
          }

          if (typeof message.id === "number") {
            const pending = pendingRequests.get(message.id);
            if (!pending) {
              return;
            }

            pendingRequests.delete(message.id);
            if (message.error) {
              pending.reject(message.error);
              return;
            }

            pending.resolve(message.result);
            return;
          }

          if (message.method === "ui/notifications/tool-result") {
            toolOutput = message.params?.structuredContent ?? null;
            render();
          }
        },
        { passive: true }
      );

      const initializeBridge = async () => {
        await rpcRequest("ui/initialize", {
          appInfo: { name: "__APP_SLUG__-widget", version: "0.1.0" },
          appCapabilities: {},
          protocolVersion: "2026-01-26",
        });
        rpcNotify("ui/notifications/initialized", {});
      };

      const bridgeReady = initializeBridge();

      toolButtonEl.addEventListener("click", async () => {
        await bridgeReady;

        const response = await rpcRequest("tools/call", {
          name: "__TOOL_NAME__",
          arguments: {
            message: "Tool call triggered from the widget.",
          },
        });

        toolOutput = response?.structuredContent ?? toolOutput;
        render();
      });

      followUpButtonEl.addEventListener("click", async () => {
        await bridgeReady;

        rpcNotify("ui/message", {
          role: "user",
          content: [
            {
              type: "text",
              text: "Explain how the __TOOL_NAME__ widget works.",
            },
          ],
        });
      });

      render();
    </script>
  </body>
</html>
`;

const SERVER_TEMPLATE = `import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const WIDGET_URI = "__WIDGET_URI__";
const WIDGET_HTML = readFileSync(
  path.join(ROOT_DIR, "public", "widget.html"),
  "utf8"
);

function createAppServer(): McpServer {
  const server = new McpServer({
    name: "__APP_SLUG__",
    version: "0.1.0",
  });

  registerAppResource(
    server,
    "main-widget",
    WIDGET_URI,
    {},
    async () => ({
      contents: [
        {
          uri: WIDGET_URI,
          mimeType: RESOURCE_MIME_TYPE,
          text: WIDGET_HTML,
          _meta: {
            ui: {
              prefersBorder: true,
              csp: {
                connectDomains: [],
                resourceDomains: [],
              },
            },
            "openai/widgetDescription":
              "__APP_TITLE__ starter widget rendered by the MCP server.",
          },
        },
      ],
    })
  );

  registerAppTool(
    server,
    "__TOOL_NAME__",
    {
      title: "__APP_TITLE__",
      description:
        "Use this when the user wants to render the __APP_TITLE__ starter widget or inspect a minimal Apps SDK tool result.",
      inputSchema: {
        message: z
          .string()
          .optional()
          .describe("Optional message to show inside the widget."),
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: false,
        idempotentHint: true,
      },
      _meta: {
        ui: { resourceUri: WIDGET_URI },
        "openai/toolInvocation/invoking": "Loading __APP_TITLE__",
        "openai/toolInvocation/invoked": "__APP_TITLE__ ready",
      },
    },
    async ({ message }) => {
      const resolvedMessage =
        message?.trim() ||
        "This starter uses the MCP Apps bridge first, keeps follow-up messaging on ui/message, and limits window.openai to optional host signals.";

      return {
        content: [
          {
            type: "text" as const,
            text: "Rendered the __APP_TITLE__ starter widget.",
          },
        ],
        structuredContent: {
          headline: "__APP_TITLE__",
          message: resolvedMessage,
          source: "__TOOL_NAME__",
          themeHint:
            "Read window.openai.theme in the widget if you need ChatGPT theme information.",
        },
        _meta: {
          "openai/outputTemplate": WIDGET_URI,
        },
      };
    }
  );

  return server;
}

const port = Number(process.env.PORT ?? "__PORT__");
const MCP_PATH = "/mcp";

createServer(async (req, res) => {
  if (!req.url) {
    res.writeHead(400).end("Missing URL");
    return;
  }

  const url = new URL(req.url, "http://" + (req.headers.host ?? "localhost"));
  const isMcpRoute = url.pathname === MCP_PATH || url.pathname.startsWith(MCP_PATH + "/");

  if (req.method === "OPTIONS" && isMcpRoute) {
    res.writeHead(204, {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "content-type, mcp-session-id",
      "Access-Control-Expose-Headers": "Mcp-Session-Id",
    });
    res.end();
    return;
  }

  if (req.method === "GET" && url.pathname === "/") {
    res.writeHead(200, { "content-type": "text/plain" }).end("__APP_TITLE__ MCP server");
    return;
  }

  const transportMethods = new Set(["GET", "POST", "DELETE"]);
  if (isMcpRoute && req.method && transportMethods.has(req.method)) {
    res.setHeader("Access-Control-Allow-Origin", "*");
    res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");

    const server = createAppServer();
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
      enableJsonResponse: true,
    });

    res.on("close", () => {
      transport.close();
      server.close();
    });

    try {
      await server.connect(transport);
      await transport.handleRequest(req, res);
    } catch (error) {
      console.error("Failed to handle MCP request:", error);
      if (!res.headersSent) {
        res.writeHead(500).end("Internal server error");
      }
    }
    return;
  }

  res.writeHead(404).end("Not Found");
}).listen(port, () => {
  console.log("__APP_TITLE__ MCP server listening on http://localhost:" + port + MCP_PATH);
});
`;

function buildWidgetHtml(appSlug, appTitle, toolName) {
  return fillTemplate(WIDGET_TEMPLATE, {
    "__APP_SLUG__": appSlug,
    "__APP_TITLE__": appTitle,
    "__TOOL_NAME__": toolName,
  });
}

function buildServerTs(appSlug, appTitle, toolName, widgetUri, port) {
  return fillTemplate(SERVER_TEMPLATE, {
    "__APP_SLUG__": appSlug,
    "__APP_TITLE__": appTitle,
    "__TOOL_NAME__": toolName,
    "__WIDGET_URI__": widgetUri,
    "__PORT__": String(port),
  });
}

function usage() {
  return [
    "Generate a minimal Node + @modelcontextprotocol/ext-apps starter with a vanilla widget that uses the MCP Apps bridge by default.",
    "Prefer upstream examples first; use this scaffold as the fallback.",
    "",
    "Usage:",
    "  ./scripts/scaffold_node_ext_apps.mjs <output_dir> [--app-name <name>] [--tool-name <name>] [--port <number>] [--force]",
    "",
    "If the executable bit is unavailable, run:",
    "  node scripts/scaffold_node_ext_apps.mjs <output_dir> [--app-name <name>] [--tool-name <name>] [--port <number>] [--force]",
  ].join("\\n");
}

function parseArgs(argv) {
  const args = {
    outputDir: null,
    appName: "example-chatgpt-app",
    toolName: null,
    port: 8787,
    force: false,
  };

  const tokens = [...argv];
  while (tokens.length > 0) {
    const token = tokens.shift();

    if (!args.outputDir && !token.startsWith("--")) {
      args.outputDir = token;
      continue;
    }

    if (token === "--app-name") {
      args.appName = tokens.shift() ?? "";
      continue;
    }

    if (token === "--tool-name") {
      args.toolName = tokens.shift() ?? "";
      continue;
    }

    if (token === "--port") {
      const value = Number(tokens.shift());
      if (!Number.isInteger(value) || value <= 0) {
        throw new Error("Expected a positive integer after --port");
      }
      args.port = value;
      continue;
    }

    if (token === "--force") {
      args.force = true;
      continue;
    }

    if (token === "--help" || token === "-h") {
      console.log(usage());
      process.exit(0);
    }

    throw new Error(`Unknown argument: ${token}`);
  }

  if (!args.outputDir) {
    throw new Error(`Missing required output directory.\\n\\n${usage()}`);
  }

  return args;
}

function main() {
  const args = parseArgs(process.argv.slice(2));

  const appSlug = toSlug(args.appName);
  const toolName = toToolName(args.toolName || appSlug);
  const appTitle = toTitle(appSlug);
  const widgetUri = "ui://widget/main-v1.html";

  const outputDir = path.resolve(args.outputDir);
  ensureTargetDir(outputDir, args.force);

  const files = new Map([
    [path.join(outputDir, "package.json"), buildPackageJson(appSlug)],
    [path.join(outputDir, "tsconfig.json"), buildTsconfig()],
    [path.join(outputDir, "public", "widget.html"), buildWidgetHtml(appSlug, appTitle, toolName)],
    [path.join(outputDir, "src", "server.ts"), buildServerTs(appSlug, appTitle, toolName, widgetUri, args.port)],
  ]);

  for (const [filePath, content] of files) {
    writeFile(filePath, content);
  }

  console.log("Generated starter scaffold:");
  for (const filePath of files.keys()) {
    console.log(" -", path.relative(outputDir, filePath));
  }
}

try {
  main();
} catch (error) {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
}