Setting the file. One moment. Scaffold Node Ext Apps · ChatGPT Apps · openai/skills · Skills Docsfunction usage
— line 503
This file
- Number
- 2.8
- Position
- 8 of 10
- Type
- JavaScript
- Size
- 16 KB
- Lines
- 606
scripts/scaffold_node_ext_apps.mjs
JavaScript·606 lines·16 KB
,
"-"
).
replace
(
/
^
-
+|
-
+$
/
g
,
""
);
8 return normalized || "example-chatgpt-app";
9}
10
11function toToolName(value) {
12 const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
13 return normalized || "show_example";
14}
15
16function toTitle(value) {
17 const parts = value.split(/[-_]+/).filter(Boolean);
18 return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join(" ") || "Example";
19}
20
21function fillTemplate(template, mapping) {
22 let result = template;
23 for (const [key, value] of Object.entries(mapping)) {
24 result = result.replaceAll(key, value);
25 }
26 return result;
27}
28
29function writeFile(filePath, content) {
30 mkdirSync(path.dirname(filePath), { recursive: true });
31 writeFileSync(filePath, content, "utf8");
32}
33
34function ensureTargetDir(targetPath, force) {
35 if (existsSync(targetPath)) {
36 if (!lstatSync(targetPath).isDirectory()) {
37 throw new Error(`Output path exists and is not a directory: ${targetPath}`);
38 }
39 if (readdirSync(targetPath).length > 0 && !force) {
40 throw new Error(
41 `Refusing to write into non-empty directory: ${targetPath}\nRe-run with --force to overwrite generated files.`
42 );
43 }
44 }
45
46 mkdirSync(targetPath, { recursive: true });
47}
48
49function buildPackageJson(appSlug) {
50 const packageJson = {
51 name: appSlug,
52 private: true,
53 type: "module",
54 scripts: {
55 dev: "tsx watch src/server.ts",
56 start: "tsx src/server.ts",
57 check: "tsc --noEmit",
58 },
59 dependencies: {
60 "@modelcontextprotocol/ext-apps": "^1.0.1",
61 "@modelcontextprotocol/sdk": "^1.20.2",
62 zod: "^3.25.76",
63 },
64 devDependencies: {
65 "@types/node": "^24.3.0",
66 tsx: "^4.19.4",
67 typescript: "^5.9.2",
68 },
69 };
70
71 return `${JSON.stringify(packageJson, null, 2)}\n`;
72}
73
74function buildTsconfig() {
75 return `{
76 "compilerOptions": {
77 "target": "ES2022",
78 "module": "NodeNext",
79 "moduleResolution": "NodeNext",
80 "strict": true,
81 "esModuleInterop": true,
82 "skipLibCheck": true,
83 "types": ["node"],
84 "outDir": "dist"
85 },
86 "include": ["src/**/*.ts"]
87}
88`;
89}
90
91const WIDGET_TEMPLATE = `<!DOCTYPE html>
92<html lang="en">
93 <head>
94 <meta charset="utf-8" />
95 <meta name="viewport" content="width=device-width, initial-scale=1" />
96 <title>__APP_TITLE__</title>
97 <style>
98 :root {
99 color: #0b0f19;
100 font-family: "Inter", system-ui, sans-serif;
101 }
102
103 * {
104 box-sizing: border-box;
105 }
106
107 body {
108 margin: 0;
109 min-height: 100vh;
110 padding: 16px;
111 background:
112 radial-gradient(circle at top right, #d8f3ff 0, transparent 40%),
113 linear-gradient(180deg, #f7fbff 0%, #edf3fb 100%);
114 }
115
116 main {
117 width: 100%;
118 max-width: 420px;
119 margin: 0 auto;
120 padding: 20px;
121 border-radius: 18px;
122 background: rgba(255, 255, 255, 0.92);
123 border: 1px solid rgba(11, 15, 25, 0.08);
124 box-shadow: 0 14px 32px rgba(11, 15, 25, 0.08);
125 }
126
127 .eyebrow {
128 margin: 0 0 8px;
129 font-size: 12px;
130 font-weight: 700;
131 letter-spacing: 0.12em;
132 text-transform: uppercase;
133 color: #4f5d75;
134 }
135
136 h1 {
137 margin: 0 0 10px;
138 font-size: 24px;
139 line-height: 1.15;
140 }
141
142 p {
143 margin: 0;
144 line-height: 1.5;
145 }
146
147 .stack {
148 display: grid;
149 gap: 12px;
150 }
151
152 button {
153 border: 0;
154 border-radius: 999px;
155 padding: 10px 14px;
156 font: inherit;
157 font-weight: 600;
158 color: white;
159 background: #0f62fe;
160 cursor: pointer;
161 }
162
163 button[hidden] {
164 display: none;
165 }
166
167 button.secondary {
168 background: #0b0f19;
169 }
170
171 .meta {
172 padding: 12px;
173 border-radius: 14px;
174 background: #f5f8fc;
175 color: #4f5d75;
176 font-size: 13px;
177 }
178 </style>
179 </head>
180 <body>
181 <main class="stack">
182 <p class="eyebrow">__APP_TITLE__ starter</p>
183 <h1 id="headline">Waiting for tool output</h1>
184 <p id="message">Call the __TOOL_NAME__ tool to hydrate this widget.</p>
185 <button id="tool-button" type="button">Call __TOOL_NAME__ from the widget</button>
186 <button id="follow-up-button" class="secondary" type="button">
187 Ask the host to explain this app
188 </button>
189 <div class="meta" id="meta">
190 This widget uses the MCP Apps bridge by default.
191 </div>
192 </main>
193
194 <script type="module">
195 const headlineEl = document.querySelector("#headline");
196 const messageEl = document.querySelector("#message");
197 const metaEl = document.querySelector("#meta");
198 const toolButtonEl = document.querySelector("#tool-button");
199 const followUpButtonEl = document.querySelector("#follow-up-button");
200
201 let toolOutput = null;
202 let rpcId = 0;
203 const pendingRequests = new Map();
204
205 const render = () => {
206 const headline = toolOutput?.headline ?? "__APP_TITLE__";
207 const message =
208 toolOutput?.message ??
209 "Call the __TOOL_NAME__ tool to hydrate this widget.";
210
211 headlineEl.textContent = headline;
212 messageEl.textContent = message;
213
214 const theme = window.openai?.theme ?? "bridge-only";
215 metaEl.textContent =
216 "Runtime: " +
217 (window.openai ? "MCP Apps bridge + optional window.openai" : "MCP Apps bridge only") +
218 " | Theme: " +
219 theme;
220 };
221
222 const rpcNotify = (method, params) => {
223 window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
224 };
225
226 const rpcRequest = (method, params) =>
227 new Promise((resolve, reject) => {
228 const id = ++rpcId;
229 pendingRequests.set(id, { resolve, reject });
230 window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*");
231 });
232
233 window.addEventListener(
234 "message",
235 (event) => {
236 if (event.source !== window.parent) {
237 return;
238 }
239
240 const message = event.data;
241 if (!message || message.jsonrpc !== "2.0") {
242 return;
243 }
244
245 if (typeof message.id === "number") {
246 const pending = pendingRequests.get(message.id);
247 if (!pending) {
248 return;
249 }
250
251 pendingRequests.delete(message.id);
252 if (message.error) {
253 pending.reject(message.error);
254 return;
255 }
256
257 pending.resolve(message.result);
258 return;
259 }
260
261 if (message.method === "ui/notifications/tool-result") {
262 toolOutput = message.params?.structuredContent ?? null;
263 render();
264 }
265 },
266 { passive: true }
267 );
268
269 const initializeBridge = async () => {
270 await rpcRequest("ui/initialize", {
271 appInfo: { name: "__APP_SLUG__-widget", version: "0.1.0" },
272 appCapabilities: {},
273 protocolVersion: "2026-01-26",
274 });
275 rpcNotify("ui/notifications/initialized", {});
276 };
277
278 const bridgeReady = initializeBridge();
279
280 toolButtonEl.addEventListener("click", async () => {
281 await bridgeReady;
282
283 const response = await rpcRequest("tools/call", {
284 name: "__TOOL_NAME__",
285 arguments: {
286 message: "Tool call triggered from the widget.",
287 },
288 });
289
290 toolOutput = response?.structuredContent ?? toolOutput;
291 render();
292 });
293
294 followUpButtonEl.addEventListener("click", async () => {
295 await bridgeReady;
296
297 rpcNotify("ui/message", {
298 role: "user",
299 content: [
300 {
301 type: "text",
302 text: "Explain how the __TOOL_NAME__ widget works.",
303 },
304 ],
305 });
306 });
307
308 render();
309 </script>
310 </body>
311</html>
312`;
313
314const SERVER_TEMPLATE = `import { createServer } from "node:http";
315import { readFileSync } from "node:fs";
316import path from "node:path";
317import { fileURLToPath } from "node:url";
318
319import {
320 registerAppResource,
321 registerAppTool,
322 RESOURCE_MIME_TYPE,
323} from "@modelcontextprotocol/ext-apps/server";
324import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
325import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
326import { z } from "zod";
327
328const __dirname = path.dirname(fileURLToPath(import.meta.url));
329const ROOT_DIR = path.resolve(__dirname, "..");
330const WIDGET_URI = "__WIDGET_URI__";
331const WIDGET_HTML = readFileSync(
332 path.join(ROOT_DIR, "public", "widget.html"),
333 "utf8"
334);
335
336function createAppServer(): McpServer {
337 const server = new McpServer({
338 name: "__APP_SLUG__",
339 version: "0.1.0",
340 });
341
342 registerAppResource(
343 server,
344 "main-widget",
345 WIDGET_URI,
346 {},
347 async () => ({
348 contents: [
349 {
350 uri: WIDGET_URI,
351 mimeType: RESOURCE_MIME_TYPE,
352 text: WIDGET_HTML,
353 _meta: {
354 ui: {
355 prefersBorder: true,
356 csp: {
357 connectDomains: [],
358 resourceDomains: [],
359 },
360 },
361 "openai/widgetDescription":
362 "__APP_TITLE__ starter widget rendered by the MCP server.",
363 },
364 },
365 ],
366 })
367 );
368
369 registerAppTool(
370 server,
371 "__TOOL_NAME__",
372 {
373 title: "__APP_TITLE__",
374 description:
375 "Use this when the user wants to render the __APP_TITLE__ starter widget or inspect a minimal Apps SDK tool result.",
376 inputSchema: {
377 message: z
378 .string()
379 .optional()
380 .describe("Optional message to show inside the widget."),
381 },
382 annotations: {
383 readOnlyHint: true,
384 destructiveHint: false,
385 openWorldHint: false,
386 idempotentHint: true,
387 },
388 _meta: {
389 ui: { resourceUri: WIDGET_URI },
390 "openai/toolInvocation/invoking": "Loading __APP_TITLE__",
391 "openai/toolInvocation/invoked": "__APP_TITLE__ ready",
392 },
393 },
394 async ({ message }) => {
395 const resolvedMessage =
396 message?.trim() ||
397 "This starter uses the MCP Apps bridge first, keeps follow-up messaging on ui/message, and limits window.openai to optional host signals.";
398
399 return {
400 content: [
401 {
402 type: "text" as const,
403 text: "Rendered the __APP_TITLE__ starter widget.",
404 },
405 ],
406 structuredContent: {
407 headline: "__APP_TITLE__",
408 message: resolvedMessage,
409 source: "__TOOL_NAME__",
410 themeHint:
411 "Read window.openai.theme in the widget if you need ChatGPT theme information.",
412 },
413 _meta: {
414 "openai/outputTemplate": WIDGET_URI,
415 },
416 };
417 }
418 );
419
420 return server;
421}
422
423const port = Number(process.env.PORT ?? "__PORT__");
424const MCP_PATH = "/mcp";
425
426createServer(async (req, res) => {
427 if (!req.url) {
428 res.writeHead(400).end("Missing URL");
429 return;
430 }
431
432 const url = new URL(req.url, "http://" + (req.headers.host ?? "localhost"));
433 const isMcpRoute = url.pathname === MCP_PATH || url.pathname.startsWith(MCP_PATH + "/");
434
435 if (req.method === "OPTIONS" && isMcpRoute) {
436 res.writeHead(204, {
437 "Access-Control-Allow-Origin": "*",
438 "Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
439 "Access-Control-Allow-Headers": "content-type, mcp-session-id",
440 "Access-Control-Expose-Headers": "Mcp-Session-Id",
441 });
442 res.end();
443 return;
444 }
445
446 if (req.method === "GET" && url.pathname === "/") {
447 res.writeHead(200, { "content-type": "text/plain" }).end("__APP_TITLE__ MCP server");
448 return;
449 }
450
451 const transportMethods = new Set(["GET", "POST", "DELETE"]);
452 if (isMcpRoute && req.method && transportMethods.has(req.method)) {
453 res.setHeader("Access-Control-Allow-Origin", "*");
454 res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
455
456 const server = createAppServer();
457 const transport = new StreamableHTTPServerTransport({
458 sessionIdGenerator: undefined,
459 enableJsonResponse: true,
460 });
461
462 res.on("close", () => {
463 transport.close();
464 server.close();
465 });
466
467 try {
468 await server.connect(transport);
469 await transport.handleRequest(req, res);
470 } catch (error) {
471 console.error("Failed to handle MCP request:", error);
472 if (!res.headersSent) {
473 res.writeHead(500).end("Internal server error");
474 }
475 }
476 return;
477 }
478
479 res.writeHead(404).end("Not Found");
480}).listen(port, () => {
481 console.log("__APP_TITLE__ MCP server listening on http://localhost:" + port + MCP_PATH);
482});
483`;
484
485function buildWidgetHtml(appSlug, appTitle, toolName) {
486 return fillTemplate(WIDGET_TEMPLATE, {
487 "__APP_SLUG__": appSlug,
488 "__APP_TITLE__": appTitle,
489 "__TOOL_NAME__": toolName,
490 });
491}
492
493function buildServerTs(appSlug, appTitle, toolName, widgetUri, port) {
494 return fillTemplate(SERVER_TEMPLATE, {
495 "__APP_SLUG__": appSlug,
496 "__APP_TITLE__": appTitle,
497 "__TOOL_NAME__": toolName,
498 "__WIDGET_URI__": widgetUri,
499 "__PORT__": String(port),
500 });
501}
502
503function usage() {
504 return [
505 "Generate a minimal Node + @modelcontextprotocol/ext-apps starter with a vanilla widget that uses the MCP Apps bridge by default.",
506 "Prefer upstream examples first; use this scaffold as the fallback.",
507 "",
508 "Usage:",
509 " ./scripts/scaffold_node_ext_apps.mjs <output_dir> [--app-name <name>] [--tool-name <name>] [--port <number>] [--force]",
510 "",
511 "If the executable bit is unavailable, run:",
512 " node scripts/scaffold_node_ext_apps.mjs <output_dir> [--app-name <name>] [--tool-name <name>] [--port <number>] [--force]",
513 ].join("\\n");
514}
515
516function parseArgs(argv) {
517 const args = {
518 outputDir: null,
519 appName: "example-chatgpt-app",
520 toolName: null,
521 port: 8787,
522 force: false,
523 };
524
525 const tokens = [...argv];
526 while (tokens.length > 0) {
527 const token = tokens.shift();
528
529 if (!args.outputDir && !token.startsWith("--")) {
530 args.outputDir = token;
531 continue;
532 }
533
534 if (token === "--app-name") {
535 args.appName = tokens.shift() ?? "";
536 continue;
537 }
538
539 if (token === "--tool-name") {
540 args.toolName = tokens.shift() ?? "";
541 continue;
542 }
543
544 if (token === "--port") {
545 const value = Number(tokens.shift());
546 if (!Number.isInteger(value) || value <= 0) {
547 throw new Error("Expected a positive integer after --port");
548 }
549 args.port = value;
550 continue;
551 }
552
553 if (token === "--force") {
554 args.force = true;
555 continue;
556 }
557
558 if (token === "--help" || token === "-h") {
559 console.log(usage());
560 process.exit(0);
561 }
562
563 throw new Error(`Unknown argument: ${token}`);
564 }
565
566 if (!args.outputDir) {
567 throw new Error(`Missing required output directory.\\n\\n${usage()}`);
568 }
569
570 return args;
571}
572
573function main() {
574 const args = parseArgs(process.argv.slice(2));
575
576 const appSlug = toSlug(args.appName);
577 const toolName = toToolName(args.toolName || appSlug);
578 const appTitle = toTitle(appSlug);
579 const widgetUri = "ui://widget/main-v1.html";
580
581 const outputDir = path.resolve(args.outputDir);
582 ensureTargetDir(outputDir, args.force);
583
584 const files = new Map([
585 [path.join(outputDir, "package.json"), buildPackageJson(appSlug)],
586 [path.join(outputDir, "tsconfig.json"), buildTsconfig()],
587 [path.join(outputDir, "public", "widget.html"), buildWidgetHtml(appSlug, appTitle, toolName)],
588 [path.join(outputDir, "src", "server.ts"), buildServerTs(appSlug, appTitle, toolName, widgetUri, args.port)],
589 ]);
590
591 for (const [filePath, content] of files) {
592 writeFile(filePath, content);
593 }
594
595 console.log("Generated starter scaffold:");
596 for (const filePath of files.keys()) {
597 console.log(" -", path.relative(outputDir, filePath));
598 }
599}
600
601try {
602 main();
603} catch (error) {
604 console.error(error instanceof Error ? error.message : String(error));
605 process.exit(1);
606}