Add an Interface with MCP Apps
On this page
Lesson rewritten on November 3, 2026 based on the Add UI to your MCP server page from the OpenAI Plugins documentation and the code for
@modelcontextprotocol/ext-apps1.7.5. The previous lesson described “widgets” (defineWidget) from a non-existent SDK; an interface is an MCP Apps resource rendered by ChatGPT in an iframe. Sources: Add UI to your MCP server · MCP Apps Specification.
When to add an interface
The interface is optional. Add one when the user needs to inspect, compare, modify, confirm, or browse structured information—and keep your tools useful without it, so ChatGPT and Codex can drive the workflow in clients that do not display a component.
The MCP Apps principle
ChatGPT implements the open MCP Apps standard. The server declares an interface resource, identified by a ui://… URI and served with the MIME type text/html;profile=mcp-app; a tool attaches to it via _meta.ui.resourceUri. When this tool is called, ChatGPT displays the resource in an iframe, next to the conversation. The component communicates with the host via JSON-RPC using postMessage:
| Message | Role |
|---|---|
ui/initialize then ui/notifications/initialized | Open the dialogue with the host |
ui/notifications/tool-input | Receive the tool call arguments |
ui/notifications/tool-result | Receive the tool result |
tools/call | Call a server tool from the interface |
ui/message | Send a follow-up message in the conversation |
ui/update-model-context | Inform the model of a current selection or modification |
Server side: declare the resource and the tool
Let’s revisit the notes server from Lesson 4. The @modelcontextprotocol/ext-apps package (in 1.x with SDK v1) provides two helpers: registerAppResource, which applies the correct MIME type exposed by the RESOURCE_MIME_TYPE constant by default, and registerAppTool, which requires the interface metadata.
import { readFileSync } from "node:fs";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
const URI_CARTE = "ui://widget/notes-v1.html"; // versioned: this is a cache key
const carteHtml = readFileSync("public/notes-widget.html", "utf8");
// Add inside creerServeur(), next to list_notes and add_note
registerAppResource(server, "notes-widget", URI_CARTE, {}, async () => ({
contents: [{ uri: URI_CARTE, mimeType: RESOURCE_MIME_TYPE, text: carteHtml }],
}));
registerAppTool(
server,
"show_notes",
{
title: "Show notes",
description:
"Displays notes in a card. To show only a subset, call list_notes first.",
inputSchema: {},
outputSchema: schemaNotes,
annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
_meta: { ui: { resourceUri: URI_CARTE } },
},
async () => ({
structuredContent: { notes },
content: [{ type: "text", text: `${notes.length} note(s) displayed.` }],
})
);
Only show_notes carries the interface. This is the decoupling that OpenAI recommends: if every tool returned a template, ChatGPT would redraw the iframe too often. Data tools (list_notes, add_note) search, calculate, or modify and return only their result; rendering tools receive final data and return the template. The model can thus work on the data—filter, cross-reference—before deciding to display, and the card appears only once, with a verified context. Indicate the dependency in the rendering tool’s description, as shown above.
Iframe side: the JSON-RPC bridge
The component fits here in a single HTML file, with no build step. It opens the dialogue with the host, then displays each tool result it receives.
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>Notes</title></head>
<body>
<ul id="liste"></ul>
<script type="module">
const liste = document.querySelector("#liste");
const enAttente = new Map();
let dernierId = 0;
const envoyer = (message) =>
window.parent.postMessage({ jsonrpc: "2.0", ...message }, "*");
const requete = (method, params) =>
new Promise((resolve, reject) => {
const id = ++dernierId;
enAttente.set(id, { resolve, reject });
envoyer({ id, method, params });
});
const afficher = (resultat) => {
const notes = resultat?.structuredContent?.notes ?? [];
liste.replaceChildren(
...notes.map((note) => {
const li = document.createElement("li");
li.textContent = note.text; // never innerHTML on received data
return li;
})
);
};
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" && enAttente.has(msg.id)) {
const { resolve, reject } = enAttente.get(msg.id);
enAttente.delete(msg.id);
msg.error ? reject(msg.error) : resolve(msg.result);
} else if (msg.method === "ui/notifications/tool-result") {
afficher(msg.params);
}
});
await requete("ui/initialize", {
appInfo: { name: "notes-widget", version: "0.1.0" },
appCapabilities: {},
protocolVersion: "2026-01-26",
});
envoyer({ method: "ui/notifications/initialized", params: {} });
</script>
</body>
</html>
The received result is untrusted data: display it with textContent, never by injecting it as HTML. For a richer interface, the same package provides a browser-side App class that encapsulates this bridge (receiving results, calling tools), to be integrated into a bundle built with esbuild; the OpenAI examples repository shows complete React components.
Choosing a presentation
Start with the inline display and request more space only if the workflow requires it:
| Presentation | Usage |
|---|---|
| Inline card | A targeted result, a confirmation, a few actions, without deep navigation |
| Inline carousel | Browse and choose among a few similar, visual options |
| Full screen | A rich task: map, editing canvas, detailed exploration; the ChatGPT input area remains available |
| Picture-in-picture | An ongoing activity that must remain visible during the conversation: live session, game, video |
ChatGPT-specific extensions
Once the MCP Apps flow is in place, ChatGPT offers extensions via window.openai, to be used only for what the standard does not cover. For common needs, prefer the standard; ChatGPT aliases remain available for existing integrations:
| Need | MCP Apps Standard | ChatGPT Alias |
|---|---|---|
| Attach a tool to an interface | _meta.ui.resourceUri | _meta["openai/outputTemplate"] |
| Receive arguments | ui/initialize + ui/notifications/tool-input | window.openai.toolInput |
| Receive result | ui/notifications/tool-result | window.openai.toolOutput |
| Call a tool | tools/call | window.openai.callTool |
| Send a follow-up message | ui/message | window.openai.sendFollowUpMessage |
Extensions without a standard equivalent cover integrated checkout (requestCheckout), files (uploadFile, selectFiles, getFileDownloadUrl), host-managed modal windows (requestModal), and component state persistence (widgetState, setWidgetState). Detect each capability before using it, with a fallback when possible—and never test the host name, test the function:
const openai = typeof window !== "undefined" ? window.openai : undefined;
if (openai?.requestModal) {
await openai.requestModal({ template: "ui://widget/details-v1.html" });
} else {
document.body.textContent = "This host does not support modal windows.";
}
Three kinds of state
| State | Owner | Lifetime | Examples |
|---|---|---|---|
| Business data, source of truth | MCP server or external service | Long | Tasks, tickets, documents |
| Interface state, ephemeral | Component instance | Duration of its display | Selected row, open panel, sort order |
| Durable state across sessions | Storage you control | Between conversations and devices | Saved filters, display mode |
Business data stays on the server: the interface calls a tool, the server validates and updates, then returns the reference state, which the interface displays while keeping what relates to presentation. When the model needs to know a selection, send it via ui/update-model-context. window.openai.setWidgetState (synchronous, no await) preserves the state of an instance, not business data. Durable state goes in your storage, behind authentication; avoid localStorage, which offers neither continuity between devices nor reliability in an isolated iframe.
Content Security Policy
The component declares, in its resource metadata (_meta.ui.csp), the exact domains it uses: connectDomains for API calls, resourceDomains for scripts, styles, and images, frameDomains only if it must integrate specific iframes—nested frames are blocked by default. Keep these lists as narrow as possible: the plugin review compares the declared policy to the interface’s actual behavior. And treat the resource URI as a cache key: for every incompatible change to the HTML, JavaScript, or CSS, publish a new URI and update the tools that reference it.
Two final details: the host reflects the user’s language in document.documentElement.lang, to be used for translating and formatting dates and numbers; and the @openai/apps-sdk-ui library provides buttons, cards, and fields matching ChatGPT if you do not want to redesign everything.
Key takeaways
- An interface is a
ui://resource of typetext/html;profile=mcp-app, attached to a tool via_meta.ui.resourceUri - The component communicates with the host via JSON-RPC (
ui/initialize,tools/call,ui/notifications/tool-result…) - Separate data tools and rendering tools: only the rendering tool carries the template
window.openaiprovides only extensions, to be detected before use- Business data on the server, narrow CSP, versioned resource URI