AI
ChatGPT Plugins (Apps SDK) Guide: Build an ERP Plugin on MCP
October 202611 min read

Yes, in practice. The Apps SDK launched in October 2025 for building apps in ChatGPT, and on 9 July 2026 the Plugin Directory replaced the App Directory, with existing apps packaged into plugins. The developers.openai.com/apps-sdk address now redirects to the plugins documentation, and the MCP server code from the Apps SDK era still applies.
A plugin can contain an MCP server that exposes tools, skills defined in SKILL.md files, and an optional UI rendered when a tool returns. Only the parts you need are required, and OpenAI's docs allow a skills-only plugin. The manifest is a plugin.json at the root, with OpenAI-specific settings under extensions.com.openai.
No. The 2023 plugin beta was shut down in April 2024. The 2026 plugins reuse the name but are built on the Model Context Protocol, the same foundation as the Apps SDK, and they bundle apps, skills and app templates.
Register an HTML resource with a ui:// URI and the MIME type text/html;profile=mcp-app, then link the tool to it with _meta.ui.resourceUri. ChatGPT renders the resource in a sandboxed frame and passes the tool result to it over postMessage. The @modelcontextprotocol/ext-apps package provides registerAppTool, registerAppResource and an App class for the view.
You need organisation verification and either owner rights or the Apps Management Write role. A publicly reviewed MCP app also needs website, support, privacy policy and terms URLs, a dedicated test account, five positive and three negative test cases, a video walkthrough and an icon. After approval you choose when to select Publish plugin.

