Publish Your MCP Server as a ChatGPT and Codex Plugin

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The pipeline, in four steps

A plugin is a folder with a manifest at .codex-plugin/plugin.json that points at whatever you're shipping: skills, an MCP server, or both. Getting yours into the directory that ChatGPT and Codex share looks like this.

  1. Package. Write the manifest, put skills under skills/, and wire the MCP server through .mcp.json or .app.json.
  2. Test. Register the server in ChatGPT developer mode, then install the packaged plugin from a local marketplace and run it end to end.
  3. Submit. Open the plugin submission portal, scan the server, and fill in listing copy, prompts, and test cases.
  4. Publish. Approval doesn't put you in the directory. You publish the approved version yourself from the portal (submission docs).

Almost nothing in steps 3 and 4 is about the folder. The MCP server review requirements sit on the server: its tool names, schemas, annotations, error behavior, and the data its responses actually return. Packaging is the moment your server's quality stops being a private matter.

Prerequisites

  • An MCP server deployed on a public HTTPS domain speaking streamable HTTP, typically at /mcp. If yours isn't there yet, building a Streamable HTTP MCP server and the Apps SDK TypeScript quickstart both end with one.
  • The ability to serve a file at /.well-known/openai-apps-challenge on the MCP host or a parent host.
  • An OpenAI Platform organization where you hold the Apps Management permission at write level, plus a completed individual or business verification for the name you'll publish under.
  • ChatGPT with Developer mode available under Settings, Security and login.
  • Public website, support, privacy policy, and terms URLs, and a square logo of at least 48x48 pixels.

Verification is the one that quietly gates everything else, since reviewers match your listing against the verified identity and a mismatch is grounds for rejection.

What's inside a plugin

Every plugin has .codex-plugin/plugin.json. Only that file belongs in .codex-plugin/; skills, hooks, assets, .mcp.json, and .app.json all live at the plugin root (packaging docs). Published plugins use a manifest shaped like this, trimmed down from the one that ships with the Figma plugin.

{
  "name": "acme-support",
  "version": "0.1.0",
  "description": "Acme ticket triage and escalation workflows.",
  "author": { "name": "Acme", "url": "https://acme.example" },
  "skills": "./skills/",
  "mcpServers": "./.mcp.json",
  "apps": "./.app.json",
  "interface": {
    "displayName": "Acme Support",
    "shortDescription": "Triage Acme tickets",
    "longDescription": "Triage incoming tickets, summarize threads, draft escalations.",
    "developerName": "Acme",
    "category": "Productivity",
    "capabilities": ["Interactive", "Read", "Write"],
    "websiteURL": "https://acme.example",
    "privacyPolicyURL": "https://acme.example/privacy",
    "termsOfServiceURL": "https://acme.example/terms",
    "defaultPrompt": ["Triage the tickets that came in overnight"],
    "composerIcon": "./assets/icon.png",
    "logo": "./assets/logo.png"
  }
}

The top-level fields identify the package and point at bundled components; license, homepage, repository, and keywords are the remaining top-level options. The interface object is the listing: display name, copy, category, links, starter prompts, and branding, with brandColor and screenshots available alongside the two image fields above. category has to be one of thirteen fixed values, and omitting it lands you in Other (error reference). Every path in the manifest starts with ./ and resolves inside the plugin root.

Skills are the other half of the bundle. A skill is a directory under skills/ containing a SKILL.md with name and description frontmatter and instructions in the body, and the description is what decides whether the model reaches for it (skills docs). Figma ships twelve of them, one per workflow, which is the pattern worth copying: a skill per recognizable user goal rather than one omnibus instruction file.

Packaging: hosted connection or bundled server

The fastest route is the built-in @plugin-creator skill, which scaffolds the manifest and can generate a local marketplace entry so the plugin shows up in the Plugins Directory for testing. Hand-authoring gets you to the identical structure, and a minimal skills-only plugin is two directories holding two files.

$mkdir -p acme-support/.codex-plugin acme-support/skills/triage

Fill those in with the plugin.json manifest above and a skills/triage/SKILL.md, and you have something installable.

The real decision is how the MCP server attaches, and there are two files for it because they do different jobs.

.mcp.json describes a server the plugin distributes. The one shipped with the Figma plugin is a remote HTTP server with its OAuth resource declared:

{
  "mcpServers": {
    "figma": {
      "type": "http",
      "url": "https://mcp.figma.com/mcp",
      "oauth_resource": "https://mcp.figma.com/mcp"
    }
  }
}

.app.json does the opposite. It references a connection already registered in ChatGPT, by ID:

{
  "apps": {
    "notion": {
      "id": "asdk_app_69c18c28f1188191bf5b8445c4ab0a2e"
    }
  }
}

Those IDs come in three prefixes the validator accepts, asdk_app_, connector_, or templated_apps_, and when we pulled the shipped plugins apart they didn't even agree with each other: Notion's .app.json carries an asdk_app_ ID while Figma's carries a connector_ one. The filename is a compatibility identifier; underneath both files, the primitive is the same MCP server. UI and auth stay part of the server integration you already built, and the manifest just connects the pieces.

Both files are inert unless the manifest points at them. A root .app.json is imported only when apps is set to ./.app.json, and a root .mcp.json only when mcpServers is set to ./.mcp.json. Otherwise you get an undeclared_app_manifest_ignored warning, which doesn't block anything and doesn't ship your server either.

For a public listing, .app.json isn't your path. The portal refuses to publish references to existing registered apps: a skills-only upload strips .app.json, and an MCP-backed submission has to choose With MCP and hand over the server URL directly, even when that same server already backs a published integration. So .app.json is the local and workspace channel, and the portal is the public one.

