# How to build a ChatGPT plugin: plugin.json, skills and an MCP server

Canonical: https://clawdocx.com/docs/chatgpt-build-a-plugin
Author: Sam Okafor
Difficulty: INTERMEDIATE
Published: 2026-02-24
Updated: 2026-10-11

> Build a ChatGPT plugin from a portable plugin.json: the required fields, bundling skills and an MCP server, local testing and what submission asks for.

A ChatGPT plugin is a folder with a `plugin.json` manifest at its root, and optionally a `skills/` directory for instructions and an `mcp.json` file for the MCP servers it ships with. In the portable Agent Plugins format that manifest needs exactly two fields, `$schema` and `name`, and everything OpenAI-specific goes in one place: an `extensions` object keyed `com.openai`. If you only want something working today, the shortest route is not to write a manifest at all: register a custom MCP server in ChatGPT and turn it into a personal plugin, then package it properly once the tools behave.

Two things are worth knowing before you start. Public plugins are published once to a universal directory that ChatGPT and Codex share, so you are not building twice. And a plugin does not have to contain an MCP server: OpenAI's docs describe a plugin as something that "can include skills that provide instructions and resources, an MCP server that exposes tools, or both."

## What is a ChatGPT plugin made of?

A portable package has one required file and several optional ones. OpenAI's structure reference lists them like this:

| Path | Required? | What it holds |
|---|---|---|
| `plugin.json` | Required | The portable plugin manifest, at the plugin root |
| `skills/<name>/SKILL.md` | Optional | Skill instructions, discovered automatically from `skills/` |
| `mcp.json` | Optional | Portable MCP server configuration, discovered automatically |
| `.codex-plugin/plugin.json` | Optional | Compatibility fallback for OpenAI-specific settings |
| `hooks/` | Optional | Hook configuration and scripts |
| `assets/` | Optional | Icons, logos and screenshots referenced by the listing |

Discovery is the part that surprises people coming from other plugin systems. Portable packages "always discover skills in `skills/` and MCP servers in `mcp.json`", and the docs are explicit that a `skills` or `mcpServers` declaration "can't replace, disable, or add to those components". Those two keys exist only for Codex-format packages. If you want to know how this compares with other agents' conventions, our guide to [where agent skills go](/docs/agent-skills-where-skills-go) covers the directory layouts side by side, and [understanding skills](/docs/skills-explained) covers what belongs in a `SKILL.md` in the first place.

One caution carried straight from the docs: lifecycle hooks are supported only for plugins installed manually in Codex desktop, and "Plugins containing lifecycle hooks aren't eligible for the public plugin directory." Decide early whether you want hooks or a public listing.

## What is the fastest way to get a working plugin?

The quickstart does not start with a manifest. It starts with a server already running, and OpenAI supplies one for the walkthrough: a public example at `https://tinymcp.dev/api/moldy-aloof-zettabyte/mcp` exposing a read-only `roll_dice` tool with no authentication.

1. Open ChatGPT Plugins, select the plus button, then **Add custom MCP server**.
2. Enter a name and the server URL, and select **No authentication**.
3. Read the risk warning, then select **I understand and want to continue**.
4. Select **Create as a plugin**.
5. Open your personal plugins, find the new plugin, and select the plus button to install it.
6. On the ChatGPT homepage switch the tab from **Chat** to **Work**, start a new Work chat, type `@` and select the plugin.

The test the docs ask for is more interesting than the happy path. Ask for one roll of a 20-sided die and confirm the model calls `roll_dice` once with `sides` set to 20. Then run "several realistic inputs, including different die sizes, invalid values, and requests that should not call the tool", and treat a wrong tool choice or inconsistent arguments as a prompt to fix the tool metadata rather than the model.

That gives you a personal plugin. It does not give you a package anyone else can install, which is what the manifest is for.

## What goes in plugin.json, and which fields are required?

Here is the whole minimal manifest, as the docs give it:

