phidea
Reference · page 3 / 6

3. Build walkthrough — zero to working app

Part 3 of 6. ← Architecture · Index · Next → Guardrails

We'll build a minimal "Todo" app — the shape of the official quickstart — so you have a working baseline before adding your own tools.

3.1 Prerequisites

  • OpenAI Platform account with Owner role or the api.apps.write permission.
  • Verified org identity in the Platform Dashboard (required for submission).
  • Node.js 18+ or Python 3.10+.
  • A public HTTPS endpoint. Local: ngrok or Cloudflare Tunnel. Prod: anywhere you can run a long-lived HTTPS server (Vercel Functions, Fly, Render, VPS).
  • ChatGPT account with Developer Mode enabled (Settings → Apps & Connectors → Advanced).
  • Chrome 142 gotcha: disable chrome://flags/#local-network-access-check for local widget testing, or the widget will not render.

Framework choices: React for the widget (bundled via esbuild or Vite). Server: @modelcontextprotocol/sdk (TS) or mcp/FastMCP (Python).

3.2 Scaffold

mkdir chatgpt-todo-app && cd chatgpt-todo-app
npm init -y
npm pkg set type="module"
npm install @modelcontextprotocol/sdk@^1.20 @modelcontextprotocol/ext-apps@^1.0 zod
mkdir public

Reference clone for copy-paste-worthy starters:

git clone https://github.com/openai/openai-apps-sdk-examples.git

Contains: mcp_app_basics_node, kitchen_sink_server_node/python, pizzaz_server_node/python (list/carousel/map views), shopping_cart_python, solar-system_server_python, authenticated_server_python.

3.3 Define tools (MCP server)

server.js:

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";
import { z } from "zod";
import http from "node:http";
import fs from "node:fs/promises";
import { randomUUID } from "node:crypto";

const todoHtml = await fs.readFile("./public/todo-widget.html", "utf8");
const todos = new Map(); // demo-only in-memory store

const server = new McpServer(
  { name: "todo-app", version: "0.1.0" },
  { capabilities: { tools: {}, resources: {} } }
);

// 1) Register the UI template as an MCP resource
registerAppResource(
  server,
  "todo-widget",
  "ui://widget/todo.html",
  {
    _meta: {
      ui: {
        prefersBorder: true,
        domain: "todo.example.com", // required at submission; your dedicated origin
        csp: {
          connectDomains: ["https://todo.example.com"],
          resourceDomains: ["https://todo.example.com"],
          frameDomains: [],
        },
      },
      "openai/widgetDescription": "An interactive todo list.",
    },
  },
  async () => ({
    contents: [{ uri: "ui://widget/todo.html", mimeType: RESOURCE_MIME_TYPE, text: todoHtml }],
  })
);

// 2) add_todo tool
registerAppTool(
  server,
  "add_todo",
  {
    title: "Add todo",
    description: "Creates a todo item with the given title.",
    inputSchema: z.object({ title: z.string().min(1).max(200) }),
    annotations: {
      readOnlyHint: false,
      destructiveHint: false,
      idempotentHint: false,
      openWorldHint: false,
    },
    _meta: {
      ui: { resourceUri: "ui://widget/todo.html", visibility: ["model", "ui"] },
      "openai/toolInvocation/invoking": "Adding todo…",
      "openai/toolInvocation/invoked": "Todo added",
    },
  },
  async ({ title }) => {
    const id = randomUUID();
    todos.set(id, { id, title, done: false });
    return {
      content: [{ type: "text", text: `Added: ${title}` }],
      structuredContent: { todos: [...todos.values()] },
    };
  }
);

// 3) complete_todo tool
registerAppTool(
  server,
  "complete_todo",
  {
    title: "Complete todo",
    description: "Marks a todo item as completed by id.",
    inputSchema: z.object({ id: z.string().uuid() }),
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
    _meta: { ui: { resourceUri: "ui://widget/todo.html" } },
  },
  async ({ id }) => {
    const t = todos.get(id);
    if (!t) throw new Error("not_found");
    t.done = true;
    return {
      content: [{ type: "text", text: `Completed ${t.title}` }],
      structuredContent: { todos: [...todos.values()] },
    };
  }
);

// 4) HTTP transport
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);

const httpServer = http.createServer(async (req, res) => {
  if (req.method === "OPTIONS") {
    res.writeHead(204, {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type,Authorization,MCP-Session-Id",
    });
    return res.end();
  }
  if (req.url === "/") { res.writeHead(200); return res.end("ok"); }
  if (req.url?.startsWith("/mcp")) return transport.handleRequest(req, res);
  res.writeHead(404); res.end();
});

httpServer.listen(8787, () => console.log("Todo MCP server on http://localhost:8787/mcp"));

