OpenAI Apps SDK: Building MCP UIs Inside ChatGPT

TL;DR
Apps SDK (now ChatGPT plugins) is MCP plus an optional web component rendered in ChatGPT. How the server, the UI resource, auth and publishing actually work, with code adapted from OpenAI's quickstart.
Last updated: October 18, 2026
Updated October 18, 2026: The earlier code in this post imported defineTool from @modelcontextprotocol/sdk/server, a /transport/stdio path and a UiDirective type from @openai/apps-sdk. None of those exist (@openai/apps-sdk is not on npm), and ChatGPT does not render a fixed card-and-button vocabulary returned by tools. The server code is now adapted from OpenAI's quickstart, which registers an HTML UI resource with @modelcontextprotocol/ext-apps and renders it in an iframe through the MCP Apps standard. The invented manifest.json, the ctx.user linking flow and the funnel numbers were removed, and the auth, publishing and FAQ sections now follow OpenAI's plugin docs.
| Official Sources | |
|---|---|
| Apps SDK quickstart (now Plugins) | MCP server plus optional ChatGPT UI |
| Add UI to your MCP server | MCP Apps bridge and ChatGPT extensions |
| Authenticate users | OAuth 2.1 per the MCP authorization spec |
| Submit and publish | Review and distribution |
| Apps SDK examples | React and Python examples |
| MCP TypeScript SDK | Model Context Protocol server SDK |
Apps SDK is OpenAI's answer to "what does a third-party app inside ChatGPT look like." OpenAI's docs now file it under Plugins, and the old developers.openai.com/apps-sdk/quickstart URL redirects there. The technical answer has not changed: an MCP server you host, plus an optional web component that ChatGPT renders in an iframe when your tool returns results.
This post walks through how the pieces fit, using OpenAI's own quickstart as the reference implementation.
What Apps SDK actually is#
The quickstart describes two parts:
- An MCP server that defines tools and exposes them to ChatGPT and Codex.
- An optional web component, rendered in an iframe inside ChatGPT.
For the broader MCP map, pair this with What Is MCP (Model Context Protocol)? A TypeScript Developer's Guide and The Complete Guide to MCP Servers; those pieces cover the concepts and server-selection layer behind this article.
The MCP server is a standard MCP server. The new part is that a tool can point at a UI resource: an HTML bundle you register on the server under a ui:// URI. When the model calls that tool, ChatGPT loads the resource in an iframe and passes it the tool result. The UI talks back over the open MCP Apps bridge, JSON-RPC over postMessage with ui/* methods and tools/call, so a button in your UI can call another tool on your server.
That is a different model from a fixed component vocabulary. You ship real HTML, CSS and JavaScript (vanilla or a React build), and OpenAI says ChatGPT implements the MCP Apps UI standard so the same UI can run in other MCP Apps-compatible hosts. ChatGPT-only capabilities sit on top through window.openai, and the docs recommend reaching for them only when the standard does not cover what you need.
The mental model: MCP tools are how your app does things, the UI resource is how it shows things, the user's prompt is how it gets invoked, and submission is how it gets discovered.
Project layout#
The quickstart keeps it to two files, which is the right place to start:
todo-plugin/
package.json # "type": "module"
server.js # MCP server: UI resource + tools
public/
todo-widget.html # web component rendered by ChatGPT
Install the MCP SDK, the MCP Apps helpers and zod. The quickstart's package.json pins @modelcontextprotocol/sdk ^1.20.2, @modelcontextprotocol/ext-apps ^1.0.1 and zod ^3.25.76. Keep the ext-apps major at 1 for this code: on npm, ext-apps 2.x declares peer dependencies on the v2 MCP SDK packages (@modelcontextprotocol/server), while 1.x pairs with @modelcontextprotocol/sdk:
npm install @modelcontextprotocol/sdk@^1 @modelcontextprotocol/ext-apps@^1 zod@^3The MCP server with a UI resource#
This is condensed from OpenAI's quickstart server.js. It registers one UI resource and one tool that renders it:
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const todoHtml = readFileSync("public/todo-widget.html", "utf8");
let todos = [];
let nextId = 1;
const replyWithTodos = (message) => ({
content: message ? [{ type: "text", text: message }] : [],
structuredContent: { tasks: todos },
});
function createTodoServer() {
const server = new McpServer({ name: "todo-plugin-server", version: "0.1.0" });
registerAppResource(server, "todo-widget", "ui://widget/todo.html", {}, async () => ({
contents: [
{ uri: "ui://widget/todo.html", mimeType: RESOURCE_MIME_TYPE, text: todoHtml },
],
}));
registerAppTool(
server,
"add_todo",
{
title: "Add todo",
description: "Creates a todo item with the given title.",
inputSchema: { title: z.string().min(1) },
outputSchema: {
tasks: z.array(z.object({ id: z.string(), title: z.string(), completed: z.boolean() })),
},
_meta: { ui: { resourceUri: "ui://widget/todo.html" } },
},
async (args) => {
const todo = { id: `todo-${nextId++}`, title: args.title.trim(), completed: false };
todos = [...todos, todo];
return replyWithTodos(`Added "${todo.title}".`);
}
);
return server;
}
const httpServer = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
if (url.pathname !== "/mcp") {
res.writeHead(404).end("Not Found");
return;
}
const server = createTodoServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
enableJsonResponse: true,
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res);
});
httpServer.listen(8787);
The full quickstart version also answers GET / for health checks, handles CORS preflight on /mcp, and adds a complete_todo tool. Use it as the copy-paste source; the condensed version above is for reading.
Two details matter. structuredContent is the data your UI reads, while content is what the model sees as text. And _meta.ui.resourceUri is the link between a tool and the UI resource that should render its result.
The web component#
The UI is a plain HTML file. In the quickstart it initializes the MCP Apps bridge with a ui/initialize request, confirms with ui/notifications/initialized, listens for ui/notifications/tool-result to re-render when the model calls a tool, and sends tools/call when the user clicks a button:
const response = await rpcRequest("tools/call", {
name: "add_todo",
arguments: { title },
});
updateFromResponse(response); // reads response.structuredContent.tasks
rpcRequest in that snippet is a small helper in the quickstart that posts a JSON-RPC message to window.parent and resolves when the matching response comes back. For React builds, compile to static assets and inline them into the HTML template; OpenAI's examples repository shows that setup.
Test locally, then connect ChatGPT#
Run node server.js, then open the MCP Inspector with npx @modelcontextprotocol/inspector@latest, choose Streamable HTTP and connect to http://localhost:8787/mcp.
ChatGPT needs a public HTTPS URL, so the quickstart uses a tunnel such as ngrok during development. Then, per the docs: open ChatGPT Plugins, add a custom MCP server with your HTTPS /mcp URL, create it as a plugin, install it, and mention it with @ in a new chat. Refresh the connection after each change to tools or metadata.
Authentication#
If your tools act on a user's data in your system, the docs expect an OAuth 2.1 flow that conforms to the MCP authorization spec. Your MCP server is the resource server and verifies an access token on every request. Your identity provider is the authorization server. ChatGPT is the client, and OpenAI lists support for Client ID Metadata Documents, dynamic client registration, predefined OAuth clients and PKCE.
Concretely, your server publishes protected resource metadata at /.well-known/oauth-protected-resource (or advertises it in a WWW-Authenticate header on 401 responses), and your authorization server publishes its OAuth metadata. See OpenAI's authentication guide for the full checklist.
Distribution#
Connecting a custom MCP server is a developer path. For public distribution, OpenAI points to Submit and publish, the plugin guidelines and the MCP server review requirements. Tool names, descriptions and metadata are what the model uses to decide when your plugin fits a prompt, and OpenAI has a separate guide on optimizing that metadata.
If you have shipped MCP servers before, you know discoverability is the whole game. I learned this running MCP Directory: a clear description and precise tool metadata move the needle more than features do.
Telemetry and iteration#
Your MCP server sees every tool call, including calls the UI makes through tools/call, so the server is the natural place to log usage. A small wrapper around the handler you pass to registerAppTool is enough:
function withTelemetry(toolName, handler) {
return async (args, extra) => {
const start = Date.now();
try {
const result = await handler(args, extra);
console.log(JSON.stringify({ event: "tool_completed", toolName, ms: Date.now() - start }));
return result;
} catch (err) {
console.log(JSON.stringify({ event: "tool_failed", toolName, error: String(err) }));
throw err;
}
};
}
Swap console.log for your analytics client. Keep user identifiers and tool arguments out of the event unless you have a reason and a retention policy for them.
The order of operations I would recommend for a first build: get one tool returning structuredContent and rendering in the UI locally, connect it to ChatGPT, then add auth, then telemetry, then submission.
Frequently Asked Questions#
What is OpenAI Apps SDK?#
Apps SDK is OpenAI's way to build apps that run inside ChatGPT, now documented under Plugins. It combines an MCP server you host with an optional web component that ChatGPT renders in an iframe. Tools do the work, the UI resource shows the results, and ChatGPT implements the open MCP Apps UI standard.
How is Apps SDK different from regular MCP servers?#
The server is a regular MCP server. The difference is that a tool can reference a UI resource through _meta.ui.resourceUri, registered with registerAppResource from @modelcontextprotocol/ext-apps/server. ChatGPT renders that HTML in an iframe and passes it the tool result, and the UI can call tools back over the MCP Apps postMessage bridge.
What UI can an Apps SDK app render?#
Your own HTML, CSS and JavaScript, rendered in an iframe. OpenAI's quickstart uses a single vanilla HTML file and its examples repository shows React builds. Follow OpenAI's UI guidelines, and use window.openai extensions only when the MCP Apps standard does not cover a capability you need.
How do I handle user authentication in Apps SDK?#
Implement OAuth 2.1 following the MCP authorization spec. Your MCP server verifies access tokens on each request and publishes protected resource metadata at /.well-known/oauth-protected-resource; your authorization server publishes OAuth metadata. ChatGPT acts as the OAuth client.
How do I publish an Apps SDK app?#
During development, add your HTTPS /mcp URL as a custom MCP server at chatgpt.com/plugins. For public distribution, follow OpenAI's Submit and publish guide, the plugin guidelines and the MCP server review requirements.
Does Apps SDK provide analytics or telemetry?#
Plan to log tool invocations on your own MCP server. Every tool call, including the ones your UI makes through tools/call, reaches your server, so a small handler wrapper gives you usage data in your own analytics.
What backend do I need for Apps SDK apps?#
Any backend that can serve an MCP endpoint over HTTPS. The quickstart uses Node's http module with the Streamable HTTP transport in stateless mode, and in-memory state you would replace with your own database.
What is the recommended build order for a first Apps SDK app?#
Start with one tool that returns structuredContent and renders in your UI, test it with the MCP Inspector, connect it to ChatGPT through a tunnel, then add OAuth, then telemetry, then submission.
Sources#
- OpenAI plugin quickstart (formerly Apps SDK quickstart): developers.openai.com/plugins/build/app-quickstart
- OpenAI plugin authentication: developers.openai.com/plugins/build/auth
- OpenAI Apps SDK examples: github.com/openai/openai-apps-sdk-examples
- Model Context Protocol specification: modelcontextprotocol.io/specification
- MCP TypeScript SDK: github.com/modelcontextprotocol/typescript-sdk
Continue Reading#
- ChatGPT Plugin Extensions: Build Sidebar Apps, Panels and File Editors - the openai/ui entrypoints that put an MCP App in ChatGPT's sidebar, panels and file viewers
- Custom GPT Retirement: Migrate to Plugins
- OpenAI Retunes GPT-5.6 Sol in ChatGPT and Makes Luna the Free Tier Default
Get the next deep dive like this in your inbox
One email a week on OpenAI and the rest of the AI dev stack. Free.
Read next
ChatGPT Plugin Extensions: Sidebar Apps, Panels and File Editors
ChatGPT plugins can now add sidebar apps, side panels, file editors and native forms. How openai/ui entrypoints work, with type-checked code.
14 min readChatGPT MCP Events: Start Work Tasks From Gmail, Slack, GitHub
How to start ChatGPT Work tasks from Gmail, Slack, GitHub and your own MCP server events: setup steps, the 30-runs-per-hour cap and signed webhook code.
12 min readCustom GPTs Retire Dec 11, 2026: How to Migrate to Plugins
Custom GPTs retire Dec 11, 2026. Instructions and files migrate to a ChatGPT plugin automatically; custom actions do not. Here is the developer plan.
12 min readNew here? Start with
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.