```json
{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
  "name": "my-first-plugin",
  "version": "1.0.0",
  "description": "Reusable greeting workflow"
}
```

Only the first two lines are obligatory. The published schema at `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json` carries `"required": ["$schema", "name"]` and nothing else, and OpenAI's own field reference matches it. The schema also sets `"additionalProperties": false` over ten properties, so a key it does not define is a validation error rather than a harmless extra.

Requirements split by format, and this is the table to keep:

| Field | Portable Agent Plugins | Codex compatibility format | Notes from the docs |
|---|---|---|---|
| `$schema` | Required | Omit it | "Required for the portable format ... Omit it from a standalone Codex compatibility manifest" |
| `name` | Required | Required | "Required stable identifier, at most 64 characters", kebab-case, kept separate from the display name |
| `version` | Optional | Required | "Optional in the portable schema; use an explicit semantic version for submission and updates" |
| `description` | Optional | Required | "Package summary, at most 4000 characters" |
| `author` | Optional | Required | Codex requires `author.name`; the portable schema allows the object to be omitted |
| `interface` | Optional | Required | Presentation object; portable packages "can derive basic text from their root metadata" |
| `skills` | Not used | For Codex packages with skills | Portable packages discover `skills/` automatically |
| `mcpServers` | Not used | For Codex packages with MCP servers | Portable packages discover root `mcp.json` automatically |

So "optional" here is a statement about validation, not about good practice. A package you intend to submit wants a real semantic version and a description regardless, and `name` has a documented character limit worth respecting from the start because hosts use it as the plugin identifier and component namespace.

OpenAI-specific settings live in one nested object. Keep "portable identity and metadata, such as `name`, `version`, and `description`, at the root":

```json
{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
  "name": "my-plugin",
  "version": "0.1.0",
  "description": "Reusable skills and MCP servers",
  "extensions": {
    "com.openai": {
      "onboardingSkill": "./skills/setup/SKILL.md",
      "interface": {
        "displayName": "My Plugin",
        "shortDescription": "Reusable skills and MCP servers",
        "developerName": "Your team",
        "category": "Productivity",
        "capabilities": ["Read", "Write"],
        "websiteURL": "https://example.com",
        "privacyPolicyURL": "https://example.com/privacy",
        "termsOfServiceURL": "https://example.com/terms"
      }
    }
  }
}
```

The replacement rule matters if you are migrating. When `extensions.com.openai` is an object, "it replaces the entire `.codex-plugin/plugin.json` overlay as the source of OpenAI-specific settings; the two aren't merged." Half your settings in each file means half your settings ignored.

## How do you bundle an MCP server?

Portable MCP configuration goes in a root `mcp.json` with its own schema and a named entry under `mcpServers`:

```json
{
  "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
  "mcpServers": {
    "docs": {
      "type": "streamable-http",
      "url": "https://example.com/mcp"
    }
  }
}
```

Do not reach for a rename here. The docs say outright: "Don't just rename `.mcp.json`: the portable MCP format also declares a transport `type` for each server." Authentication is declared per server, and it belongs to the server rather than to the plugin, under `extensions["com.openai"].auth` inside `mcp.json`:

| Method | Configuration |
|---|---|
| No authentication | `auth.type: "none"` |
| OAuth | `auth.type: "oauth"`, with optional client registration settings |
| Public and OAuth-protected access | `auth.type: "mixed"` |
| API key | `auth.type: "api_key"`, with a header scheme |

The manifest "declares connection settings, not secret credentials", so a client secret goes into the submission portal and never into the package.

For the server itself, OpenAI points at the official TypeScript SDK (`@modelcontextprotocol/sdk`) and Python SDK (`mcp`), both of which provide streamable HTTP transport. Give the server a stable name and version, and use the `instructions` field returned during initialization for guidance that spans tools, such as required tool sequences or shared rate limits:

```js
const server = new McpServer(
  { name: "acme-projects", version: "1.0.0" },
  {
    instructions:
      "Before updating a project, call get_project to confirm its ID and current status.",
  }
);
```