Key Takeaway
A 2026 ChatGPT plugin is the Apps SDK renamed and widened: a remote MCP server for tools, optional SKILL.md skills for workflow, and an optional MCP Apps UI returned from a tool through a ui:// resource. Build the server first, test it in developer mode, then submit through the platform dashboard after organisation verification.
I went back to the Apps SDK documentation to build a small ChatGPT integration for an ERP, a tool that answers where a sales order is, and the URL dropped me on a page titled Plugins. The tutorials I had bookmarked said apps, the ChatGPT settings said plugins, and older answers still said connectors. They are the same product line under three names, and the code underneath barely changed.
This post untangles the names with dates, then builds the thing: a read-only MCP server with one ERP tool, an order-status card rendered as an MCP App, a skill that wraps the tool in a follow-up workflow, local testing, and what the directory submission asks for. Every API name comes from OpenAI's plugin docs or the official ext-apps repository, cited at the end.
If you searched for ChatGPT plugins Apps SDK and found pages that disagree, it is because the naming moved three times in under a year. None of the moves broke the underlying contract: ChatGPT connects to a remote MCP server and calls its tools.
| When | What it was called | What changed for developers |
|---|---|---|
| 6 October 2025 | Apps in ChatGPT, built with the Apps SDK (preview) | The SDK was built on MCP and extended it so one server defines both the logic and the interface |
| 2025 | Connectors, then absorbed into apps | Data-source integrations such as Google Drive and SharePoint stopped being a separate concept |
| 9 July 2026 | Plugins, listed in the Plugin Directory | Existing apps were packaged into plugins, which can bundle apps, skills and app templates |
| Today | developers.openai.com/apps-sdk redirects to /plugins | The docs describe building plugins with skills, MCP servers and optional UI |
The practical consequence: an Apps SDK tutorial from late 2025 is still mostly correct about the MCP server, and mostly wrong about packaging and distribution. Keep the server code, re-read everything about where it is published. Also note that these plugins are unrelated to the 2023 plugin beta, which was shut down in April 2024. Same word, different system.
OpenAI's plugin architecture page lists the parts, and only some are mandatory. Start with the smallest shape that covers the use case; the docs explicitly allow adding an MCP server or UI later.
erp-order-status/
├── plugin.json # manifest: name, version, description, OpenAI extensions
├── mcp.json # where the remote MCP server lives
├── .app.json # maps the registered MCP app; referenced from plugin.json
└── skills/
└── late-order-follow-up/
└── SKILL.md # the workflow the model follows around the tool
# plugin.json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "erp-order-status",
"version": "1.0.0",
"description": "Look up sales order status from the ERP and draft customer updates.",
"extensions": {
"com.openai": {
"apps": "./.app.json",
"interface": { "displayName": "ERP Order Status", "category": "Productivity" }
}
}
}
# mcp.json
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"erp-order-status": {
"type": "streamable-http",
"url": "https://erp-mcp.example.com/mcp"
}
}
}The manifest is a portable plugin.json at the root, with OpenAI-specific fields under an extensions.com.openai block. That split matters if you also target Codex: OpenAI says one published plugin lands in a directory shared by ChatGPT and Codex, and Codex reads a .codex-plugin/plugin.json fallback.
The tool answers one question and nothing else: given a sales order number, return its stage, promised date and line-level shipped quantities. It reads from a read model over the ERP, never from an endpoint that can post a document. The registration below follows the shape of the official ext-apps example, which uses registerAppTool so the tool can carry a link to its UI.
// server.ts
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE, // "text/html;profile=mcp-app"
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import { z } from "zod";
import { findSalesOrder } from "./erp.js"; // read model over the ERP, never the write API
const ORDER_CARD_URI = "ui://order-status/v1.html";
export function createServer(): McpServer {
const server = new McpServer({ name: "erp-order-status", version: "1.0.0" });
registerAppTool(
server,
"get_order_status",
{
title: "Get sales order status",
// The description is routing logic: it decides when ChatGPT calls you.
description:
"Use this when the user asks where a sales order is: confirmed, picking, " +
"shipped or invoiced. Read-only. It cannot change, cancel or reprice an order.",
inputSchema: z.object({
orderNumber: z.string().regex(/^SO-\d{6}$/).describe("Sales order number, e.g. SO-004217"),
}),
outputSchema: z.object({
orderNumber: z.string(),
stage: z.enum(["confirmed", "picking", "shipped", "invoiced"]),
promisedDate: z.string(),
lines: z.array(z.object({ sku: z.string(), ordered: z.number(), shipped: z.number() })),
}),
// Explicit booleans, all three. Reviewers check these against behaviour.
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
// Standard MCP Apps key. ChatGPT also reads the alias _meta["openai/outputTemplate"].
_meta: { ui: { resourceUri: ORDER_CARD_URI } },
},
async ({ orderNumber }) => {
const order = await findSalesOrder(orderNumber);
if (!order) {
return { isError: true, content: [{ type: "text", text: `No sales order ${orderNumber}.` }] };
}
return {
// Seen by the model AND the card. Keep it small: it stays in context.
structuredContent: {
orderNumber,
stage: order.stage,
promisedDate: order.promisedDate,
lines: order.lines,
},
// What the model narrates from.
content: [{ type: "text", text: `${orderNumber} is ${order.stage}, promised ${order.promisedDate}.` }],
// Card-only data the model never sees. No trace IDs or internal keys here either.
_meta: { warehouseLabel: order.warehouseDisplayName },
};
},
);
registerAppResource(
server,
"Order status card",
ORDER_CARD_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async () => ({
contents: [
{
uri: ORDER_CARD_URI,
mimeType: RESOURCE_MIME_TYPE,
text: await fs.readFile("dist/order-card.html", "utf-8"), // single-file Vite build
// Empty allowlists: the card fetches nothing. Widen only what you use.
_meta: { ui: { prefersBorder: true, csp: { connectDomains: [], resourceDomains: [] } } },
},
],
}),
);
return server;
}The return value has three channels, and they are the easiest part to get wrong. structuredContent is concise data that both the model and the UI read, and it stays available to the model in later turns, so it should hold facts rather than pages of rows. content is the text the model narrates from. _meta is client-specific data hidden from the model, useful for display-only labels.
The description and the annotations do more work than the handler. The description is how ChatGPT decides to call the tool, so it says when to use it and what it cannot do. The three annotations must be explicit booleans: readOnlyHint true for retrieval, destructiveHint false because nothing is overwritten, openWorldHint false because the data is confined to one private ERP tenant.
The UI is an HTML resource with a ui:// URI and the MIME type text/html;profile=mcp-app. The tool points at it through _meta.ui.resourceUri; ChatGPT also accepts the older openai/outputTemplate key as an alias. When the tool runs, the host fetches the resource, renders it in a sandboxed frame and forwards the tool result over postMessage. The App class from the ext-apps package wraps that bridge.
// order-card.ts — bundled into dist/order-card.html
import { App } from "@modelcontextprotocol/ext-apps";
type OrderStatus = {
orderNumber: string;
stage: string;
promisedDate: string;
lines: { sku: string; ordered: number; shipped: number }[];
};
const app = new App({ name: "Order status card", version: "1.0.0" });
let current: OrderStatus | undefined;
// Wrong: connecting first and attaching handlers afterwards. The initial tool
// result can arrive before anyone is listening, and the card renders empty.
// Right: register every handler, then connect.
app.ontoolresult = (result) => {
const order = result.structuredContent as OrderStatus | undefined;
if (order) render(order);
};
document.getElementById("refresh")!.addEventListener("click", async () => {
if (!current) return;
// The card can call its own server's tools without a new chat turn.
const result = await app.callServerTool({
name: "get_order_status",
arguments: { orderNumber: current.orderNumber },
});
render(result.structuredContent as OrderStatus);
});
function render(order: OrderStatus) {
current = order;
// textContent, never innerHTML: structuredContent is untrusted input.
document.getElementById("stage")!.textContent = order.stage;
document.getElementById("promised")!.textContent = order.promisedDate;
const open = order.lines.filter((l) => l.shipped < l.ordered).length;
document.getElementById("open-lines")!.textContent = String(open);
}
app.connect();Two rules from the docs shape this code. Treat structuredContent as untrusted input, so the card writes textContent and never innerHTML. And declare a Content Security Policy on the resource: connectDomains for anything the card fetches, resourceDomains for scripts, fonts or images it loads. An order card that only renders the tool result needs neither, so both lists stay empty.
Associate the ui:// resource only with the tools that should render it. In an ERP plugin most tools, such as search or totals, are better answered in prose; a card on every call turns a conversation into a dashboard nobody asked for. I version the URI, as the docs' own example does, so a redesigned card is a new resource rather than a silent edit.
A tool says what the ERP can answer; a skill says how a salesperson's task should go. The skill below turns the order-status tool into a late-order follow-up routine. It has the two required fields, a name and a description that tells the model when to consider it, and then plain numbered instructions.
---
name: late-order-follow-up
description: Use when a sales user asks which of a customer's orders are late, or what to tell a customer about a delayed shipment.
---
# Late order follow-up
1. Call get_order_status for every order number the user gives. Never infer a stage.
2. An order is late when its stage is "confirmed" or "picking" and promisedDate is before today.
3. For each late order, list the lines where shipped is less than ordered.
4. Draft a short customer update: what shipped, what has not, and the original promised date.
Do not promise a new date. The tool does not return one, so any date you write is invented.
5. If the tool returns an error, say the order number was not found and ask the user to check it.
Do not retry with a "corrected" number you made up.Most of the value is in the refusals. Step four forbids promising a new delivery date, because the tool never returns one and a confident model will otherwise invent a plausible date for a real customer. Step five stops it retrying with a guessed order number. These are the failure modes I would expect from any model wired to ERP data, and a skill is the cheapest place to close them.
Run the server statelessly: a fresh server and transport per request, no session ID. That is how the ext-apps example is written, and it means any replica behind a load balancer can answer any call. Before ChatGPT sees it, connect the MCP Inspector to the local /mcp endpoint and call the tool by hand with a valid number, an unknown number and a malformed one.
// main.ts
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import { createServer } from "./server.js";
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.all("/mcp", async (req, res) => {
const server = createServer();
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
# then, in another terminal, before ChatGPT ever sees it:
# npx @modelcontextprotocol/inspector -> connect to http://localhost:3000/mcpDeveloper-mode access is not the same on every plan. Gradually.ai reports that custom MCP apps on Pro work on the web only and are limited to read and fetch actions, while full MCP support including write actions is rolling out in beta on Business, Enterprise and Edu. Design an ERP plugin read-first, and confirm the current limits for your plan before promising anyone a write tool.
Submission happens in the OpenAI platform dashboard under Plugins, with Upload new or existing plugin. Automated checks run against the package first; you review their findings, add what the review team needs, and submit the draft. Prepare these before you start, because each one blocks the form.
Approval does not publish anything. You open the approved version and select Publish plugin when you are ready. The guidelines are where ERP plugins usually need rework: tools must not request the full conversation history or context fields just in case, and responses should not carry session IDs, trace IDs, request IDs or logging metadata. An ERP API returns all of those by default, so map its responses to a narrow shape on purpose.
The UI layer is the open MCP Apps standard, published as @modelcontextprotocol/ext-apps with /server and /react entry points. The repository lists ChatGPT, Claude, VS Code, Goose, Postman and MCPJam as hosts that render it, so the order card built here is not locked to one chat product.
The name changed three times; the contract did not. Build a narrow, read-only MCP server with honest annotations, return a UI only where a picture beats a sentence, put the workflow and its refusals in a skill, and treat the submission checklist as part of the build rather than paperwork at the end.
Sources