How to create and publish a ChatGPT plugin
By Flavio Copes
Create a ChatGPT plugin from scratch: a booking MCP server, a skill and a plugin.json package, tested in developer mode and submitted to the directory.
A ChatGPT plugin is a folder with a plugin.json manifest that bundles skills, an MCP server, or both. You build those pieces, test them in ChatGPT’s developer mode, zip the folder and upload it on the Plugins page of the OpenAI Platform dashboard. OpenAI runs automated checks and a review. Once the plugin is approved, you decide when to publish it, and it shows up in the plugin directory that ChatGPT and Codex share.
In this tutorial we build a plugin from an empty folder and take it all the way to the submit button. It lets people book a repair at a bike shop from a ChatGPT conversation: check the prices, find a free time, book it, and cancel it later. That’s something ChatGPT can’t do on its own, and it’s the kind of plugin a developer could build for a real client.
If you remember the 2023 ChatGPT plugins, the ones described by an ai-plugin.json file and an OpenAPI spec, OpenAI retired those in 2024. The plugins in this post only share the name. They’re built on MCP and skills.
What goes inside a ChatGPT plugin?
A plugin can contain:
- skills, folders with a
SKILL.mdfile that tell the model how to run a workflow - an MCP server, which gives the model tools that read live data or take actions on a service you run
- UI, optional, which the MCP server returns for tools that need something visual, like a map or an editable schedule
- lifecycle hooks, commands that run at set points in the Codex runtime
You don’t need all of them. A plugin can be skills only, an MCP server only, or both.
An MCP server is a program that lists tools and runs them when the model asks. If that’s new to you, read what MCP is first.
Skills and the MCP server do different jobs. The server holds the data and decides who can do what. The skill describes the workflow around it: which tool to call first, what to ask the user, when to stop.
One directory serves both products, so a public plugin gets a single listing that people find in ChatGPT and in Codex. Hook scripts are one exception, because they have to exist on the machine where Codex runs.
What we’re going to build
Our plugin is for Pedale Rosso, a bike repair shop in Milan. The shop is made up, and so is everything about it, but the plugin is complete. It has four tools:
list_servicesreturns the repairs the shop offers, with duration and pricefind_slotsreturns the free times on a given daybook_slotbooks a repair and returns a booking codecancel_bookingcancels a booking, given its code
A book-repair skill ties them together: ask what’s broken, offer free times, confirm the details, book, and hand over the code.
Two of the tools only read and two of them write, one of which can’t be undone. That’s what reviewers look at most closely, so the example covers all of it. There’s no login: anyone can book, like on a shop’s public booking page, and the booking code works as the key for cancelling. Login and UI come up near the end.
We’ll end up with two folders: the MCP server, which we deploy, and the plugin package, which we zip and upload. The ZIP never contains the server code, only the URL where the server lives.
pedale-rosso-mcp/ the MCP server, deployed to Cloudflare
├── package.json
├── schema.sql
├── wrangler.jsonc
└── src/
├── shop.js
├── server.js
└── worker.js
pedale-rosso/ the plugin package, zipped and uploaded
├── plugin.json
├── mcp.json
├── assets/
│ ├── icon.png
│ └── logo.png
└── skills/
└── book-repair/
└── SKILL.md
Step 1: build the MCP server
ChatGPT talks to MCP servers over HTTPS, using the streamable HTTP transport. A public plugin needs its server at a stable, public HTTPS URL, so the server has to run somewhere on the internet.
We’ll write it as a Cloudflare Worker and keep the bookings in D1, Cloudflare’s SQLite database. The official MCP TypeScript SDK gives us a handler that takes a standard Request and returns a Response, which is exactly what a Worker is. We get HTTPS and a database without managing a server, and both have a free plan. The same handler also runs on Deno and Bun, and on Node through a small adapter.
You need Node.js and a free Cloudflare account. Create the project:
mkdir pedale-rosso-mcp
cd pedale-rosso-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install -D wrangler
mkdir src
@modelcontextprotocol/server is version 2 of the SDK, the current stable line. OpenAI’s own examples still use the 1.x package, @modelcontextprotocol/sdk, so their imports and HTTP setup look different. Tools, schemas and annotations work the same way in both. My MCP server tutorial uses version 2 too, for a local stdio server.
The database
Each booking is one row. Create schema.sql:
CREATE TABLE IF NOT EXISTS bookings (
code TEXT PRIMARY KEY,
service TEXT NOT NULL,
date TEXT NOT NULL,
time TEXT NOT NULL,
name TEXT NOT NULL,
phone TEXT NOT NULL,
UNIQUE (date, time)
);
UNIQUE (date, time) is what stops two customers from getting the same slot. Even if two requests arrive at the same moment, the database accepts only one of them.
Create wrangler.jsonc with the D1 binding:
{
"name": "pedale-rosso-mcp",
"main": "src/worker.js",
"compatibility_date": "2026-09-30",
"d1_databases": [
{ "binding": "DB", "database_name": "pedale-rosso" }
]
}
Set compatibility_date to the day you create the project. The binding has no database_id yet. For local development Wrangler uses a local SQLite file, and we’ll create the real database when we deploy.
Create the table in the local database:
npx wrangler d1 execute pedale-rosso --local --file=schema.sql
The shop
Now the shop itself: its services, its opening times and the rules for which days you can book. Create src/shop.js:
export const services = [
{ id: 'flat-tire', name: 'Flat tire repair', minutes: 30, priceEur: 15 },
{ id: 'brake-service', name: 'Brake service', minutes: 45, priceEur: 35 },
{ id: 'tune-up', name: 'Tune-up', minutes: 60, priceEur: 45 },
{ id: 'wheel-truing', name: 'Wheel truing', minutes: 45, priceEur: 30 }
]
export const times = ['09:00', '10:00', '11:00', '14:00', '15:00', '16:00', '17:00']
const OPEN_DAYS = [2, 3, 4, 5, 6]
const MAX_DAYS_AHEAD = 30
export function today() {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Rome' }).format(
new Date()
)
}
export function checkDate(date) {
const day = new Date(`${date}T12:00:00Z`)
if (Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== date) {
return `${date} is not a valid date.`
}
const start = new Date(`${today()}T12:00:00Z`)
const daysAhead = Math.round((day - start) / 86400000)
if (daysAhead < 1) {
return 'Bookings start from tomorrow.'
}
if (daysAhead > MAX_DAYS_AHEAD) {
return `You can book up to ${MAX_DAYS_AHEAD} days ahead.`
}
if (!OPEN_DAYS.includes(day.getUTCDay())) {
return 'The shop is closed on Sundays and Mondays.'
}
return null
}
Every appointment takes one of seven fixed start times, whatever the service. The shop is open Tuesday to Saturday (days 2 to 6 in JavaScript, where Sunday is 0), and you can book from tomorrow up to 30 days ahead.
today() uses the shop’s time zone. A Worker’s clock runs in UTC, so shortly after midnight in Milan it would still think it’s yesterday. The en-CA locale formats dates as YYYY-MM-DD, the same format the tools use.
checkDate() returns a sentence explaining what’s wrong with a date, or null when the date is fine. Those sentences go straight back to the model.
The tools
Now the MCP server with its four tools. Create src/server.js:
import { McpServer } from '@modelcontextprotocol/server'
import * as z from 'zod/v4'
import { checkDate, services, times } from './shop.js'
const booking = z.object({
code: z.string(),
service: z.string(),
date: z.string(),
time: z.string()
})
const dateInput = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe('The day, in YYYY-MM-DD format')
function fail(message) {
return { content: [{ type: 'text', text: message }], isError: true }
}
function booked({ code, service, date, time }) {
const { name } = services.find((item) => item.id === service)
return {
content: [
{
type: 'text',
text: `Booked: ${name} on ${date} at ${time}. The booking code is ${code}.`
}
],
structuredContent: { code, service, date, time }
}
}
export function createServer(db) {
const server = new McpServer(
{ name: 'pedale-rosso', version: '1.0.0' },
{
instructions:
'Pedale Rosso is a bike repair shop in Milan, open Tuesday to Saturday. Dates are in Europe/Rome time. Call find_slots before book_slot, and only book after the customer confirms the service, day, time, name and phone number.'
}
)
server.registerTool(
'list_services',
{
title: 'List repair services',
description:
'Use this to see which repairs Pedale Rosso offers, how long each one takes, and its price in euros.',
outputSchema: z.object({
services: z.array(
z.object({
id: z.string(),
name: z.string(),
minutes: z.number(),
priceEur: z.number()
})
)
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false
}
},
async () => ({
content: [
{
type: 'text',
text: services
.map((item) => `${item.name}: ${item.minutes} minutes, €${item.priceEur}`)
.join('\n')
}
],
structuredContent: { services }
})
)
server.registerTool(
'find_slots',
{
title: 'Find free times',
description:
'Use this to find the free appointment times on one day. Call it before book_slot.',
inputSchema: z.object({ date: dateInput }),
outputSchema: z.object({
date: z.string(),
times: z.array(z.string())
}),
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false
}
},
async ({ date }) => {
const problem = checkDate(date)
if (problem) return fail(problem)
const { results } = await db
.prepare('SELECT time FROM bookings WHERE date = ?')
.bind(date)
.all()
const taken = results.map((row) => row.time)
const free = times.filter((time) => !taken.includes(time))
return {
content: [
{
type: 'text',
text: free.length
? `Free times on ${date}: ${free.join(', ')}.`
: `${date} is fully booked.`
}
],
structuredContent: { date, times: free }
}
}
)
server.registerTool(
'book_slot',
{
title: 'Book a repair',
description:
'Use this to book a repair after the customer confirmed the service, day, time, name and phone number. Returns the booking code the customer needs to cancel.',
inputSchema: z.object({
service: z.enum(services.map((item) => item.id)),
date: dateInput,
time: z.enum(times).describe('A free time returned by find_slots'),
name: z.string().min(1).max(60).describe("The customer's name"),
phone: z
.string()
.regex(/^\+?[0-9 ]{6,20}$/)
.describe("The customer's phone number")
}),
outputSchema: booking,
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false
}
},
async ({ service, date, time, name, phone }) => {
const problem = checkDate(date)
if (problem) return fail(problem)
const existing = await db
.prepare('SELECT * FROM bookings WHERE date = ? AND time = ?')
.bind(date, time)
.first()
if (existing) {
const sameRequest =
existing.service === service &&
existing.name === name &&
existing.phone === phone
return sameRequest
? booked(existing)
: fail(`${time} on ${date} is already taken. Call find_slots for the free times.`)
}
const code = crypto.randomUUID().slice(0, 8).toUpperCase()
try {
await db
.prepare(
'INSERT INTO bookings (code, service, date, time, name, phone) VALUES (?, ?, ?, ?, ?, ?)'
)
.bind(code, service, date, time, name, phone)
.run()
} catch {
return fail(`${time} on ${date} was just taken. Call find_slots for the free times.`)
}
return booked({ code, service, date, time })
}
)
server.registerTool(
'cancel_booking',
{
title: 'Cancel a booking',
description:
'Use this to cancel a booking when the customer gives its booking code. It frees the time slot and cannot be undone.',
inputSchema: z.object({
code: z
.string()
.regex(/^[A-Za-z0-9]{8}$/)
.describe('The 8-character booking code')
}),
outputSchema: booking,
annotations: {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false
}
},
async ({ code }) => {
const cancelled = await db
.prepare('DELETE FROM bookings WHERE code = ? RETURNING code, service, date, time')
.bind(code.toUpperCase())
.first()
if (!cancelled) return fail(`There is no booking with the code ${code}.`)
return {
content: [
{
type: 'text',
text: `Cancelled booking ${cancelled.code} on ${cancelled.date} at ${cancelled.time}.`
}
],
structuredContent: cancelled
}
}
)
return server
}
createServer() is a factory that receives the database. The SDK calls it on every HTTP request and throws the instance away afterwards, so nothing lives on the server object between calls. The bookings live in D1, outside the server. That’s how stateless MCP looks in code.
The instructions string is guidance that applies to all the tools, and ChatGPT and Codex read it next to the tool metadata. OpenAI suggests putting the important part in the first 512 characters, without repeating the tool descriptions.
Each tool has a name, a title, a description, a schema for its input and its output, and annotations. The model reads all of them to decide when to call a tool and with which arguments, so write them like user-facing copy. The names are verbs that say what happens, and the descriptions start with when to use the tool. find_slots also says to call it before book_slot.
The input schemas do a lot of the safety work. service and time are enums, so the model can’t book a service the shop doesn’t offer or a time that doesn’t exist. phone must look like a phone number. Anything else is rejected before our code runs.
The annotations
The three annotation hints tell ChatGPT and Codex what a tool does to the world. They use them to choose confirmation and safety behavior, and wrong values are one of the common rejection reasons OpenAI lists.
readOnlyHintistrueonly when the tool can’t change anything. Sending an email, writing a log or starting a job all make itfalse.destructiveHintistruewhen a write is hard or impossible to undo, like deleting, overwriting, cancelling, sending a message or making a payment. Having a way to undo the action doesn’t, by itself, make it non-destructive.openWorldHintistruewhen the tool reaches the public internet or arbitrary destinations, like a web search or posting to a public site. A tool confined to one account, workspace or catalog can usefalse, even when that service is hosted elsewhere.
Here’s how our four tools come out:
| Tool | readOnlyHint | destructiveHint | openWorldHint |
|---|---|---|---|
list_services | true | false | false |
find_slots | true | false | false |
book_slot | false | false | false |
cancel_booking | false | true | false |
book_slot writes, but it only adds a booking and takes nothing away, so it’s not destructive. cancel_booking deletes a row, and the customer might lose that slot to someone else a minute later, so it is. None of them leaves the shop’s own booking system, so all four are closed-world. OpenAI wants all three hints set explicitly on every tool.
Results and errors
A tool result has two parts. structuredContent is data the model can reuse in later calls, like the booking code that cancel_booking needs. content is text that helps the model answer. OpenAI asks for results that work without any UI, which is what we return.
Notice what the results leave out. book_slot stores the customer’s name and phone number, but it never sends them back. OpenAI asks tools to return only what the request needs, and the phone number isn’t needed to confirm a booking.
When something goes wrong, the tool returns isError: true with a sentence the model can pass on: the shop is closed that day, the slot is taken, the code doesn’t exist. The conversation keeps going instead of failing.
book_slot is also safe to retry. If the model sends the same booking twice, for example after a timeout, the second call finds the existing row and returns the same code instead of an error. OpenAI asks tools to be safe to retry where possible, and a double-submitted booking is the classic case. If someone else holds the slot, it says so. If two requests race for the same slot, the UNIQUE constraint rejects the second insert and the catch turns that into an error result.
The Worker
Now the Worker that serves it all. Create src/worker.js:
import { createMcpHandler } from '@modelcontextprotocol/server'
import { createServer } from './server.js'
export default {
async fetch(request, env) {
const url = new URL(request.url)
if (url.pathname === '/mcp') {
const handler = createMcpHandler(() => createServer(env.DB))
return handler.fetch(request)
}
if (url.pathname === '/') {
return new Response('Pedale Rosso MCP server')
}
return new Response('Not found', { status: 404 })
}
}
The handler is created inside fetch() because that’s where the Worker gets env, and with it the D1 binding. The Worker answers MCP requests on /mcp, returns a line of text on / so we can check it’s up, and sends a 404 for everything else.
That 404 also covers the OAuth discovery URLs, like /.well-known/oauth-protected-resource, where ChatGPT looks for login metadata. OpenAI’s own quickstart answers those routes with a 404 too, which keeps ChatGPT from showing 502 errors while the server has no login.
Start the local server:
npx wrangler dev
Wrangler prints Ready on http://localhost:8787, so our MCP endpoint is http://localhost:8787/mcp.
Step 2: test the server with the MCP Inspector
Before involving ChatGPT, call the tools directly. If something breaks here, the problem is in the server, not in the model’s choices.
The MCP Inspector has a web interface, which opens with npx @modelcontextprotocol/inspector, and a CLI. I’ll use the CLI because its output is easier to show.
List the tools:
npx @modelcontextprotocol/inspector --cli http://localhost:8787/mcp --method tools/list
You get the four tools with their JSON schemas and annotations. This is what ChatGPT will see, so check the descriptions and the hints.
Look for free times. Use a Tuesday to Saturday in the next 30 days. I’m using 6 October 2026:
npx @modelcontextprotocol/inspector --cli http://localhost:8787/mcp \
--method tools/call --tool-name find_slots --tool-arg date=2026-10-06
Free times on 2026-10-06: 09:00, 10:00, 11:00, 14:00, 15:00, 16:00, 17:00.
That’s the content text. The full output also has the times as a list in structuredContent.
Book one of them:
npx @modelcontextprotocol/inspector --cli http://localhost:8787/mcp \
--method tools/call --tool-name book_slot \
--tool-arg service=brake-service --tool-arg date=2026-10-06 --tool-arg time=10:00 \
--tool-arg name="Marco Bianchi" --tool-arg phone="+39 333 123 4567"
{
"content": [
{
"type": "text",
"text": "Booked: Brake service on 2026-10-06 at 10:00. The booking code is 01E0C0AE."
}
],
"structuredContent": {
"code": "01E0C0AE",
"service": "brake-service",
"date": "2026-10-06",
"time": "10:00"
}
}
Your code will be different, since it’s random. Now run the same command again. You get the same code back, because it’s the same booking. Change the name and phone number and try once more:
10:00 on 2026-10-06 is already taken. Call find_slots for the free times.
Then cancel the booking with its code:
npx @modelcontextprotocol/inspector --cli http://localhost:8787/mcp \
--method tools/call --tool-name cancel_booking --tool-arg code=01E0C0AE
Cancelled booking 01E0C0AE on 2026-10-06 at 10:00.
Run it a second time and you get There is no booking with the code 01E0C0AE.
Now try the failures. A Sunday, like --tool-arg date=2026-10-04, reaches our code and comes back as an error result:
The shop is closed on Sundays and Mondays.
A service that isn’t in the list doesn’t even reach our code. The SDK rejects it against the enum:
Input validation error: Invalid arguments for tool book_slot: service: Invalid option: expected one of "flat-tire"|"brake-service"|"tune-up"|"wheel-truing"
One more check. The 2026-07-28 MCP spec changed the protocol a lot, and hosts move to it at their own pace. The v2 handler also serves clients on the older 2025 protocol by default, and you can confirm it with --protocol-era legacy:
npx @modelcontextprotocol/inspector --cli http://localhost:8787/mcp \
--protocol-era legacy --method tools/list
Step 3: try it in ChatGPT with developer mode
Developer mode lets you connect your own MCP server to your account as a personal plugin, before any review. ChatGPT has to reach the server from the internet, so localhost won’t work.
The quickest way around that is a Cloudflare Quick Tunnel, which gives your local port a temporary public HTTPS URL. My Quick Tunnels guide explains how to install cloudflared. With wrangler dev still running, open another terminal:
cloudflared tunnel --url http://localhost:8787
It prints a URL like https://roland-certificate-blast-continue.trycloudflare.com. Add /mcp to it and that’s the MCP server URL for ChatGPT. The tunnel passes MCP requests through unchanged, so the Inspector commands above give the same results against it.
Then connect it in ChatGPT:
- Open ChatGPT, go to Settings, then Security and login, and turn on Developer mode.
- Go to chatgpt.com/plugins and select the plus button.
- Enter a name and a short description, and paste the tunnel URL with
/mcpat the end as the MCP server URL. Our server needs no login. - Create the connection and check the tools ChatGPT discovered.
- Open your personal plugins, open the new plugin and select the plus button to install it.
- On the ChatGPT homepage, switch the tab from Chat to Work and start a new chat. Type
@, pick the plugin and ask something.
OpenAI says developer mode availability can depend on your account and workspace policy, so on a work account an admin may have turned it off.
Test more than the happy path. Try a direct request (“Book a tune-up on Thursday morning”), an indirect one (“My brakes squeak, can someone look at them this week?”), a follow-up that changes an earlier answer (“Actually, make it Friday”) and something unrelated that should call no tool (“Which bike should I buy?”). For each one, note which tool ran, with which arguments, and what came back. When the model picks the wrong tool or sends odd arguments, fix the tool names, descriptions and schemas.
After each change to the tools, open the plugin at chatgpt.com/plugins, select Refresh, and start a new chat before testing again.
If your MCP server must stay private, OpenAI also offers Secure MCP Tunnel. It works in developer mode but not for public submission.
Step 4: write the skill
The tools know how to book. They don’t know how a good booking conversation goes: what to ask first, when to repeat the details back, what to do without a booking code. That knowledge goes in a skill.
A skill is a folder with a SKILL.md file. Its frontmatter has a name and a description. The model first sees only those two, and it loads the rest when a request matches the description or when the user calls the skill directly. So write the description around the user’s goal, and keep the steps in the body.
Create the plugin package folder next to the server project, with the skill inside:
mkdir -p pedale-rosso/skills/book-repair
Create pedale-rosso/skills/book-repair/SKILL.md:
---
name: book-repair
description: Book, move or cancel a bike repair at Pedale Rosso when the user wants their bike fixed or needs to change an appointment.
---
Use this skill when the user wants to fix their bike, asks for an appointment, or wants to move or cancel one.
1. If the user didn't say what's wrong, ask. Call `list_services` and pick the matching service. If nothing matches, such as an e-bike motor or a car, say the shop doesn't do that and stop.
2. Ask which day suits them and call `find_slots`. Offer at most three free times. Never suggest a time that `find_slots` didn't return.
3. Ask for their name and phone number. Before calling `book_slot`, repeat the service, price, day, time, name and phone number, and wait for a clear yes.
4. After booking, give the booking code and tell the user to keep it. It's the only way to cancel.
To move an appointment, book the new time first, then cancel the old one with its code.
To cancel, ask for the booking code. Without it, explain that the booking can't be cancelled here and that the shop can help in person.
Don't invent prices, opening hours or services. Everything comes from the tools.
The skill adds things the tools can’t express. It puts a human confirmation in front of every booking. It defines moving an appointment as “book the new one, then cancel the old one”, so the customer never ends up with no appointment at all. And it says when to stop: repairs the shop doesn’t do, cancellations without a code. OpenAI’s skill guidance asks for exactly that last part, the points where the workflow should stop or decline.
Name the folder after the skill, and keep each skill focused on one goal. Workflows with different triggers or different inputs belong in separate skills.
You can also start from the built-in skill creator, @skill-creator in ChatGPT or $skill-creator in Codex. If skills are new to you, the free AI Agent Skills course covers writing and testing them.
Step 5: deploy the MCP server
The tools work, so let’s give the server its permanent address.
Choose that address carefully. After you publish, the origin of the MCP server (its scheme, hostname and port) can’t change. A new origin means submitting a new plugin. You can change the path in a new version, but not the host.
That’s a reason to skip the workers.dev URL Cloudflare gives every Worker, and use a hostname on your own domain. A real shop would use its own domain. Pedale Rosso doesn’t exist, so I’m using a subdomain of this site. When the domain is on Cloudflare, one route in wrangler.jsonc does it:
{
"name": "pedale-rosso-mcp",
"main": "src/worker.js",
"compatibility_date": "2026-09-30",
"routes": [
{ "pattern": "pedale-rosso.flaviocopes.com", "custom_domain": true }
],
"d1_databases": [
{ "binding": "DB", "database_name": "pedale-rosso" }
]
}
Replace the hostname with yours. Log in once with npx wrangler login, then deploy and create the table in the production database:
npx wrangler deploy
npx wrangler d1 execute pedale-rosso --remote --file=schema.sql
Because the D1 binding has no ID, the first deploy creates the database on your account and writes its ID back into wrangler.jsonc. With custom_domain, Cloudflare also creates the DNS record and the certificate for the hostname.
Run the Inspector commands from Step 2 against https://pedale-rosso.flaviocopes.com/mcp, the failing ones included, and cancel the test bookings you make. From now on you can also test in developer mode with this URL instead of the tunnel.
You don’t have to use Workers. OpenAI asks for a stable public HTTPS endpoint that speaks streamable HTTP, stays reachable for review, and logs failed calls. On Node, wrap the same handler with toNodeHandler() from @modelcontextprotocol/node, swap D1 for any database with a unique constraint, and run it on a VPS, Railway, Render or Fly.io. A temporary tunnel is fine for testing and not accepted for submission.
Step 6: package the plugin
The package tells ChatGPT and Codex which skills and which MCP server belong together, and it carries everything the directory listing shows.
OpenAI accepts two layouts. The portable Agent Plugins format has a plugin.json at the root, with OpenAI-specific settings under extensions.com.openai. The older Codex format uses .codex-plugin/plugin.json. OpenAI’s docs recommend the portable format for new packages, so that’s what we’ll write.
Create pedale-rosso/mcp.json to point at the deployed server:
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {
"pedale-rosso": {
"type": "streamable-http",
"url": "https://pedale-rosso.flaviocopes.com/mcp"
}
}
}
The Codex format has a similar .mcp.json file without the type field, so don’t just rename one into the other.
Then create pedale-rosso/plugin.json:
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "pedale-rosso",
"version": "1.0.0",
"description": "Book, move and cancel bike repairs at Pedale Rosso in Milan.",
"author": {
"name": "Pedale Rosso",
"url": "https://pedale-rosso.flaviocopes.com"
},
"homepage": "https://pedale-rosso.flaviocopes.com",
"keywords": ["bike", "repair", "booking", "milan"],
"extensions": {
"com.openai": {
"interface": {
"displayName": "Pedale Rosso",
"shortDescription": "Book a bike repair in Milan",
"longDescription": "Pedale Rosso is a bike repair shop in Milan. Ask ChatGPT what a repair costs, find a free time, and book it with your name and phone number. You get a booking code you can use later to cancel. The shop is open Tuesday to Saturday, and you can book up to 30 days ahead.",
"developerName": "Pedale Rosso",
"category": "Other",
"capabilities": ["See repair prices", "Find free times", "Book repairs", "Cancel bookings"],
"websiteURL": "https://pedale-rosso.flaviocopes.com",
"supportURL": "https://pedale-rosso.flaviocopes.com/support",
"privacyPolicyURL": "https://pedale-rosso.flaviocopes.com/privacy",
"termsOfServiceURL": "https://pedale-rosso.flaviocopes.com/terms",
"defaultPrompt": [
"My brakes squeak. Can I get them checked this week?",
"How much does a tune-up cost?",
"I need to cancel my bike repair."
],
"brandColor": "#C8102E",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png"
},
"review": {
"test_cases": {
"positive": [
{
"description": "Check a price",
"prompt": "How much does a tune-up cost?",
"tools_triggered": "list_services",
"expected_behavior": "Answers that a tune-up takes 60 minutes and costs €45."
},
{
"description": "Find free times",
"prompt": "Which times are free next Tuesday?",
"tools_triggered": "find_slots",
"expected_behavior": "Lists the free times on the next Tuesday, and no others."
},
{
"description": "Book a repair",
"prompt": "Book a brake service on Friday at 10:00. I'm Marco Bianchi, +39 333 123 4567.",
"tools_triggered": "list_services, find_slots, book_slot",
"expected_behavior": "Repeats the service, price, day, time, name and phone number, books after the user confirms, and returns an 8-character booking code."
},
{
"description": "Move a booking",
"prompt": "Move my brake service to Saturday morning. My booking code is the one from the previous case.",
"tools_triggered": "find_slots, book_slot, cancel_booking",
"expected_behavior": "Books a free Saturday morning time, then cancels the old booking, and returns the new booking code."
},
{
"description": "Cancel a booking",
"prompt": "Cancel my booking. The code is the one from the previous case.",
"tools_triggered": "cancel_booking",
"expected_behavior": "Cancels the booking and confirms the day and time that were freed."
}
],
"negative": [
{
"description": "The shop doesn't repair e-bike motors. The plugin should say so and book nothing.",
"prompt": "Can you fix the motor of my e-bike tomorrow?"
},
{
"description": "The shop is closed on Sundays. The plugin should explain that and suggest Tuesday to Saturday instead of booking.",
"prompt": "Book a tune-up this Sunday at 10."
},
{
"description": "Cancelling needs a booking code. The plugin should refuse to cancel other people's bookings.",
"prompt": "Cancel all the bookings for tomorrow."
}
]
},
"commerce": false
},
"publication": {
"countries": ["IT"],
"release_notes": "First release: repair prices, free times, booking and cancelling."
}
}
}
}
The root fields are the package identity. name is the stable identifier, in lowercase with dashes, and every later upload must use the same one. version is a semantic version, and you bump it on each new upload. The portable schema marks description and author.name optional, but submission requires both.
Everything under interface is the listing people see. displayName and shortDescription can be 30 characters each. category has to be one of OpenAI’s values, such as Productivity, Developer Tools or Travel. None of them fits a bike shop, so it’s Other. Up to three defaultPrompt entries become starter prompts, 128 characters at most and without @ mentions. brandColor needs at least 2:1 contrast against white, and OpenAI derives a dark-mode color when you don’t set brandColorDark.
A plugin with an MCP server needs all four URLs: website, support, privacy policy and terms of service, as public HTTPS pages from the same publisher. Pedale Rosso is made up, so these pages don’t exist. A real shop would need all four, and its privacy policy would have to say that it stores the customer’s name and phone number, why, and for how long. Returning user data the privacy policy doesn’t mention is one of the rejection reasons OpenAI lists.
logo is the main listing icon and composerIcon the small one shown in the composer. Both point at square images in assets/, as PNG, JPEG, WebP or SVG, at least 48×48 pixels and at most 5 MiB. Leave screenshots out. OpenAI accepts them only when the MCP server returns custom UI, and the directory no longer shows them.
The review block is what the review team uses to test the plugin. The first review needs exactly five positive test cases and three negative ones. A positive case names the prompt, the tools it should trigger and the result you expect. A negative case is a prompt where the plugin should decline, ask for clarification or stay out of the way. Our cases chain together (book, move, cancel), so a reviewer can run them in order. Run every case before submitting, since a case that fails in review is one of the rejection reasons OpenAI lists. commerce: false declares that the plugin sells nothing: it books a repair, and payment happens at the shop.
publication.countries limits where the plugin is available. A shop in Milan has little reason to be listed outside Italy. Leave the field out to keep whatever targeting you had before, or set it to [] to remove the restriction.
Whatever you put in the ZIP becomes read-only in the dashboard, and changing it means uploading a new ZIP. Fields you leave out can be filled in the dashboard instead. We’ll do that with the video walkthrough, which we can only record once everything works.
Keep secrets out of the ZIP. OpenAI also can’t accept packages that contain a .app.json file or lifecycle hooks yet.
Or let Plugin Creator write it
If you’d rather not write the manifest by hand, OpenAI has a Plugin Creator: @plugin-creator in ChatGPT’s Work mode or $plugin-creator in Codex. It scaffolds the Codex layout, .codex-plugin/plugin.json, and can add a local marketplace entry for testing. It’s also available in the plugin directory, where it can help prepare the review information.
Both layouts are accepted, but pick one. When the root plugin.json has an extensions.com.openai object, OpenAI ignores .codex-plugin/plugin.json, and the two files are never merged. Plugin Creator can also generate a .app.json file to wire in a server you registered in developer mode. That’s useful locally, but remove it and declare the server in mcp.json before you submit.
Test the whole package locally
Before uploading, install the package the way a user would and run your prompts again.
A marketplace is a JSON file that lists plugins. The ChatGPT desktop app and Codex both read a personal one from ~/.agents/plugins/marketplace.json. Copy the package to ~/.codex/plugins/pedale-rosso, then create the marketplace file:
{
"name": "local-plugins",
"interface": {
"displayName": "Local plugins"
},
"plugins": [
{
"name": "pedale-rosso",
"source": {
"source": "local",
"path": "./.codex/plugins/pedale-rosso"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Other"
}
]
}
The path is relative to your home folder, which is the root of the personal marketplace. Restart the ChatGPT desktop app and install the plugin from the local source in the Plugins Directory. In the Codex CLI, it’s one command:
codex plugin add pedale-rosso@local-plugins
Codex then loads the MCP server from mcp.json and lists the skill as pedale-rosso:book-repair, with the plugin name in front.
Check that the skill confirms the details before every booking, that it refuses repairs the shop doesn’t do, and that it stays quiet for unrelated questions. When you change the package, update the copy in ~/.codex/plugins and restart the app.
Step 7: submit the plugin
This is the flow as OpenAI documents it on September 30, 2026.
Before you start
Submissions happen in the OpenAI Platform dashboard, not in ChatGPT, under the organization and project that will own the plugin. Organization owners can submit. Other members need the Apps Management Write permission, which an owner can grant in the organization roles.
You also need to verify your identity in the organization settings: individual verification to publish under your own name, or business verification to publish under a company name. The directory shows the verified name, whatever developerName says in your ZIP. To publish under a client’s name, that business has to be the one verified.
If your project uses EU data residency, you can’t submit a plugin with an MCP server yet. Use a project with global data residency, and create one in the same organization if you don’t have it.
Upload the ZIP
Zip the contents of the package folder, so plugin.json sits at the root of the archive:
cd pedale-rosso
zip -r ../pedale-rosso.zip . -x '.*' -x '*/.*'
The -x patterns keep hidden files like .DS_Store out. A ZIP with one top-level folder containing the plugin also works, as long as nothing sits next to that folder.
Open Plugins in the dashboard, select Upload new or existing plugin, choose your verified developer identity and upload the ZIP. If it validates, the draft opens.
Include the MCP server in this first upload. You can’t add an MCP server later to a plugin that started as skills only.
Fix the automated findings
The draft has two sections. Metadata & Skills checks the package, and MCPs checks the server. They’re separate because a package change needs a new ZIP, while tool changes are read straight from your server.
Wait for the metadata checks and the skill scans, which can take up to two hours. Each finding names the field to fix. Copy issues copies the list, and pasting it into Codex next to your package is a quick way to get the fixes. The submission errors reference explains every error code. Fix the package and upload the corrected ZIP.
Connect the server and verify the domain
In MCPs, select the pedale-rosso server and Connect. Only one MCP server can be connected per plugin, even if the package declares more.
The portal then asks you to prove that you control the domain. It shows a token that must be served as plain text at https://pedale-rosso.flaviocopes.com/.well-known/openai-apps-challenge. The body must be exactly the token, with no JSON around it.
Let’s add that route to the Worker. Here’s the complete src/worker.js:
import { createMcpHandler } from '@modelcontextprotocol/server'
import { createServer } from './server.js'
export default {
async fetch(request, env) {
const url = new URL(request.url)
if (url.pathname === '/mcp') {
const handler = createMcpHandler(() => createServer(env.DB))
return handler.fetch(request)
}
if (
url.pathname === '/.well-known/openai-apps-challenge' &&
env.OPENAI_APPS_CHALLENGE
) {
return new Response(env.OPENAI_APPS_CHALLENGE)
}
if (url.pathname === '/') {
return new Response('Pedale Rosso MCP server')
}
return new Response('Not found', { status: 404 })
}
}
The token goes in wrangler.jsonc as a variable:
{
"name": "pedale-rosso-mcp",
"main": "src/worker.js",
"compatibility_date": "2026-09-30",
"routes": [
{ "pattern": "pedale-rosso.flaviocopes.com", "custom_domain": true }
],
"d1_databases": [
{ "binding": "DB", "database_name": "pedale-rosso" }
],
"vars": {
"OPENAI_APPS_CHALLENGE": "paste-the-token-from-the-portal"
}
}
Your file will also have the database_id that the first deploy added. The route stays a 404 until the variable is set. Deploy again with npx wrangler deploy, open the challenge URL to check it returns the token, and complete verification in the portal. If another plugin already uses the challenge URL on the same host, use a parent domain or a different hostname instead of replacing its token.
After verification, OpenAI scans the tools. Read the discovered tools and their findings. When a finding is about the server, fix it, deploy, and select Rescan.
Fill in the review details
In Metadata & Skills, open Review information, then Review details. The test cases from plugin.json are already there. Add what’s missing:
- A video walkthrough. Record the test cases and the main features, upload the video, and paste a URL the reviewers can open.
- Reviewer credentials, if your server requires a login. Ours doesn’t. If yours does, give reviewers a dedicated test account with sample data that works right away, without MFA, email or SMS codes, magic links or a private network. Login problems are one of the common rejection reasons OpenAI lists.
- Release notes, which we already set in
publication.release_notes.
Credentials only go in this form. OpenAI rejects packages that contain test_credentials or reviewer_instructions fields.
Submit and publish
Select the draft, choose Submit for review and confirm the policy attestations. You can follow the progress under Review status on the Plugins page, and feedback arrives by email. OpenAI gives no review timeline and asks you not to request expedited reviews.
Only one review can be active per plugin. To replace a package under review, cancel the review first. If the plugin is rejected, the email says why. Fix it and submit a corrected ZIP, or reply to the email to appeal.
Approval doesn’t publish anything. Open the approved version and select Publish plugin when you’re ready. People can then find it by searching its name in the directory, or through its direct link. The directory’s main pages feature only the plugins OpenAI picks, and you can’t ask to be featured.
How do you update a published plugin?
It depends on what you change.
Server changes go live without a new version. OpenAI scans published MCP servers daily, and you can select Rescan right after deploying. Each tool is checked on its own. A new tool stays unavailable until it passes, and a removed tool disappears at the next scan. A changed tool keeps its old definition live until the new one passes, so the server must keep accepting the old schema in the meantime. If Pedale Rosso adds a new service, for example, book_slot should keep accepting bookings in the old format until the updated schema is approved. Fixes to what a tool returns need no scan at all, as long as the published contract stays the same.
Package changes, like the listing text, the icons or the skill, need a new ZIP with a new version. Each upload gets its own checks and review, and publishing the approved version replaces the previous one.
And a new hostname for the server means a new plugin, as we saw in Step 5.
What gets a plugin rejected?
Read the plugin guidelines before you start building. These are the rules I’d keep in mind from day one:
- The plugin must do something useful that ChatGPT can’t do on its own, and do it reliably. Demo and trial plugins are rejected, so a made-up shop like Pedale Rosso can’t be submitted. A real shop’s booking plugin has a strong case, because ChatGPT can’t book a repair by itself. OpenAI even runs beta programs with partners for restaurant reservations and local-service quotes.
- Names can’t be generic dictionary words, and you can’t add “MCP” or “Plugin” to a product name. Descriptions can’t compare you with other products or mention pricing, trials or discounts, and tool descriptions can’t steer the model toward your plugin.
- Plugins can’t sell digital products or services, like subscriptions, credits or digital content, and they can’t show ads. Commerce is limited to physical goods, with checkout on your own site. Users can sign in to a paid account they already have and use what their plan includes, but the plugin can’t show plans, link to a checkout or push upgrades.
- Unofficial connectors to third-party services are out, and so is using another company’s API without their authorization.
- Tools ask for the minimum input and return only what the request needs. Asking for the conversation history or a precise location gets flagged, and so do session IDs, trace IDs and timestamps in results. Card data, health information, government IDs, passwords and API keys can’t be collected at all.
- The plugin has to work in ChatGPT on desktop and on mobile.
Irreversible actions need a human confirmation on top of the right annotations. That’s why the skill repeats the details before every booking. My MCP server that buys Cloudflare domains takes the same approach: the purchase only runs after an exact human approval.
Where do login and UI fit?
Our plugin has neither. The booking code is a lightweight stand-in for a login: whoever has it can cancel that one booking, and nothing else. That’s fine for a public booking page. When customers should see their own bookings, or you store more than a name and a phone number, it’s time for a login.
When tools read private data or act for a user, the server has to authenticate users with OAuth 2.1, following the MCP authorization spec. The server publishes protected resource metadata at /.well-known/oauth-protected-resource, pointing to your authorization server, and verifies the access token on every request. Authorization always happens in the server, never in the model. OpenAI’s authentication guide covers the flow, and the free Build with MCP course has lessons on authenticating and deploying a remote server.
For UI, ChatGPT implements the open MCP Apps standard. A tool declares a UI resource with _meta.ui.resourceUri, and ChatGPT renders that HTML in a sandboxed iframe in the conversation, with a content security policy listing the domains it can load from. A slot picker would be a good first component for Pedale Rosso, since people compare times faster on a grid than in a sentence. Keep every tool useful without the UI, because not every client renders components. Start from OpenAI’s Add UI to your MCP server guide.
Already have a Claude Code plugin?
You can bring it over, but Claude listings and approvals don’t transfer, so it goes through the same review. The portal converts .claude-plugin/plugin.json into its own format on upload. Claude commands and agents have to become skills, userConfig values need a replacement such as OAuth or an explicit input, and a local MCP server has to be deployed at a public HTTPS URL first. OpenAI’s conversion guide lists every difference.
Want me to talk about your product? You can sponsor this site.