Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "paper",
"version": "1.0.0",
"description": "Design on a canvas that ChatGPT can read and write to — built on web standards.",
Expand All @@ -24,31 +23,32 @@
"mcp",
"design-system"
],
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Paper",
"shortDescription": "Design, share, ship",
"longDescription": "Paper is a professional design tool built on HTML and CSS. With this plugin, ChatGPT can read your Paper files and write back to the canvas in real time. Use it to sketch new screens from a prompt, iterate on existing designs, generate UI from a brief or design system, and export artboards.\n\nSign in with your Paper account to connect. ChatGPT can only access files that your account can access.",
"developerName": "Paper",
"category": "Creativity",
"capabilities": [
"Read",
"Write"
],
"websiteURL": "https://paper.design",
"supportURL": "https://paper.design/support",
"privacyPolicyURL": "https://paper.design/legal/privacy",
"termsOfServiceURL": "https://paper.design/legal/tos",
"defaultPrompt": [
"Design a settings page for a mobile app in Paper",
"Take a screenshot of my selected artboard in Paper and suggest layout improvements",
"Create a design system sticker sheet in Paper with my brand colors and type scale"
],
"brandColor": "#81ADEC",
"composerIcon": "./assets/logo.png",
"logo": "./assets/logo.png"
},
"extensions": {
"com.openai": {
"interface": {
"displayName": "Paper",
"shortDescription": "Design, share, ship",
"longDescription": "Paper is a professional design tool built on HTML and CSS. With this plugin, ChatGPT can read your Paper files and write back to the canvas in real time. Use it to sketch new screens from a prompt, iterate on existing designs, generate UI from a brief or design system, and export artboards.\n\nSign in with your Paper account to connect. ChatGPT can only access files that your account can access.",
"developerName": "Paper",
"category": "Creativity",
"capabilities": [
"Read",
"Write"
],
"websiteURL": "https://paper.design",
"supportURL": "https://paper.design/support",
"privacyPolicyURL": "https://paper.design/legal/privacy",
"termsOfServiceURL": "https://paper.design/legal/tos",
"defaultPrompt": [
"Design a settings page for a mobile app in Paper",
"Take a screenshot of my selected artboard in Paper and suggest layout improvements",
"Create a design system sticker sheet in Paper with my brand colors and type scale"
],
"brandColor": "#81ADEC",
"composerIcon": "./assets/logo.png",
"logo": "./assets/logo.png"
},
"review": {
"test_cases": {
"positive": [
Expand Down
9 changes: 9 additions & 0 deletions plugins/paper/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"mcpServers": {
"paper": {
"type": "http",
"url": "https://mcp.paper.design",
"oauth_resource": "https://mcp.paper.design"
}
}
}
10 changes: 6 additions & 4 deletions plugins/paper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ No desktop install is required: everything runs through the remote server.

**Package layout**

This folder is a portable [Agent Plugins](https://agent-plugins.org) package:
This folder uses the Codex plugin layout, the same one every plugin in OpenAI's curated catalog ships:

- `plugin.json`: identity and listing metadata (OpenAI-specific fields under `extensions.com.openai`).
- `mcp.json`: the remote MCP server, strictly per the Agent Plugins schema. OAuth is not declared here: Codex's parser rejects servers with extra keys, and clients discover the authorization server from the MCP endpoint itself (401 + `resource_metadata`). Pick OAuth in the portal's Connect panel when submitting.
- `.codex-plugin/plugin.json`: identity and listing metadata (`interface`), with review test cases under `extensions.com.openai`.
- `.mcp.json`: the remote MCP server. OAuth is discovered from the server itself (401 + `resource_metadata`); nothing secret lives here.
- `assets/`: listing icon.

Run `bun run pack:chatgpt` from the repository root to produce the ZIP uploaded to the OpenAI plugin submission portal. The ZIP contains only `plugin.json`, `mcp.json`, and the referenced icons; this README is for the repo, not the listing.
We deliberately don't use the portable Agent Plugins layout (root `plugin.json` + `mcp.json`): Codex applies an 8 KB per-tool / 64 KB total budget to servers declared that way and silently hides tools past it, which drops Paper's write tools. The Codex layout has no such budget. See openai/codex#44308.

Run `bun run pack:chatgpt` from the repository root to produce the ZIP uploaded to the OpenAI plugin submission portal. The ZIP contains only the manifest, `.mcp.json`, and the referenced icons; this README is for the repo, not the listing.
9 changes: 0 additions & 9 deletions plugins/paper/mcp.json

This file was deleted.

18 changes: 10 additions & 8 deletions scripts/pack-chatgpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* Packs the portable `plugins/paper` package into the ZIP uploaded to the OpenAI plugin
* submission portal (ChatGPT + Codex directory).
*
* Only what the portal reads is staged: plugin.json, mcp.json, the icons plugin.json
* references, and skills/ if the plugin has one (portable packages discover it by path).
* The README is for people browsing the repo; the listing text comes from plugin.json.
* Only what the portal reads is staged: .codex-plugin/plugin.json, .mcp.json, the icons the
* manifest references, and skills/ if the plugin has one. The README is for people browsing
* the repo; the listing text comes from the manifest's interface.
* Timestamps and modes are normalized so rebuilds are bit-identical, matching the .mcpb
* pack script.
*/
Expand All @@ -26,23 +26,25 @@ const distDir = join(repoRoot, "dist");
const stageDir = join(distDir, "stage-chatgpt");
const outFile = join(distDir, "paper-chatgpt-plugin.zip");

type Interface = Record<string, unknown>;
type Manifest = {
name: string;
version?: string;
extensions?: { "com.openai"?: { interface?: Interface } };
mcpServers?: string;
interface?: Record<string, unknown>;
};

const manifest = (await Bun.file(join(pluginDir, "plugin.json")).json()) as Manifest;
const iface = manifest.extensions?.["com.openai"]?.interface ?? {};
const manifestPath = ".codex-plugin/plugin.json";
const manifest = (await Bun.file(join(pluginDir, manifestPath)).json()) as Manifest;
const iface = manifest.interface ?? {};
const mcpPath = (manifest.mcpServers ?? "./.mcp.json").replace(/^\.\//, "");

// Every icon the listing references must ship in the ZIP
const iconFields = ["logo", "logoDark", "composerIcon", "composerIconDark"];
const assetPaths = iconFields
.map((field) => iface[field])
.filter((value): value is string => typeof value === "string");

const files = ["plugin.json", "mcp.json", ...assetPaths];
const files = [manifestPath, mcpPath, ...assetPaths];
if (existsSync(join(pluginDir, "skills"))) {
files.push("skills");
}
Expand Down
136 changes: 83 additions & 53 deletions scripts/validate-agent-plugins.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
#!/usr/bin/env node

/**
* Validates portable Agent Plugins packages (root plugin.json + mcp.json) against the
* published 1.0.0 schemas, then applies the limits OpenAI's plugin submission portal
* enforces on the extensions["com.openai"] listing data
* Validates plugins prepared for OpenAI's plugin directory against the limits the submission
* portal enforces on listing metadata, review cases, icons and the MCP server declaration
* (https://developers.openai.com/plugins/deploy/submission).
*
* Plugins are discovered from .agents/plugins/marketplace.json; entries without a root
* plugin.json (Codex compatibility layout) are left to validate-codex.mjs.
* Plugins are discovered from .agents/plugins/marketplace.json; only those with a review block
* are checked. Both package layouts are read: the Codex layout (.codex-plugin/plugin.json +
* .mcp.json), and the portable Agent Plugins layout (plugin.json + mcp.json), which is also
* validated against the published 1.0.0 schemas.
*/

import { readFileSync, existsSync, openSync, readSync, closeSync } from "fs";
Expand Down Expand Up @@ -319,35 +320,71 @@ function validateReview(label, review) {
}
}

function validateMcpConfig(pluginDir, label) {
const mcpPath = resolve(pluginDir, "mcp.json");
if (!existsSync(mcpPath)) return;
const mcp = loadJSON(mcpPath, `${label} mcp.json`);
/**
* Reads a plugin package in either layout and returns the parts the submission checks need.
*
* Codex layout (`.codex-plugin/plugin.json`, listing fields at root `interface`, servers in `.mcp.json`) is
* what every plugin in OpenAI's own catalog ships, and Codex applies no tool budget to it. The portable
* Agent Plugins layout (root `plugin.json` + `mcp.json`) is still accepted, but Codex budgets its tools.
*/
function loadPackage(pluginDir, label) {
const codexManifestPath = resolve(pluginDir, ".codex-plugin/plugin.json");
const portableManifestPath = resolve(pluginDir, "plugin.json");

if (existsSync(codexManifestPath)) {
const manifest = loadJSON(codexManifestPath, `${label} .codex-plugin/plugin.json`);
if (!manifest) return null;
const mcpRelative = typeof manifest.mcpServers === "string" ? manifest.mcpServers : "./.mcp.json";
return {
layout: "codex",
manifest,
iface: manifest.interface,
openai: manifest.extensions?.["com.openai"] ?? {},
mcpPath: resolve(pluginDir, mcpRelative.replace(/^\.\//, "")),
};
}

if (existsSync(portableManifestPath)) {
const manifest = loadJSON(portableManifestPath, `${label} plugin.json`);
if (!manifest) return null;
if (!validatePluginSchema(manifest)) {
fail(`${label}: plugin.json failed schema validation:`);
reportSchemaErrors(label, validatePluginSchema);
}
const openai = manifest.extensions?.["com.openai"] ?? {};
return { layout: "portable", manifest, iface: openai.interface, openai, mcpPath: resolve(pluginDir, "mcp.json") };
}

return null;
}

const REMOTE_TRANSPORTS = new Set(["http", "streamable_http", "streamable-http"]);

function validateMcpConfig(pkg, label) {
if (!existsSync(pkg.mcpPath)) return;
const mcp = loadJSON(pkg.mcpPath, `${label} ${relative(root, pkg.mcpPath)}`);
if (!mcp) return;

// Strict schema validation on purpose. OpenAI's docs show auth settings under a server's
// extensions["com.openai"], but Codex's Agent Plugins parser deserializes servers with
// deny_unknown_fields and drops any server carrying extra keys, so a Codex install would
// end up with no Paper tools. OAuth is discovered from the server at connect time instead
// (401 + resource_metadata), and the submission portal exposes the auth method itself.
if (!validateMcpSchema(mcp)) {
// The portable schema forbids unknown server keys, and Codex's portable parser drops any server carrying
// them, so portable packages are validated strictly. The Codex layout has no published schema.
if (pkg.layout === "portable" && !validateMcpSchema(mcp)) {
fail(`${label}: mcp.json failed schema validation:`);
reportSchemaErrors(label, validateMcpSchema);
}

const servers = mcp.mcpServers ?? {};
const serverNames = Object.keys(servers);
if (serverNames.length !== 1) {
warn(`${label}: mcp.json declares ${serverNames.length} servers; the portal connects one per plugin and plugin-level review cases require exactly one`);
warn(`${label}: ${serverNames.length} MCP servers declared; the portal connects one per plugin and plugin-level review cases require exactly one`);
}

for (const [name, server] of Object.entries(servers)) {
const serverLabel = `${label} server "${name}"`;

if (server.type !== "streamable-http") {
fail(`${serverLabel}: public submission requires a remote "streamable-http" server — got "${server.type}"`);
if (!REMOTE_TRANSPORTS.has(server.type)) {
fail(`${serverLabel}: public submission requires a remote HTTP server — got type "${server.type}"`);
}
if (typeof server.url === "string" && !isHttpsUrl(server.url)) {
if (typeof server.url !== "string" || !isHttpsUrl(server.url)) {
fail(`${serverLabel}: url must be a public HTTPS endpoint — got "${server.url}"`);
}
// Also what a fixed credential would look like, and credentials never belong in the package
Expand All @@ -357,46 +394,42 @@ function validateMcpConfig(pluginDir, label) {
}
}

function validatePortablePlugin(pluginDir, marketplaceName) {
function validateSubmissionPackage(pluginDir, marketplaceName) {
const label = `Plugin "${marketplaceName}"`;
const manifest = loadJSON(resolve(pluginDir, "plugin.json"), `${label} plugin.json`);
if (!manifest) return;
const pkg = loadPackage(pluginDir, label);
if (!pkg) return false;

if (!validatePluginSchema(manifest)) {
fail(`${label}: plugin.json failed schema validation:`);
reportSchemaErrors(label, validatePluginSchema);
}
// Only plugins prepared for the directory carry review cases; the rest aren't held to submission rules
if (pkg.openai.review === undefined) return false;

const { manifest, iface, openai } = pkg;
if (manifest.name !== marketplaceName) {
fail(`${label}: marketplace name does not match plugin.json name "${manifest.name}"`);
fail(`${label}: marketplace name does not match manifest name "${manifest.name}"`);
}
if (typeof manifest.version !== "string") {
warn(`${label}: version is optional in the schema but submissions and updates need an explicit semantic version`);
fail(`${label}: version is required for submission and updates`);
}

const openai = manifest.extensions?.["com.openai"];
if (openai === undefined) {
warn(`${label}: no extensions["com.openai"]; listing metadata will be derived from root fields`);
} else {
// App references and lifecycle hooks make a ZIP ineligible for public submission
for (const field of ["apps", "hooks"]) {
if (openai[field] !== undefined) {
fail(`${label}: extensions["com.openai"].${field} is not accepted for public directory submission`);
}
}
if (openai.interface === undefined) {
fail(`${label}: extensions["com.openai"].interface is required for submission`);
} else {
validateInterface(pluginDir, label, openai.interface);
if (pkg.layout === "codex") {
for (const field of ["description", "author"]) {
if (manifest[field] === undefined) fail(`${label}: ${field} is required in the Codex manifest`);
}
if (openai.review !== undefined) {
validateReview(label, openai.review);
} else {
warn(`${label}: no review block; test cases and demo recording must be entered in the portal`);
}

// App references and lifecycle hooks make a ZIP ineligible for public submission
for (const field of ["apps", "hooks"]) {
if (manifest[field] !== undefined || openai[field] !== undefined) {
fail(`${label}: ${field} is not accepted for public directory submission`);
}
}

validateMcpConfig(pluginDir, label);
if (iface === undefined) {
fail(`${label}: interface listing metadata is required for submission`);
} else {
validateInterface(pluginDir, label, iface);
}
validateReview(label, openai.review);
validateMcpConfig(pkg, label);
return true;
}

// --- Main ---
Expand All @@ -406,14 +439,11 @@ let validated = 0;
for (const entry of marketplace?.plugins ?? []) {
const sourcePath = typeof entry.source === "string" ? entry.source : entry.source?.path;
if (typeof sourcePath !== "string" || !sourcePath.startsWith("./")) continue;
const pluginDir = resolve(root, sourcePath.slice(2));
if (!existsSync(resolve(pluginDir, "plugin.json"))) continue;
validatePortablePlugin(pluginDir, entry.name);
validated++;
if (validateSubmissionPackage(resolve(root, sourcePath.slice(2)), entry.name)) validated++;
}

if (validated === 0) {
warn("No portable Agent Plugins packages found in .agents/plugins/marketplace.json");
warn("No directory submission packages (plugins with a review block) found in .agents/plugins/marketplace.json");
}

if (errors > 0) {
Expand Down
Loading