Test it before the portal sees it

The order that saves time is server first, plugin second. Register the server in developer mode, exercise it, and only then install the packaged plugin.

Turn on Developer mode, then go to chatgpt.com/plugins, select the plus button, and enter your MCP server URL including the /mcp path. ChatGPT creates the connection and shows you the tools and metadata it discovered (connect and test docs). If you're feeding that connection to @plugin-creator, the connection ID is in the browser URL.

Then build an evaluation set and keep it. OpenAI's recommended shape covers five categories (connect and test docs).

  • Direct requests that should call one specific tool
  • Indirect requests expressing the same goal in other words
  • Follow-ups that reuse identifiers from an earlier result
  • Write actions that require authorization or confirmation
  • Unsupported requests that shouldn't call a tool at all

Record the selected tool, arguments, result, and confirmation behavior for each, and rerun the whole set whenever you touch a tool name, description, schema, or annotation. Metadata in developer mode refreshes on demand: redeploy the server, open the connection, and select Refresh. That live-refresh behavior is a developer-mode convenience, not how published plugins work.

For the same coverage outside ChatGPT, MCP Inspector lists and calls tools directly, and it's the faster loop for schema and error-message work.

Packaged plugin testing comes next. Install from a local marketplace, start a fresh conversation, and confirm that skills invoke the right tools and that bundled files resolve after installation. In Codex CLI the equivalent is /plugins, which opens a marketplace-grouped browser, and bundled skills only become available in a session started after the install (plugins overview).

Submitting to the directory

Create the submission at platform.openai.com/plugins, pick Skills only or With MCP, and the portal saves a draft as you work. The MCP tab is where the substance is: enter the production URL, configure auth and demo credentials, define a content security policy for any domains your UI fetches from, clear the domain challenge, then select Scan Tools.

Scanning imports a snapshot of what your server advertises: tool names, titles, descriptions, input and output schemas, security schemes, _meta fields, annotations, linked UI resource metadata, and the server instructions string. Annotations are where submissions get sent back. Every tool needs explicit readOnlyHint, openWorldHint, and destructiveHint values plus a written justification for each, and the justification doesn't override what the server advertises. Calling a tool "functionally read-only" in prose while the server reports readOnlyHint: false fails; the fix is to correct the annotation, redeploy, rescan, and then submit (review requirements).

The rest of the form wants materials you can't improvise at 2am (submission docs, error reference).

  • Exactly five positive test cases and three negative ones, each runnable by a reviewer with no internal context
  • Demo credentials that work without MFA, SMS, email confirmation, or private-network access
  • A demo-recording URL covering the main use cases across supported platforms
  • Up to three starter prompts, each 128 characters or fewer, with no @mention in them
  • Screenshots only if the tool scan reports a UI output template, at exactly 706 pixels wide and 400 to 860 tall
  • Countries where the plugin should be available, and release notes

Domain verification catches teams with several servers behind one host. The challenge base URL ignores paths, so two plugins on the same hostname that differ only by path resolve to the same challenge URL and can't be verified separately. A parent origin you can host a token on, or a distinct hostname per server, is the way out.

The validation errors worth pre-empting

Listing copy is the most common surprise, because a package can pass upload validation and still fail final submission on the same fields. interface.displayName is capped at 80 characters for package validation and 30 for the directory, and interface.shortDescription drops from 240 to 30. The plugins in openai/plugins carry taglines well past 30 characters, so treating them as a copy template will get you submission_display_name_too_long and submission_subtitle_too_long at the last step.

Skill packaging fails more silently. Files sitting directly under skills/ aren't imported at all, and neither are symlinks; each skill has to be a real directory containing SKILL.md. A flat skills/triage.md produces a skill_file_ignored warning and a plugin that ships with no skills.

Branding assets are checked as images, not as paths. Logos and composer icons must be square, at least 48x48, under 5 MiB, and the filename extension has to match the detected format, so a PNG renamed to .svg fails on raster_image_extension_content_mismatch. The full error reference maps every code to its requirement.

After approval

Approval and publication are separate events. Once approved, you publish from the portal, and only then does the plugin appear in the universal directory. Discovery is narrow at first: users find it by exact name search or by the direct listing URL, and the directory's main pages are reserved for plugins OpenAI selects for enhanced distribution, which few receive at publication and nobody can request (review requirements).

Published metadata behaves like a versioned API contract, which is the sharpest difference from developer mode. Your live server still handles tool calls and serves UI resources, but the reviewed snapshot is what users see, and four rules follow from that:

  • Changing a tool name, schema, annotation, or the server instructions means deploying, rescanning, resubmitting, and publishing the approved version.
  • Server-only fixes that preserve the published contract ship immediately.
  • Breaking changes don't have a supported path at all. Renaming or removing a tool can break the published version the moment it deploys, so add alongside rather than replace.
  • The server origin (scheme, hostname, port) can't change between versions; a new origin means a new plugin and a new review.

One submission, two products. The same listing serves ChatGPT Work on the web, ChatGPT Work and Codex in the desktop app, and Codex CLI, and it's absent from regular Chat, the IDE extension, and mobile. Those surfaces don't call your server the same way. A Codex CLI session driving your tools through a bundled skill produces different sequences, argument shapes, and error rates than a ChatGPT Work user typing a starter prompt, and the submission portal's job ends at review and publishing. Telling those two traffic streams apart happens in your own server telemetry or not at all; AgentCat breaks MCP traffic down that way per tool and per client.

The same server can carry a second listing. Anthropic's Connectors Directory has its own portal and its own review criteria, but it wants the same tool annotations this submission made you fix, so the second submission is mostly paperwork on top of work you've already done.