#!/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 = ` __APP_TITLE__

__APP_TITLE__ starter

Waiting for tool output

Call the __TOOL_NAME__ tool to hydrate this widget.

This widget uses the MCP Apps bridge by default.
`; 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 [--app-name ] [--tool-name ] [--port ] [--force]", "", "If the executable bit is unavailable, run:", " node scripts/scaffold_node_ext_apps.mjs [--app-name ] [--tool-name ] [--port ] [--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); }