OpenAI Apps SDK TypeScript Quickstart

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The one thing that makes the widget render

A ChatGPT app is an MCP server with a UI layer (OpenAI Apps SDK, MCP Apps). OpenAI's docs organize distribution under plugins, where a plugin bundles skills, an MCP server, and optional UI; the app you build here is the MCP server and UI part (plugin architecture). The single mistake that breaks more first apps than anything else: serving the widget HTML from your own /widget URL and pointing the tool at that URL. ChatGPT will call your tool, get text back, and render nothing.

The widget is not a web page ChatGPT fetches over HTTP. It is an MCP resource with a ui:// URI, served over the same MCP connection as your tools. The tool announces which resource is its UI via _meta, and the URI in _meta must be byte-for-byte the URI you registered the resource under. Get the two strings to match and the widget renders; mismatch them (or serve an https:// URL instead) and you get a silent blank (per OpenAI's troubleshooting docs, the tool descriptor's _meta.ui.resourceUri must point to a registered HTML resource whose URI matches exactly, or the component won't load).

So a minimal app is exactly three registrations on one McpServer:

  1. A resource at ui://greeting/widget.html whose content is your HTML.
  2. A tool whose _meta points back at that exact URI.
  3. A Streamable HTTP endpoint at /mcp that carries both.

Everything below builds that, then connects it to ChatGPT.

Three things to get right

Three details decide whether the widget renders at all. Get these right up front and the rest is wiring.

  • McpServer, not the low-level Server. registerTool / registerResource are methods on McpServer (@modelcontextprotocol/sdk/server/mcp.js). The low-level Server (server/index.js) has no registerTool method, so code written against it fails immediately. (MCP TypeScript SDK README: "The McpServer is your core interface... registerTool(), registerResource()".)
  • The widget binds to the tool via _meta, and the standard key is _meta.ui.resourceUri. ChatGPT also honors the older _meta["openai/outputTemplate"] as a compatibility alias; _meta.ui.resourceUri alone is sufficient, but setting both is harmless if you want belt-and-suspenders compatibility. (OpenAI reference: openai/outputTemplate is the "OpenAI-specific optional/compatibility alias for _meta.ui.resourceUri".)
  • The MIME type is text/html;profile=mcp-app (exposed as the RESOURCE_MIME_TYPE constant). The old text/html+skybridge is a legacy ChatGPT alias still accepted, but new code should use the constant. (OpenAI build/chatgpt-ui: "Expose the component as an MCP resource with the MCP Apps UI MIME type (text/html;profile=mcp-app)... prefer RESOURCE_MIME_TYPE instead of embedding the string.")

The cleanest way to get all three right is the MCP Apps extension SDK (@modelcontextprotocol/ext-apps), whose registerAppTool / registerAppResource helpers default the MIME type and wire the _meta for you. It is the joint OpenAI + Anthropic standard formalized in SEP-1865, and it ships as the first official MCP extension, with support in ChatGPT, Claude, Goose, and Visual Studio Code (MCP Apps blog). We use it here.

Prerequisites

  • Node.js 18 or higher. (modelcontextprotocol.io build guide: "You'll need Node.js 18 or higher.")
  • A way to expose localhost over HTTPS (ChatGPT connectors require a public URL). This guide uses ngrok; cloudflared works too.
  • ChatGPT with Developer mode available (Settings → Security and login); availability can depend on your account and workspace policy (OpenAI connect and test). Publishing to everyone comes later, through the plugin directory's submission review (OpenAI submission).

Install

$npm init -y
$npm pkg set type=module
$npm install @modelcontextprotocol/sdk@1.29.0 @modelcontextprotocol/ext-apps@1.7.4 express@5.2.1 cors@2.8.6 zod@4.4.3
$npm install -D typescript@6.0.3 tsx@4.22.4 @types/node@26.0.1 @types/express@5.0.6 @types/cors@2.8.19

@modelcontextprotocol/ext-apps carries both the server helpers (registerAppTool, registerAppResource) and the constants we need. (npm: @modelcontextprotocol/ext-apps; install command shape from modelcontextprotocol.io build guide: "npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk".)

A minimal tsconfig.json (Node ESM, modern target):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

(Config shape from modelcontextprotocol.io build guide.)

The widget HTML