Keep the important part of that string early: both OpenAI's plugin docs and the ChatGPT MCP documentation say to keep the first 512 characters self-contained, and the plugin guide adds what not to do with it, which is repeat every tool description or try to change the model's personality. If MCP itself is new to you, start with [what MCP is](/blog/what-is-mcp) and come back.

Test locally before you connect anything. Expose a streamable HTTP endpoint, "typically at `/mcp`", run `npx @modelcontextprotocol/inspector`, choose **Streamable HTTP** and point it at `http://localhost:3000/mcp`. The checks the docs ask for are the ones people skip: call every tool with representative **and invalid** inputs, and confirm "that authorization is enforced for private data and write actions."

## What does the production endpoint have to do?

For a public submission the server has to be deployed at a stable, publicly reachable HTTPS endpoint, and the docs list what that endpoint must do: support streamable HTTP, respond at a stable URL "typically ending in `/mcp`", meet the workflows' latency and availability needs, reach the services and data stores it depends on, preserve authentication and authorization boundaries, and "Produce logs and metrics for failed initialization and tool calls."

If the real server has to stay private, the documented answer is a public HTTPS proxy in front of it, with OpenAI-managed mTLS to authenticate ChatGPT as the client and OAuth 2.1 where the plugin needs user authentication. An IP allowlist is available through the published ChatGPT connectors IP ranges, and the docs are blunt that it "does not replace authentication or authorization". What will not work is a tunnel: Secure MCP Tunnel can connect a private server inside ChatGPT but "does not satisfy public submission requirements."

## How do you install and share it before it is public?

A local marketplace is a JSON catalog, and it is separate from the public directory. For a repo marketplace, put the file at `$REPO_ROOT/.agents/plugins/marketplace.json` and the plugins under `$REPO_ROOT/plugins/`:

```json
{
  "name": "local-repo",
  "plugins": [
    {
      "name": "my-plugin",
      "source": {
        "source": "local",
        "path": "./plugins/my-plugin"
      },
      "policy": {
        "installation": "AVAILABLE",
        "authentication": "ON_INSTALL"
      },
      "category": "Productivity"
    }
  ]
}
```

Then restart the ChatGPT desktop app and check the plugin appears. A personal marketplace uses `~/.agents/plugins/marketplace.json` with plugins under `~/.codex/plugins/`, and a legacy-compatible path at `$REPO_ROOT/.claude-plugin/marketplace.json` is also read. Entries can point at Git sources with `"source": "url"` or `"source": "git-subdir"`, or at an npm package. One detail that saves debugging time: ChatGPT installs into `~/.codex/plugins/cache/$MARKETPLACE_NAME/$PLUGIN_NAME/$VERSION/` and loads the installed copy from that cache rather than from the marketplace entry, with `$VERSION` being `local` for local plugins. Another: if a marketplace entry's source cannot be resolved, Codex "skips that plugin entry instead of failing the whole marketplace", so a plugin that silently fails to appear is worth checking before you suspect the manifest.

## What does public submission ask for?

Submission is a review process, not an upload. Expect, in roughly this order: automated findings to clear, where "required skill scans must finish successfully before you can submit"; a domain-verification challenge in the portal; the MCP server connected and authenticated; then the review information.

That review information is the part to plan time for, and it goes in `plugin.json` under `extensions.com.openai.review`:

- **Five positive test cases.** Each needs "the scenario, user prompt, expected tools, and expected result", written as `description`, `prompt`, `tools_triggered` and `expected_behavior`. Run each one with the test account first.
- **Three negative test cases.** Each needs "a prompt or scenario where the plugin should not act, why it should not complete the request, and the expected refusal, clarification, or safe fallback." OpenAI's own examples are an unsupported payment, an unsupported deletion, and access outside the connected account.
- **A video walkthrough**, at an accessible recording URL, demonstrating the test cases and the plugin working.
- **Reviewer credentials, if sign-in is required**, entered privately rather than in the package. The docs are specific: a dedicated test account on sample data rather than a real user's, holding the permissions and data the test cases need, and working "immediately without MFA approval, email or SMS codes, magic links, or private-network access."

