GitHub list of ChatGPT Plugin Directory

Build your first ChatGPT app with an MCP server and a UI widget

Build a minimal ChatGPT app in TypeScript: an MCP server with one tool and an MCP Apps widget, tested in MCP Inspector and connected through developer mode.

Divy ChaurasiaIntermediate 45 minFor builders

A ChatGPT app is an MCP server. It exposes tools the model can call and, optionally, a small web UI that ChatGPT renders inline when a tool runs. ChatGPT implements the open MCP Apps UI standard, so the same widget can also run in other hosts that support it. OpenAI's Apps SDK documentation now lives under the Plugins docs at developers.openai.com/plugins, because apps are distributed inside plugins.

In this tutorial you'll build a dice roller: one roll_dice tool, plus a widget that draws the dice. Then you'll test it locally, put it on a public URL, and use it in ChatGPT.

Before you start#

  • Node.js 20 or newer (the MCP Inspector CLI used later needs 22.19 or newer).
  • A ChatGPT plan with developer mode: Plus, Pro, Business, Enterprise or Education, on the web.
  • A tunnel tool: ngrok or Cloudflare's cloudflared.

1. Set up the project#

bash
mkdir dice-app && cd dice-app
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk@^1.29.0 @modelcontextprotocol/ext-apps@^1.7.5 zod@^3.25.76
npm install -D tsx typescript @types/node
mkdir public

Three packages do the work:

  • @modelcontextprotocol/sdk: the official TypeScript MCP SDK (McpServer, transports).
  • @modelcontextprotocol/ext-apps: MCP Apps helpers. Its /server entry point provides registerAppResource, registerAppTool and the RESOURCE_MIME_TYPE constant.
  • zod: input and output schemas.

Note: The versions are pinned on purpose. ext-apps 2.x targets the new split MCP SDK v2 packages (@modelcontextprotocol/server and friends), while OpenAI's quickstart uses @modelcontextprotocol/sdk 1.x with ext-apps 1.x. The code below is for the 1.x pair. The wire protocol is the same in both, so ChatGPT doesn't care which you use.

2. Write the widget#

The widget is a single HTML file. ChatGPT loads it in a sandboxed iframe and talks to it with JSON-RPC messages over postMessage. The widget sends ui/initialize, confirms with ui/notifications/initialized, and then receives ui/notifications/tool-result each time the model calls the tool.

Create public/dice-widget.html:

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <style>
      body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; }
      .dice { display: flex; gap: 8px; flex-wrap: wrap; }
      .die { width: 48px; height: 48px; display: grid; place-items: center;
             border: 2px solid #333; border-radius: 10px; font-size: 20px; font-weight: 600; }
      #total { margin-top: 12px; color: #555; }
    </style>
  </head>
  <body>
    <div class="dice" id="dice"></div>
    <div id="total">Waiting for a roll…</div>
    <script type="module">
      const render = (data) => {
        if (!data?.rolls) return;
        document.querySelector("#dice").innerHTML =
          data.rolls.map((n) => `<div class="die">${n}</div>`).join("");
        document.querySelector("#total").textContent = `Total: ${data.total}`;
      };

      // MCP Apps bridge: JSON-RPC 2.0 over postMessage with the host.
      let nextId = 0;
      const pending = new Map();
      const send = (msg) => window.parent.postMessage({ jsonrpc: "2.0", ...msg }, "*");
      const request = (method, params) =>
        new Promise((resolve, reject) => {
          const id = ++nextId;
          pending.set(id, { resolve, reject });
          send({ id, method, params });
        });

      window.addEventListener("message", (event) => {
        if (event.source !== window.parent) return;
        const msg = event.data;
        if (msg?.jsonrpc !== "2.0") return;
        if (typeof msg.id === "number" && pending.has(msg.id)) {
          const { resolve, reject } = pending.get(msg.id);
          pending.delete(msg.id);
          msg.error ? reject(msg.error) : resolve(msg.result);
          return;
        }
        // The host pushes the tool result after the model calls roll_dice.
        if (msg.method === "ui/notifications/tool-result") {
          render(msg.params?.structuredContent);
        }
      });

      await request("ui/initialize", {
        appInfo: { name: "dice-widget", version: "0.1.0" },
        appCapabilities: {},
        protocolVersion: "2026-01-26",
      });
      send({ method: "ui/notifications/initialized", params: {} });
    </script>
  </body>
</html>

The widget only reads structuredContent. That is the machine-readable half of a tool result; the content text is what the model reads.

3. Write the MCP server#

Create server.ts:

typescript
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";

const WIDGET_URI = "ui://widget/dice.html";
const widgetHtml = readFileSync("public/dice-widget.html", "utf8");

function createDiceServer() {
  const server = new McpServer({ name: "dice-roller", version: "0.1.0" });

  // 1. The UI resource ChatGPT renders in an iframe.
  registerAppResource(server, "dice-widget", WIDGET_URI, {}, async () => ({
    contents: [{ uri: WIDGET_URI, mimeType: RESOURCE_MIME_TYPE, text: widgetHtml }],
  }));

  // 2. A tool linked to that UI through _meta.ui.resourceUri.
  registerAppTool(
    server,
    "roll_dice",
    {
      title: "Roll dice",
      description: "Use this when the user wants to roll one or more dice, e.g. 2d6.",
      inputSchema: {
        count: z.number().int().min(1).max(10).describe("Number of dice"),
        sides: z.number().int().min(2).max(100).describe("Sides per die"),
      },
      outputSchema: {
        rolls: z.array(z.number()),
        total: z.number(),
      },
      annotations: { readOnlyHint: true },
      _meta: { ui: { resourceUri: WIDGET_URI } },
    },
    async ({ count, sides }) => {
      const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides));
      const total = rolls.reduce((a, b) => a + b, 0);
      return {
        content: [{ type: "text", text: `Rolled ${count}d${sides}: ${rolls.join(", ")} (total ${total})` }],
        structuredContent: { rolls, total },
      };
    }
  );

  return server;
}