The widget runs in a sandboxed iframe. Inside it, ChatGPT exposes a window.openai object: window.openai.toolOutput holds the structuredContent your tool returned, and an openai:set_globals event fires whenever the host pushes new globals in (OpenAI build/chatgpt-ui; OpenAI reference: "toolOutput is your structuredContent").

Because the iframe runs under a deny-by-default CSP, the reliable pattern for a quickstart is self-contained HTML with no external script loads: render with plain DOM, not a CDN React import (OpenAI troubleshooting: "Make sure the HTML contains your compiled JavaScript and that the bundle contains all dependencies."). Once you outgrow this, bundle a real component with Vite + vite-plugin-singlefile and serve the single output file as the resource text (modelcontextprotocol.io build guide).

Create src/widget.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Greeting</title>
    <style>
      :root { color-scheme: light dark; }
      body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
      .card { border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
              border-radius: 12px; padding: 16px; }
      .greeting { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
      .meta { opacity: 0.6; font-size: 13px; margin: 0; }
    </style>
  </head>
  <body>
    <div class="card">
      <p class="greeting" id="greeting">Loading…</p>
      <p class="meta" id="meta"></p>
    </div>

    <script type="module">
      // window.openai.toolOutput holds the tool's structuredContent.
      // It may be undefined on first paint, so also listen for set_globals.
      function render(output) {
        if (!output) return;
        document.getElementById("greeting").textContent = output.greeting;
        document.getElementById("meta").textContent =
          "Generated " + new Date(output.timestamp).toLocaleTimeString();
      }

      render(window.openai?.toolOutput);

      window.addEventListener("openai:set_globals", (event) => {
        render(event.detail?.globals?.toolOutput);
      });
    </script>
  </body>
</html>

The render(window.openai?.toolOutput) call plus the openai:set_globals listener is the documented vanilla-JS read pattern. toolOutput can be undefined on the first paint, and the event fires once the host has the data ready (OpenAI build/chatgpt-ui: listen for openai:set_globals, read event.detail?.globals?.toolOutput).

The server

This is the whole app: one McpServer, one resource registered at a ui:// URI, one tool whose _meta points back at that same URI, exposed over stateless Streamable HTTP. The structure follows OpenAI's own mcp_app_basics_node example.

Create src/server.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
import express from "express";
import cors from "cors";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// This exact string is the contract: the resource is registered under it,
// and the tool's _meta points at it. They must match byte-for-byte.
const WIDGET_URI = "ui://greeting/widget.html";

const widgetHtml = readFileSync(path.join(__dirname, "widget.html"), "utf-8");

function createServer(): McpServer {
  const server = new McpServer({ name: "greeting-app", version: "1.0.0" });

  // 1. Register the widget as a ui:// MCP resource.
  registerAppResource(
    server,
    "Greeting widget",
    WIDGET_URI,
    { mimeType: RESOURCE_MIME_TYPE },
    async () => ({
      contents: [
        { uri: WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml },
      ],
    }),
  );

  // 2. Register the tool, bound to the resource via _meta.
  registerAppTool(
    server,
    "greet_user",
    {
      title: "Greet user",
      description:
        "Render a personalized greeting card. Use this when the user asks to " +
        "be greeted or welcomed by name.",
      inputSchema: { name: z.string().describe("The name to greet") },
      _meta: {
        // Cross-host MCP Apps standard key:
        ui: { resourceUri: WIDGET_URI },
        // ChatGPT compatibility alias + status strings:
        "openai/outputTemplate": WIDGET_URI,
        "openai/toolInvocation/invoking": "Creating greeting…",
        "openai/toolInvocation/invoked": "Greeting ready.",
      },
    },
    async ({ name }) => {
      const who = name?.trim() || "World";
      return {
        // structuredContent becomes window.openai.toolOutput in the widget.
        structuredContent: {
          greeting: `Hello, ${who}!`,
          timestamp: new Date().toISOString(),
        },
        // content is the model-facing narration.
        content: [{ type: "text", text: `Greeted ${who}.` }],
      };
    },
  );

  return server;
}

// Stateless Streamable HTTP: a fresh server + transport per request.
const app = express();
app.use(cors());
app.use(express.json());

app.all("/mcp", async (req, res) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  res.on("close", () => {
    transport.close().catch(() => {});
    server.close().catch(() => {});
  });
  try {
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  } catch (error) {
    console.error("MCP error:", error);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: "2.0",
        error: { code: -32603, message: "Internal server error" },
        id: null,
      });
    }
  }
});

