# 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.

Published: 2026-10-04 · By: Divy Chaurasia · Level: intermediate · Time: 45 min · Tags: apps-sdk, mcp, typescript

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](/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](https://ngrok.com) 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](/tutorials/test-mcp-server-with-inspector).

## 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](/tutorials/add-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

| Problem | Fix |
| --- | --- |
| `ERR_REQUIRE_ESM` or import errors | Make sure `"type": "module"` is in `package.json`. |
| Tool works but no widget appears | Check `_meta.ui.resourceUri` matches the resource URI exactly, and that the resource uses `RESOURCE_MIME_TYPE`. |
| Widget shows "Waiting for a roll…" forever | The bridge didn't initialize. Open the browser console on the chat page and look for errors from the iframe. |
| Changes don't show up | Select **Refresh** on the plugin page and open a new chat. |
| Peer dependency errors on install | You installed `ext-apps` 2.x with `sdk` 1.x. Use the pinned versions above. |

> **app:** [MCP App Basics (Apps SDK example)](https://chatgptplugins.directory/apps/apps-sdk-mcp-app-basics): Eleven interactive widgets that each teach one MCP Apps API
## 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](/tutorials/publish-a-chatgpt-plugin).
- See what others have shipped in the [apps directory](/apps).

> **app:** [Pizzaz (Apps SDK example)](https://chatgptplugins.directory/apps/apps-sdk-pizzaz): Pizza-themed demo of list, carousel, map and checkout widgets
If you build with Codex, the OpenAI Developers plugin (built by OpenAI) bundles skills for building and submitting ChatGPT apps:

> **plugin:** [OpenAI Developers](https://chatgptplugins.directory/plugins/openai-developers): Develop AI apps, agents, and ChatGPT Apps with OpenAI best practices

## Listings in this tutorial

- [MCP App Basics (Apps SDK example)](https://chatgptplugins.directory/apps/apps-sdk-mcp-app-basics.md): Eleven interactive widgets that each teach one MCP Apps API
- [Pizzaz (Apps SDK example)](https://chatgptplugins.directory/apps/apps-sdk-pizzaz.md): Pizza-themed demo of list, carousel, map and checkout widgets
- [OpenAI Developers](https://chatgptplugins.directory/plugins/openai-developers.md): Develop AI apps, agents, and ChatGPT Apps with OpenAI best practices

---
Source: https://chatgptplugins.directory/tutorials/build-your-first-chatgpt-app · Index: https://chatgptplugins.directory/llms.txt · Independent and community-run. Not affiliated with or endorsed by OpenAI. ChatGPT is a trademark of OpenAI.