const port = Number(process.env.PORT ?? 8787);

createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);

  // Health check on "/", 404 for everything else (including OAuth discovery
  // routes you don't use yet), so ChatGPT doesn't surface 502 errors.
  if (url.pathname !== "/mcp") {
    res.writeHead(url.pathname === "/" ? 200 : 404).end(url.pathname === "/" ? "ok" : "Not Found");
    return;
  }
  if (req.method === "OPTIONS") {
    res.writeHead(204, {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "POST, GET, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "content-type, mcp-session-id, mcp-protocol-version",
      "Access-Control-Expose-Headers": "Mcp-Session-Id",
    }).end();
    return;
  }

  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");

  // Stateless mode: a fresh server and transport per request.
  const server = createDiceServer();
  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 (err) {
    console.error("MCP request failed:", err);
    if (!res.headersSent) res.writeHead(500).end("Internal server error");
  }
}).listen(port, () => console.log(`Dice MCP server on http://localhost:${port}/mcp`));

What matters here:

  • registerAppResource serves the HTML at a ui:// URI with the MCP Apps MIME type (text/html;profile=mcp-app).
  • registerAppTool registers a normal MCP tool and adds _meta.ui.resourceUri, which tells ChatGPT which widget to render for its results.
  • readOnlyHint: true marks the tool as read-only, so ChatGPT won't ask for confirmation each time. Only set it on tools that change nothing.
  • Stateless transport: sessionIdGenerator: undefined means no session state between requests, which keeps the server simple to deploy.

4. Run it and test with MCP Inspector#

Start the server:

bash
npx tsx server.ts
# Dice MCP server on http://localhost:8787/mcp

In a second terminal, open the Inspector web UI:

bash
npx @modelcontextprotocol/inspector@latest

Choose Streamable HTTP, enter http://localhost:8787/mcp, and connect. List the tools, call roll_dice with count: 2, sides: 6, and check that the result has both content and structuredContent.

For a quick check from the shell, use the Inspector CLI:

bash
npx @modelcontextprotocol/inspector --cli --transport http \
  --server-url http://localhost:8787/mcp \
  --method tools/call --tool-name roll_dice \
  --tool-args-json '{"count":2,"sides":6}' --format json

The output includes an appInfo object with "hasApp": true and your resourceUri, which confirms the widget is wired to the tool. More checks are in Test and debug an MCP server.

5. Put it on a public URL#

ChatGPT can't reach localhost. Open a tunnel to port 8787:

bash
ngrok http 8787
# or
cloudflared tunnel --url http://localhost:8787

Copy the HTTPS URL it prints and add /mcp, for example https://abc123.ngrok.app/mcp. Tunnels are fine for development. A public plugin needs a stable HTTPS deployment.

6. Connect it in ChatGPT#

  1. In ChatGPT, open Settings > Security and login and turn on Developer mode.
  2. Go to chatgpt.com/plugins and select the plus button.
  3. Paste your tunnel URL with /mcp, give it a name ("Dice roller") and a short description, choose No Authentication, and select Create.
  4. Open a new chat, select the plugin from the plus menu (under More or Developer mode, depending on your rollout), and ask: "Roll 3d6."

You should see the tool call (expand it to view the JSON) and, below it, the widget showing three dice and the total. The full walkthrough of developer mode is in How to add a custom MCP server to ChatGPT.

7. Iterate#

  • Refresh after changes. When you change tools, descriptions or the widget, restart the server, open the plugin at chatgpt.com/plugins, select Refresh, and start a new chat.
  • Version the widget URI (for example ui://widget/dice-v2.html) when you make changes that could break a cached copy.
  • Declare external domains. If the widget calls APIs or loads scripts, styles or images from other origins, list them in the resource's _meta.ui.csp (connectDomains for API requests, resourceDomains for assets). Plugin review checks this policy against what the widget actually loads.
  • Keep tools useful without UI. The model should be able to finish the task from content alone.
  • ChatGPT-only features such as checkout, file upload, modals and widget state are available through window.openai. Add them after the standard MCP Apps flow works.

Troubleshooting#

ProblemFix
ERR_REQUIRE_ESM or import errorsMake sure "type": "module" is in package.json.
Tool works but no widget appearsCheck _meta.ui.resourceUri matches the resource URI exactly, and that the resource uses RESOURCE_MIME_TYPE.
Widget shows "Waiting for a roll…" foreverThe bridge didn't initialize. Open the browser console on the chat page and look for errors from the iframe.
Changes don't show upSelect Refresh on the plugin page and open a new chat.
Peer dependency errors on installYou installed ext-apps 2.x with sdk 1.x. Use the pinned versions above.

Next steps#

  • Study OpenAI's example apps, including Pizzaz (lists, carousels, maps), in the openai/openai-apps-sdk-examples repository.
  • Add OAuth when your tools need user accounts (see OpenAI's auth guide under developers.openai.com/plugins/build/auth).
  • Package and submit it with Publish a ChatGPT plugin.
  • See what others have shipped in the apps directory.

If you build with Codex, the OpenAI Developers plugin (built by OpenAI) bundles skills for building and submitting ChatGPT apps:

Keep reading