const PORT = Number(process.env.PORT ?? 8000);
app.listen(PORT, () => {
  console.log(`MCP app listening on http://localhost:${PORT}/mcp`);
});

Three things in this file are doing the real work, and they map exactly to the three rules at the top:

  • registerAppResource(server, name, WIDGET_URI, { mimeType: RESOURCE_MIME_TYPE }, handler) serves the HTML as a ui:// resource. RESOURCE_MIME_TYPE is text/html;profile=mcp-app; using the constant means you can never typo it (OpenAI build/chatgpt-ui). Signature and shape are from OpenAI's mcp_app_basics_node and the modelcontextprotocol.io build guide.
  • registerAppTool(server, name, { ..., _meta }, handler) registers the tool. _meta.ui.resourceUri is what registerAppTool keys on (MCP Apps build guide: "Registers a tool with the _meta.ui.resourceUri field. When the host calls this tool, the UI is fetched and rendered."); the openai/* keys make ChatGPT render the same widget today and show status text while the tool runs.
  • The /mcp Streamable HTTP block, stateless (sessionIdGenerator: undefined), new server + transport per request, is OpenAI's documented wiring, copied structurally from mcp_app_basics_node.

There is no app.get("/widget/...") route here, no static text/html file, and no ngrok URL stuffed into outputTemplate: the widget travels over MCP as a resource. That is the fix.

Add run scripts to package.json:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "start": "tsx src/server.ts"
  }
}

Run it:

$npm run dev
# MCP app listening on http://localhost:8000/mcp

Connect it to ChatGPT

ChatGPT connectors need a public HTTPS URL, so tunnel your local server:

$ngrok http 8000

Copy the HTTPS forwarding URL (e.g. https://abc123.ngrok.app). Your MCP endpoint is that URL plus /mcp. You do not edit any outputTemplate. The ui:// URI stays internal; only the connector URL changes.

Turn on Developer mode, then register the server (OpenAI connect and test):

  1. ChatGPT → SettingsSecurity and login → turn on Developer mode. Availability here can depend on your account and workspace policy.
  2. Go to chatgpt.com/plugins → the plus button → give it a user-facing name and description, and set the MCP server URL to https://abc123.ngrok.app/mcp, /mcp path included.
  3. Create the connection and review the tools it discovered.
  4. Start a new conversation, add the connection from the tools menu, and ask: "Greet me as Alex with the greeting app."

ChatGPT shows "Creating greeting…", calls greet_user with { "name": "Alex" }, fetches the ui://greeting/widget.html resource, and renders the card inline: "Hello, Alex!" with a timestamp.

When the widget still won't render

These are the documented failure modes, in the order they actually bite (OpenAI troubleshooting).

Tool runs, but you get text instead of a card

The binding is broken. Two strings must be identical: the URI you passed to registerAppResource and the one in the tool's _meta (ui.resourceUri / openai/outputTemplate). A trailing slash or a widget.htm vs widget.html typo is enough to silently fall back to text. The MIME type must be exactly text/html;profile=mcp-app, which is why we use RESOURCE_MIME_TYPE rather than a literal.

Card renders empty

window.openai.toolOutput is undefined when your script reads it on first paint. Make sure you both call render(window.openai?.toolOutput) and subscribe to openai:set_globals; the data often arrives via the event, not the initial global. Also confirm your tool actually returned a structuredContent object; that is what becomes toolOutput.

Console shows CSP errors

The iframe is deny-by-default. A <script src="https://esm.sh/..."> or a remote stylesheet will be blocked, and your widget stays blank. Inline everything (as the HTML above does), or bundle to a single file with vite-plugin-singlefile and serve that file's contents as the resource text.

ChatGPT answers from general knowledge instead of calling the tool

This is a metadata problem, not a wiring one. Give the tool a specific title and a description in "Use this when…" form so the model knows when it applies. greet_user / "Render a personalized greeting card. Use this when…" is discoverable; process / "Processes input" is not.

Where to go next

You now have the minimum viable ChatGPT app: a tool, a ui:// resource, and the _meta link between them. From here the same three primitives scale up: return richer structuredContent, swap the inline DOM for a bundled React widget, and use window.openai.callTool(name, args) to let the widget drive the server, or window.openai.sendFollowUpMessage({ prompt }) to push a message back into the conversation (OpenAI reference). The wiring you just built does not change; only the contents of the three registrations do.