diff --git a/plugins/paper/plugin.json b/plugins/paper/.codex-plugin/plugin.json similarity index 73% rename from plugins/paper/plugin.json rename to plugins/paper/.codex-plugin/plugin.json index 9a8eca2..e723ee0 100644 --- a/plugins/paper/plugin.json +++ b/plugins/paper/.codex-plugin/plugin.json @@ -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.", @@ -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": [ diff --git a/plugins/paper/.mcp.json b/plugins/paper/.mcp.json new file mode 100644 index 0000000..207d592 --- /dev/null +++ b/plugins/paper/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "paper": { + "type": "http", + "url": "https://mcp.paper.design", + "oauth_resource": "https://mcp.paper.design" + } + } +} diff --git a/plugins/paper/README.md b/plugins/paper/README.md index 1ae6010..697d526 100644 --- a/plugins/paper/README.md +++ b/plugins/paper/README.md @@ -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. diff --git a/plugins/paper/mcp.json b/plugins/paper/mcp.json deleted file mode 100644 index bcfe8f9..0000000 --- a/plugins/paper/mcp.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", - "mcpServers": { - "paper": { - "type": "streamable-http", - "url": "https://mcp.paper.design" - } - } -} diff --git a/scripts/pack-chatgpt.ts b/scripts/pack-chatgpt.ts index eea13ae..1ea5c26 100644 --- a/scripts/pack-chatgpt.ts +++ b/scripts/pack-chatgpt.ts @@ -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. */ @@ -26,15 +26,17 @@ const distDir = join(repoRoot, "dist"); const stageDir = join(distDir, "stage-chatgpt"); const outFile = join(distDir, "paper-chatgpt-plugin.zip"); -type Interface = Record; type Manifest = { name: string; version?: string; - extensions?: { "com.openai"?: { interface?: Interface } }; + mcpServers?: string; + interface?: Record; }; -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"]; @@ -42,7 +44,7 @@ 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"); } diff --git a/scripts/validate-agent-plugins.mjs b/scripts/validate-agent-plugins.mjs index d95a704..21cbc7f 100644 --- a/scripts/validate-agent-plugins.mjs +++ b/scripts/validate-agent-plugins.mjs @@ -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"; @@ -319,18 +320,54 @@ 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); } @@ -338,16 +375,16 @@ function validateMcpConfig(pluginDir, label) { 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 @@ -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 --- @@ -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) {