#!/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);
}