Key ideas:

  • structuredContent.todos is what the model reads on the next turn.
  • _meta.ui.resourceUri points the client at the widget bundle.
  • Annotation hints (readOnlyHint, destructiveHint, openWorldHint, idempotentHint) drive ChatGPT's safety UX. Wrong values are a top cause of review rejection. See guardrails.

3.4 Build the widget

The simplest shape is a self-contained HTML file. Save as public/todo-widget.html:

<!doctype html>
<html><head><meta charset="utf-8"><style>
  body{font:14px system-ui;padding:12px;color:var(--text)}
  li{display:flex;gap:8px;align-items:center}
  .done{text-decoration:line-through;opacity:.6}
</style></head><body>
<form id="f"><input id="t" placeholder="New task" required><button>Add</button></form>
<ul id="list"></ul>
<script type="module">
  function render(){
    const out = window.openai?.toolOutput ?? { todos: [] };
    const ul = document.getElementById("list");
    ul.innerHTML = "";
    for (const t of out.todos ?? []) {
      const li = document.createElement("li");
      li.className = t.done ? "done" : "";
      li.innerHTML = `<input type="checkbox" ${t.done?"checked":""} data-id="${t.id}"> <span>${t.title}</span>`;
      ul.appendChild(li);
    }
  }
  document.addEventListener("openai:set_globals", render);
  document.getElementById("f").addEventListener("submit", async (e) => {
    e.preventDefault();
    const title = document.getElementById("t").value;
    document.getElementById("t").value = "";
    await window.openai.callTool("add_todo", { title });
  });
  document.getElementById("list").addEventListener("change", async (e) => {
    if (e.target.matches('input[type=checkbox]'))
      await window.openai.callTool("complete_todo", { id: e.target.dataset.id });
  });
  render();
</script></body></html>

For production, bundle a real React component tree with esbuild or Vite (see kitchen_sink_server_node in the examples repo) and inline the ESM output into a single HTML resource. The optional `apps-sdk-ui` component library matches ChatGPT's look and feel.

UI constraints

  • Sandboxed iframe with strict CSP. Subframes blocked unless you add _meta.ui.csp.frameDomains (invites stricter review).
  • Forbidden browser APIs: window.alert, window.prompt, window.confirm, navigator.clipboard. Use window.openai.requestModal(...) instead.
  • File downloads: use window.openai.getFileDownloadUrl({ fileId }). Don't embed raw blobs.
  • Display modes: window.openai.requestDisplayMode("inline" | "pip" | "fullscreen").
  • Persist UI state with window.openai.setWidgetState(state) — never put secrets or tokens in widget state.

3.5 Handle auth

For anything touching user accounts, implement OAuth 2.1 with PKCE and Dynamic Client Registration (DCR). ChatGPT self-registers as a client, so you don't hand out credentials manually.

  • Expose /.well-known/oauth-protected-resource and (on your auth server) /.well-known/oauth-authorization-server with a registration_endpoint.
  • On every tools/call, validate the Authorization: Bearer <jwt> header: issuer, audience, expiry, scopes.
  • On failure, return HTTP 401 with a `WWW-Authenticate` challenge — ChatGPT will re-run the flow.
  • Mark each tool's required scopes via securitySchemes in its descriptor. Mix type: oauth2 tools with type: noauth tools to allow a "logged-out preview."
  • Canonical example: authenticated_server_python in the OpenAI examples repo.
Guardrail — never leak tokens to the iframe. Perform privileged work in the MCP tool handler using the bearer token ChatGPT attaches. The widget should receive results, not credentials. widgetState is UI-only.

3.6 Connect to ChatGPT for local testing

  1. node server.js — confirm Todo MCP server on http://localhost:8787/mcp.
  2. Validate with MCP Inspector (catches 80% of schema mistakes):
   npx @modelcontextprotocol/inspector@latest --server-url http://localhost:8787/mcp --transport http
  1. Expose with ngrok:
   ngrok http 8787
  1. In ChatGPT: Settings → Apps & Connectors → Advanced settings → toggle Developer Mode.
  2. Settings → Connectors → Create → paste https://<subdomain>.ngrok.app/mcp, give it a name + description.
  3. Open a new chat, pick your connector from the More menu, and prompt: "Add 'read my book' to my todo list."

3.7 Submit for review / publish

  1. In the OpenAI Platform Dashboard, complete organisation verification and add customer-support contact details.
  2. Create an app draft, fill in privacy policy URL, category, supported locales (start with en), demo credentials (fully-featured, no MFA, no mandatory sign-up wall), and screenshots.
  3. Point the draft at your production MCP server URL (HTTPS, stable, low-latency).
  4. Submit. Status updates via email. OpenAI has not published a public review SLA — plan for days, not hours.

See Testing & publishing for the full pre-submission checklist.