Then you select the draft, choose **Submit for review**, complete the policy attestations, and track progress under **Review status**, with feedback arriving by email.

## What is worth checking before you ship?

- **Validate against the schema, not against an example.** `additionalProperties` is `false`, so a stray key fails rather than being ignored, and `name` has a documented pattern as well as a length limit.
- **Pick your format and stay in it.** The portable root manifest is the format new packages should use; `.codex-plugin/plugin.json` is a fallback that gets ignored the moment `extensions.com.openai` exists.
- **Do not expect `skills` or `mcpServers` to do anything in a portable package.** Directory discovery is not overridable.
- **Treat the negative test cases as design work.** Three documented refusals force you to decide what the plugin will not do, which is cheaper to decide now than during review.
- **Budget for the endpoint, not just the code.** Stable HTTPS, logging on failed initialization, and authorization enforced on write actions are submission requirements rather than polish.

A note on how this guide was checked. Every field name, requirement, path and command above was taken from OpenAI's current plugin documentation and from the published Agent Plugins manifest schema on 11 October 2026, and the quotations are verbatim from those pages. The ChatGPT interface steps are the documented ones and were not performed in a ChatGPT account for this guide, so treat the click paths as the docs' own description rather than as a screen-by-screen walkthrough. For what ChatGPT supports more broadly, see our [ChatGPT hub](/ai-agents/chatgpt), and for the protocol underneath the server half of this, our [MCP hub](/ai-agents/mcp).

## Frequently asked questions

**Which plugin.json fields are actually required?**

In the portable Agent Plugins format, two: $schema and name. The published schema lists exactly those in its required array, and OpenAI's field reference agrees. version, description, author and interface are required in the older Codex compatibility format and optional in the portable one, though OpenAI tells you to set an explicit semantic version for submission and updates.

**Do I need an MCP server to build a ChatGPT plugin?**

No. OpenAI's docs say a plugin can include skills that provide instructions and resources, an MCP server that exposes tools, or both. Add a server when the plugin needs live data, authentication, controlled actions, or code running on infrastructure you operate. A skills-only package omits mcp.json entirely.

**Where do OpenAI-specific plugin settings go?**

Under extensions.com.openai in the root plugin.json, which covers presentation, registered MCP server mappings and hook settings. A separate .codex-plugin/plugin.json still works as a compatibility fallback, but the two are not merged: when the inline object is present it replaces the overlay entirely.

**How many test cases does ChatGPT plugin submission need?**

Five positive and three negative. Each positive case needs the scenario, user prompt, expected tools and expected result, and OpenAI says to run each one with the test account before submitting. Each negative case needs a prompt the plugin should not act on, why, and the expected refusal or safe fallback. A video walkthrough is also required.

**Can I submit a plugin whose MCP server runs on my own machine?**

No. Public submission needs the server deployed at a stable, publicly reachable HTTPS endpoint that supports streamable HTTP. OpenAI states that Secure MCP Tunnel can connect a private server inside ChatGPT but does not satisfy public submission requirements, and that a temporary tunnel or local endpoint is not acceptable either.

## Sources

- [OpenAI: Package your plugin](https://developers.openai.com/plugins/build/plugins)
- [OpenAI: Plugins quickstart](https://developers.openai.com/plugins/quickstart)
- [OpenAI: Build an MCP server for a plugin](https://developers.openai.com/plugins/build/mcp-server)
- [OpenAI: Plugin submission](https://developers.openai.com/plugins/deploy/submission)
- [Agent Plugins 1.0.0 manifest schema](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json)
- [ChatGPT docs: Model Context Protocol](https://learn.chatgpt.com/docs/extend/mcp)