Skip to main content
Lesson 6 of 14 · ChatGPT Apps SDK and Plugins: MCP Servers and Interfaces

Add an Interface with MCP Apps

Lesson 6 of 14 Updated November 3, 2026 Developer
On this page
  1. When to add an interface
  2. The MCP Apps principle
  3. Server side: declare the resource and the tool
  4. Iframe side: the JSON-RPC bridge
  5. Choosing a presentation
  6. ChatGPT-specific extensions
  7. Three kinds of state
  8. Content Security Policy
  9. Key takeaways

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-apps 1.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:

MessageRole
ui/initialize then ui/notifications/initializedOpen the dialogue with the host
ui/notifications/tool-inputReceive the tool call arguments
ui/notifications/tool-resultReceive the tool result
tools/callCall a server tool from the interface
ui/messageSend a follow-up message in the conversation
ui/update-model-contextInform 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:

PresentationUsage
Inline cardA targeted result, a confirmation, a few actions, without deep navigation
Inline carouselBrowse and choose among a few similar, visual options
Full screenA rich task: map, editing canvas, detailed exploration; the ChatGPT input area remains available
Picture-in-pictureAn 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:

NeedMCP Apps StandardChatGPT Alias
Attach a tool to an interface_meta.ui.resourceUri_meta["openai/outputTemplate"]
Receive argumentsui/initialize + ui/notifications/tool-inputwindow.openai.toolInput
Receive resultui/notifications/tool-resultwindow.openai.toolOutput
Call a tooltools/callwindow.openai.callTool
Send a follow-up messageui/messagewindow.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

StateOwnerLifetimeExamples
Business data, source of truthMCP server or external serviceLongTasks, tickets, documents
Interface state, ephemeralComponent instanceDuration of its displaySelected row, open panel, sort order
Durable state across sessionsStorage you controlBetween conversations and devicesSaved 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 type text/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.openai provides only extensions, to be detected before use
  • Business data on the server, narrow CSP, versioned resource URI