DX-2996: plugin icon and mcp descriptions (#34)

* DX-2996 surface the MCP in plugin descriptions and ship the Codex icon

The manifests gained an MCP server but kept their skills-only descriptions,
and Codex installed the plugin with no Upstash icon because
`.codex-plugin/plugin.json` had an incomplete `interface` block.

- Descriptions across all six manifests (and the Claude/Cursor marketplace
  entries) now lead with the MCP server, and are identical everywhere. They
  had drifted into two different wordings before this.
- `.codex-plugin/plugin.json` gains the required `longDescription` and
  `capabilities`, plus `logo`/`composerIcon` pointing at
  `assets/upstash-icon-white-bg.png`, `brandColor` (#00C98D, sampled from the
  icon), `defaultPrompt`, and the privacy/terms URLs. Drops the non-spec
  `interface.repository`. This mirrors the asset layout redis/agent-skills
  uses — Codex is the only client with an icon field, so the other manifests
  cannot carry one.
- Category is "Developer Tools" for Codex and the .agents marketplace entry.
- Fills the gaps the icon audit turned up: `skills: "./skills/"` stated
  explicitly for Claude/Codex/Cursor, `displayName`, `author.url`, and the
  full metadata set (version, author, homepage, license, keywords, category,
  tags) on both marketplace entries. Version bumped to 1.2.0 in lockstep.

Adds `scripts/check-manifests.mjs`, run by `npm run check`, so this drift
fails CI instead of shipping: it pins version/description/repository/license
across manifests, requires the description to mention MCP, requires every
client to point at https://mcp.upstash.com/mcp, and requires the Codex
interface block to be complete with logo files that actually exist.

Gitignores `opencode.json`. OpenCode reads it from the project root, so a
committed one would only configure OpenCode for people working inside a clone
of this repo — a personal dev setting the repo has no reason to ship.

Claude-Session: https://claude.ai/code/session_01FPx9e82XCTxDH1msNUAV1q

* DX-2996 title the plugin "Upstash Redis" for marketplace search

The skills and MCP cover every Upstash product and the descriptions still list
them all, but the title now leads with the flagship. Marketplace search is
largely name-driven, so a plugin called "Upstash" does not surface for people
browsing for "redis".

Sets `displayName` to "Upstash Redis" in the Claude and Cursor plugin.json and
in both marketplace entries, and `interface.displayName` for Codex. Cursor's
plugin.json had no display name at all, so it fell back to the slug.

Deliberately unchanged, and now asserted by the check script:

- `"name": "upstash"` — the install identifier behind
  `/plugin install upstash@upstash` and `codex plugin marketplace add`.
  Renaming it would break every install command in the README.
- `author.name` / `owner.name` — the company, not the plugin.
- The marketplace-level displayName, which names the vendor's marketplace
  rather than the plugin inside it.
- Skill names, which AGENTS.md already rules out renaming for ranking.

AGENTS.md documents the title and the three things adjacent to it that must
not be renamed, including why this does not contradict the skills rule.

Claude-Session: https://claude.ai/code/session_01FPx9e82XCTxDH1msNUAV1q

* DX-2996 conform the Cursor manifests to their real schema, add $schema refs

Answering "does every client actually accept displayName?" turned up a bug I
introduced two commits ago. Cursor publishes real JSON Schemas
(github.com/cursor/plugins/schemas) and both are additionalProperties:false.

- `.cursor-plugin/marketplace.json` entries accept only name, source,
  description and minClientVersions. The earlier commit added nine fields
  Cursor rejects — displayName, version, author, homepage, repository,
  license, keywords, category, tags — plus owner.url. All removed; the entry
  now matches the shape Cursor's own marketplace uses.
- That metadata belongs in `.cursor-plugin/plugin.json`, which does take it.
  Moved category and tags there.
- Cursor has a `logo` field, so the icon works there too. Earlier commits
  claimed Codex was the only client with one; that was wrong, and the README
  and AGENTS.md are corrected. Cursor's own plugins use a bare relative path
  ("assets/logo.png", no "./"), so ours matches.

$schema is added only where it is accepted:

- `.claude-plugin/plugin.json` — verified with `claude plugin validate`, which
  passes with the key present. marketplace.json already had one.
- Cursor takes none: its schemas declare no $schema property under
  additionalProperties:false, and none of Cursor's 65 official manifests carry
  one. The check script now fails if someone adds it.
- Codex and Gemini CLI publish no schema URL, so neither gets one.
- plugin.json and mcp.json keep the agent-plugins.org URLs, the only ones that
  actually serve JSON rather than a docs page.

AGENTS.md gains a per-client schema table and the two Cursor traps, so the
next person copies fields between manifests only after checking.

Claude-Session: https://claude.ai/code/session_01FPx9e82XCTxDH1msNUAV1q
This commit is contained in:
Cahid Arda
2026-09-04 14:54:46 +03:00
committed by GitHub
parent ae4fd7e8f9
commit d995ff5115
14 changed files with 375 additions and 33 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
"category": "Developer Tools"
}
]
}
+17 -2
View File
@@ -1,8 +1,10 @@
{
"$schema": "https://code.claude.com/schemas/marketplace.json",
"name": "upstash",
"owner": {
"name": "Upstash",
"email": "support@upstash.com"
"email": "support@upstash.com",
"url": "https://upstash.com"
},
"metadata": {
"description": "Official Upstash plugin marketplace"
@@ -10,8 +12,21 @@
"plugins": [
{
"name": "upstash",
"displayName": "Upstash Redis",
"source": "./",
"description": "Agent skills for all Upstash SDKs — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search."
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"version": "1.2.0",
"author": {
"name": "Upstash",
"email": "support@upstash.com",
"url": "https://upstash.com"
},
"homepage": "https://upstash.com",
"repository": "https://github.com/upstash/skills",
"license": "MIT",
"keywords": ["upstash", "mcp", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"],
"category": "database",
"tags": ["mcp", "redis", "queue", "vector", "search", "serverless"]
}
]
}
+8 -4
View File
@@ -1,7 +1,10 @@
{
"$schema": "https://code.claude.com/schemas/plugin.json",
"name": "upstash",
"description": "Agent skills for all Upstash SDKs — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"version": "1.1.0",
"displayName": "Upstash Redis",
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"version": "1.2.0",
"skills": "./skills/",
"mcpServers": {
"upstash": {
"type": "http",
@@ -10,10 +13,11 @@
},
"author": {
"name": "Upstash",
"email": "support@upstash.com"
"email": "support@upstash.com",
"url": "https://upstash.com"
},
"homepage": "https://upstash.com",
"repository": "https://github.com/upstash/skills",
"license": "MIT",
"keywords": ["upstash", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"]
"keywords": ["upstash", "mcp", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"]
}
+27 -15
View File
@@ -1,28 +1,40 @@
{
"name": "upstash",
"version": "1.1.0",
"version": "1.2.0",
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"author": {
"name": "Upstash",
"email": "support@upstash.com",
"url": "https://upstash.com"
},
"homepage": "https://upstash.com",
"repository": "https://github.com/upstash/skills",
"license": "MIT",
"keywords": ["upstash", "mcp", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"],
"skills": "./skills/",
"mcpServers": {
"upstash": {
"type": "http",
"url": "https://mcp.upstash.com/mcp"
}
},
"description": "Agent skills for all Upstash SDKs — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"author": {
"name": "Upstash",
"email": "support@upstash.com"
},
"homepage": "https://upstash.com",
"repository": "https://github.com/upstash/skills",
"license": "MIT",
"keywords": ["upstash", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"],
"skills": "./skills/",
"interface": {
"displayName": "Upstash",
"shortDescription": "Skills for Upstash SDKs (Redis, QStash, Workflow, Box, Ratelimit, Vector, Search)",
"category": "Productivity",
"displayName": "Upstash Redis",
"shortDescription": "Upstash MCP server and skills for every Upstash SDK",
"longDescription": "Build on Upstash without leaving your agent. Bundles the hosted Upstash MCP server (OAuth on first use) for live access to your databases, queues, and indexes, together with skills for Redis, Ratelimit, QStash, Workflow, Vector, Search, Box, and the Upstash CLI.",
"developerName": "Upstash",
"category": "Developer Tools",
"capabilities": ["Read", "Write"],
"websiteURL": "https://upstash.com",
"repository": "https://github.com/upstash/skills"
"privacyPolicyURL": "https://upstash.com/trust/privacy.pdf",
"termsOfServiceURL": "https://upstash.com/trust/terms.pdf",
"defaultPrompt": [
"List my Upstash databases and show their usage stats.",
"Add caching to this route with Upstash Redis.",
"Run this background job through QStash on a cron schedule."
],
"brandColor": "#00C98D",
"composerIcon": "./assets/upstash-icon-white-bg.png",
"logo": "./assets/upstash-icon-white-bg.png"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
{
"name": "upstash",
"source": "./",
"description": "Agent skills for Upstash SDKs and CLIs — Redis, QStash, Workflow, Box (SDKs and the box CLI), Ratelimit, Vector, and Search."
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search."
}
]
}
+8 -3
View File
@@ -1,7 +1,9 @@
{
"name": "upstash",
"description": "Agent skills for Upstash SDKs and CLIs — Redis, QStash, Workflow, Box (SDKs and the box CLI), Ratelimit, Vector, and Search.",
"version": "1.1.0",
"displayName": "Upstash Redis",
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"version": "1.2.0",
"skills": "./skills/",
"mcpServers": {
"upstash": {
"url": "https://mcp.upstash.com/mcp"
@@ -14,5 +16,8 @@
"homepage": "https://upstash.com",
"repository": "https://github.com/upstash/skills",
"license": "MIT",
"keywords": ["upstash", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"]
"logo": "assets/upstash-icon-white-bg.png",
"keywords": ["upstash", "mcp", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"],
"category": "developer-tools",
"tags": ["mcp", "redis", "queue", "vector", "search", "serverless"]
}
+2
View File
@@ -0,0 +1,2 @@
.env
opencode.json
+85
View File
@@ -25,3 +25,88 @@ skills.sh matches multi-word searches against each skill's `description` (single
## Gemini CLI extension
`gemini-extension.json` at the root makes the repo installable with `gemini extensions install https://github.com/upstash/skills` (it clones the default branch; no tag needed). Listing in the gallery at geminicli.com/extensions is crawler-only: the repo must carry the `gemini-cli-extension` GitHub topic and have a tag, and there is no publish command or submission form. We have not tagged this repo for that yet, so bump `version` in `gemini-extension.json` only when the skills change in a way users should see.
## The plugin is titled "Upstash Redis"
The skills and the MCP cover every Upstash product, and the descriptions list
them all. The *title* is still **Upstash Redis**, because Redis is the flagship
and because marketplace search is largely name-driven — someone browsing for
"redis" has to find us. Every user-facing plugin title reads `Upstash Redis`:
`displayName` in the Claude and Cursor `plugin.json` and in both marketplace
entries, and `interface.displayName` for Codex.
Three things nearby are **not** display surfaces and must not be renamed:
- **`"name": "upstash"`** — the install identifier. It is what
`/plugin install upstash@upstash`, `codex plugin marketplace add` and
`enabledPlugins` key off. Renaming it breaks every install command in the
README and orphans existing installs.
- **`author.name` / `owner.name`: `"Upstash"`** — the company publishing the
plugin, not the plugin itself.
- **Marketplace-level `displayName`** (`.agents/plugins/marketplace.json`) —
names the vendor's marketplace, which hosts the plugin; the plugin's own
title comes from `.codex-plugin/plugin.json`.
This does not loosen the skills rule above: skill names are still never changed
for ranking. That rule is about the `name:` in a `SKILL.md` frontmatter, whose
slug is a stable identifier; this one is about plugin titles, which are free
text. `scripts/check-manifests.mjs` enforces both halves — the titles must say
"Upstash Redis", the slug and vendor must not.
## Plugin manifests must stay in sync
Six manifests describe the same plugin to different clients: `plugin.json`
(portable Agent Plugins) plus `mcp.json`, `.claude-plugin/`, `.codex-plugin/`,
`.cursor-plugin/` and `gemini-extension.json`. They drift easily — a change to
one is almost always a change to all of them.
There is deliberately no `opencode.json` in the repo. Nothing would install it:
OpenCode users get the skills from `npx skills add upstash/skills --agent
opencode` and configure the MCP themselves (see the README). OpenCode reads
`opencode.json` from the project root, so a committed one would only ever
configure OpenCode for people running it *inside a clone of this repo* — a
personal dev setting, not something the repo should ship. It is gitignored;
keep your own if you want the MCP while working here.
`npm run check` runs `scripts/check-manifests.mjs`, which asserts that:
- `version` and `description` match `plugin.json` everywhere, including the
Claude and Cursor marketplace entries;
- the description mentions the MCP server, since that is the only place a user
learns the plugin ships one;
- every client points the MCP at `https://mcp.upstash.com/mcp`;
- `.codex-plugin/plugin.json` carries a complete `interface` block — Codex
renders the plugin card from it, and a missing `logo`/`composerIcon` is why
an installed plugin shows no Upstash icon.
## Schemas are the source of truth, not the other manifests
These clients look alike and are not. Check the real schema before copying a
field from one manifest to another — several are `additionalProperties: false`,
where a stray field is a hard validation failure rather than harmless noise.
| Client | Schema | Strict? | Icon field | `$schema` key |
|---|---|---|---|---|
| Agent Plugins | `agent-plugins.org/schemas/1.0.0/` (serves JSON) | yes | none | yes, and it resolves |
| Claude Code | `code.claude.com/schemas/` (serves the docs page, not JSON) | no | none | accepted by `claude plugin validate` |
| Cursor | `github.com/cursor/plugins/schemas/` | yes | `logo` | **no** — the key itself is rejected |
| Codex | `plugin-json-spec.md` in `openai/codex`, no URL | — | `interface.logo`, `interface.composerIcon` | none published |
| Gemini CLI | none published | — | none | none published |
Two traps worth remembering:
- **Cursor's marketplace entry allows only `name`, `source`, `description` and
`minClientVersions`** — not the version/author/license/category/tags block
that Claude's entry takes. Per-plugin metadata goes in
`.cursor-plugin/plugin.json`, which does accept all of it plus `logo`.
- **Cursor rejects `$schema`.** Its schemas declare no such property, and none
of Cursor's own 65 official manifests carry one.
Validate for real rather than by eye: `claude plugin validate <path>` checks the
Claude manifests, and Cursor's schemas can be diffed against ours from a clone
of `github.com/cursor/plugins`. `scripts/check-manifests.mjs` encodes what both
found.
Branding assets live in `assets/`. Codex and Cursor both render them; Claude
Code, Gemini CLI and the Agent Plugins schema have no icon field, so the icon
cannot be wired up for those clients.
+1
View File
@@ -18,6 +18,7 @@ Connect your AI coding agent to Upstash. This repo ships **skills** (per-SDK ins
- **`skills/upstash/`** — the combined skill, **generated** from all the sources by `npm run build`. Never hand-edit it (see [`AGENTS.md`](AGENTS.md)).
- **Plugin manifests** — `.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/`, `gemini-extension.json`, and the portable [Agent Plugins](https://agent-plugins.org) `plugin.json` + `mcp.json`. They make the repo installable as a plugin/extension. **These plugins now bundle the remote Upstash MCP server (OAuth), so installing the plugin sets up the skills *and* the MCP in one step** — no separate MCP configuration for Claude Code, Codex, Cursor, or Gemini CLI.
- **`zed-extension/`** — a Zed MCP server extension (Rust → Wasm).
- **`assets/`** — branding used on the plugin card, read by Codex (`interface.logo` / `interface.composerIcon`) and Cursor (`logo`). Claude Code, Gemini CLI and the Agent Plugins schema have no icon field.
## Available Skills
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+3 -3
View File
@@ -1,10 +1,10 @@
{
"name": "upstash",
"version": "1.1.0",
"version": "1.2.0",
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"mcpServers": {
"upstash": {
"httpUrl": "https://mcp.upstash.com/mcp"
}
},
"description": "Agent skills for all Upstash SDKs: Redis, Ratelimit, QStash, Workflow, Vector, Search, Box, and the Upstash CLI."
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
"private": true,
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/build.mjs && node scripts/check.mjs"
"check": "node scripts/check-manifests.mjs && node scripts/build.mjs && node scripts/check.mjs",
"check:manifests": "node scripts/check-manifests.mjs"
},
"dsh": {
"bundle": {
+3 -3
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "upstash",
"version": "1.1.0",
"description": "Agent skills for Upstash SDKs and CLIs — Redis, QStash, Workflow, Box (SDKs and the box CLI), Ratelimit, Vector, and Search.",
"version": "1.2.0",
"description": "The Upstash MCP server plus agent skills for every Upstash SDK and CLI — Redis, QStash, Workflow, Box, Ratelimit, Vector, and Search.",
"author": {
"name": "Upstash",
"email": "support@upstash.com",
@@ -11,5 +11,5 @@
"homepage": "https://upstash.com",
"repository": "https://github.com/upstash/skills",
"license": "MIT",
"keywords": ["upstash", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"]
"keywords": ["upstash", "mcp", "redis", "qstash", "vector", "workflow", "ratelimit", "serverless", "sandbox"]
}
+217
View File
@@ -0,0 +1,217 @@
import { readFileSync, existsSync } from "fs";
import { join, resolve, relative, isAbsolute } from "path";
const ROOT = join(import.meta.dirname, "..");
const errors = [];
const read = (path) => JSON.parse(readFileSync(join(ROOT, path), "utf-8"));
const fail = (msg) => errors.push(msg);
const MCP_URL = "https://mcp.upstash.com/mcp";
// Redis is the flagship, so every user-facing plugin title says so — people
// searching a marketplace for "redis" have to find us. See AGENTS.md.
const DISPLAY_NAME = "Upstash Redis";
// The slug is the install identifier (`/plugin install upstash@upstash`) and
// the vendor is the company. Neither is a display surface; renaming the slug
// would break every install command in the README.
const SLUG = "upstash";
const VENDOR = "Upstash";
const root = read("plugin.json");
const claude = read(".claude-plugin/plugin.json");
const claudeMarket = read(".claude-plugin/marketplace.json");
const codex = read(".codex-plugin/plugin.json");
const cursor = read(".cursor-plugin/plugin.json");
const cursorMarket = read(".cursor-plugin/marketplace.json");
const gemini = read("gemini-extension.json");
const mcp = read("mcp.json");
// Every manifest must agree on version, description, repository and license.
const manifests = {
"plugin.json": root,
".claude-plugin/plugin.json": claude,
".codex-plugin/plugin.json": codex,
".cursor-plugin/plugin.json": cursor,
"gemini-extension.json": gemini,
};
for (const [name, manifest] of Object.entries(manifests)) {
for (const field of ["version", "description"]) {
if (manifest[field] !== root[field]) {
fail(`${name} ${field} must match plugin.json (got ${JSON.stringify(manifest[field])}).`);
}
}
}
for (const [name, manifest] of [[".claude-plugin/plugin.json", claude], [".codex-plugin/plugin.json", codex], [".cursor-plugin/plugin.json", cursor]]) {
for (const field of ["repository", "license"]) {
if (manifest[field] !== root[field]) fail(`${name} ${field} must match plugin.json.`);
}
}
// The plugin is titled after the flagship product everywhere it is shown.
const displayNames = {
".claude-plugin/plugin.json": claude.displayName,
".cursor-plugin/plugin.json": cursor.displayName,
".codex-plugin/plugin.json": codex.interface?.displayName,
};
for (const [name, value] of Object.entries(displayNames)) {
if (value !== DISPLAY_NAME) {
fail(`${name} display name must be ${JSON.stringify(DISPLAY_NAME)} (got ${JSON.stringify(value)}).`);
}
}
// ...but the slug and the vendor are not display surfaces.
for (const [name, manifest] of Object.entries(manifests)) {
if (manifest.name !== SLUG) {
fail(`${name} name must stay ${JSON.stringify(SLUG)} — it is the install identifier, not a title (got ${JSON.stringify(manifest.name)}).`);
}
if (manifest.author && manifest.author.name !== VENDOR) {
fail(`${name} author.name must stay ${JSON.stringify(VENDOR)} — that is the company, not the plugin (got ${JSON.stringify(manifest.author.name)}).`);
}
}
// Marketplace entries must mirror the plugin they point at. The two schemas
// differ: Claude's entry carries full metadata, Cursor's allows only
// name/source/description under additionalProperties:false, so extra fields
// there are a validation error rather than harmless noise.
const CURSOR_ENTRY_FIELDS = ["name", "source", "description", "minClientVersions"];
for (const [name, market] of [[".claude-plugin/marketplace.json", claudeMarket], [".cursor-plugin/marketplace.json", cursorMarket]]) {
const entry = market.plugins?.find((p) => p.name === SLUG);
if (!entry) {
fail(`${name} has no ${JSON.stringify(SLUG)} plugin entry.`);
continue;
}
if (entry.description !== root.description) {
fail(`${name} plugin entry description must match plugin.json.`);
}
if (market.owner?.name !== VENDOR) {
fail(`${name} owner.name must stay ${JSON.stringify(VENDOR)} — the marketplace is the vendor's, not the plugin's.`);
}
}
// Claude's entry is the only one that may carry the richer metadata.
const claudeEntry = claudeMarket.plugins?.find((p) => p.name === SLUG);
if (claudeEntry) {
if (claudeEntry.version !== root.version) fail(".claude-plugin/marketplace.json plugin entry version must match plugin.json.");
if (claudeEntry.displayName !== DISPLAY_NAME) {
fail(`.claude-plugin/marketplace.json plugin entry displayName must be ${JSON.stringify(DISPLAY_NAME)} (got ${JSON.stringify(claudeEntry.displayName)}).`);
}
}
// Cursor rejects anything outside its four entry fields.
for (const entry of cursorMarket.plugins ?? []) {
const extra = Object.keys(entry).filter((k) => !CURSOR_ENTRY_FIELDS.includes(k));
if (extra.length > 0) {
fail(`.cursor-plugin/marketplace.json entry "${entry.name}" has fields Cursor's schema rejects (additionalProperties:false): ${extra.join(", ")}. Put plugin metadata in .cursor-plugin/plugin.json instead.`);
}
}
if (cursorMarket.owner && Object.keys(cursorMarket.owner).some((k) => !["name", "email"].includes(k))) {
fail(".cursor-plugin/marketplace.json owner allows only name and email.");
}
// $schema is only safe where the client actually accepts it. Cursor's schemas
// are additionalProperties:false and declare no $schema property (and none of
// Cursor's own 65 manifests carry one), so adding it there is a violation.
const schemaRefs = {
"plugin.json": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"mcp.json": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
".claude-plugin/plugin.json": "https://code.claude.com/schemas/plugin.json",
".claude-plugin/marketplace.json": "https://code.claude.com/schemas/marketplace.json",
};
for (const [name, expected] of Object.entries(schemaRefs)) {
const actual = { "plugin.json": root, "mcp.json": mcp, ".claude-plugin/plugin.json": claude, ".claude-plugin/marketplace.json": claudeMarket }[name].$schema;
if (actual !== expected) fail(`${name} $schema must be ${JSON.stringify(expected)} (got ${JSON.stringify(actual)}).`);
}
for (const [name, manifest] of [[".cursor-plugin/plugin.json", cursor], [".cursor-plugin/marketplace.json", cursorMarket]]) {
if (manifest.$schema !== undefined) {
fail(`${name} must not declare $schema — Cursor's schema is additionalProperties:false and does not allow the key.`);
}
}
// The description is the only place users learn the plugin ships the MCP server.
if (!/\bMCP\b/.test(root.description)) {
fail("plugin.json description must mention the MCP server — the plugins bundle it.");
}
// Every client that can register the MCP must point at the same URL.
const mcpUrls = {
"mcp.json": mcp.mcpServers?.upstash?.url,
".claude-plugin/plugin.json": claude.mcpServers?.upstash?.url,
".codex-plugin/plugin.json": codex.mcpServers?.upstash?.url,
".cursor-plugin/plugin.json": cursor.mcpServers?.upstash?.url,
"gemini-extension.json": gemini.mcpServers?.upstash?.httpUrl,
};
for (const [name, url] of Object.entries(mcpUrls)) {
if (url !== MCP_URL) fail(`${name} must declare the Upstash MCP server at ${MCP_URL} (got ${JSON.stringify(url)}).`);
}
// Codex renders the plugin card from `interface`; missing fields mean a blank card.
const iface = codex.interface;
if (!iface || typeof iface !== "object") {
fail(".codex-plugin/plugin.json is missing the required interface object.");
} else {
for (const field of ["displayName", "shortDescription", "longDescription", "developerName", "category"]) {
if (typeof iface[field] !== "string" || !iface[field].trim()) {
fail(`.codex-plugin/plugin.json interface.${field} must be a non-empty string.`);
}
}
if (!Array.isArray(iface.capabilities) || iface.capabilities.length === 0) {
fail(".codex-plugin/plugin.json interface.capabilities must be a non-empty array.");
}
if (!Array.isArray(iface.defaultPrompt) || iface.defaultPrompt.length === 0 || iface.defaultPrompt.length > 3) {
fail(".codex-plugin/plugin.json interface.defaultPrompt must contain one to three prompts.");
} else {
for (const [i, prompt] of iface.defaultPrompt.entries()) {
if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > 128) {
fail(`.codex-plugin/plugin.json interface.defaultPrompt[${i}] must contain 1-128 characters.`);
}
}
}
// Without a real logo the plugin installs with no Upstash icon.
for (const field of ["composerIcon", "logo"]) {
const value = iface[field];
if (typeof value !== "string") {
fail(`.codex-plugin/plugin.json interface.${field} is required so the plugin shows the Upstash icon.`);
continue;
}
if (!value.startsWith("./")) {
fail(`.codex-plugin/plugin.json interface.${field} must start with "./".`);
continue;
}
const target = resolve(ROOT, value);
const rel = relative(ROOT, target);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
fail(`.codex-plugin/plugin.json interface.${field} must stay inside the plugin root.`);
} else if (!existsSync(target)) {
fail(`.codex-plugin/plugin.json interface.${field} references a missing file: ${value}`);
}
}
}
// Cursor has its own logo field (relative to the plugin root, no "./" prefix
// in Cursor's own plugins), so the icon is wired up there as well as in Codex.
if (typeof cursor.logo !== "string" || !cursor.logo) {
fail(".cursor-plugin/plugin.json logo is required so the plugin shows the Upstash icon in Cursor.");
} else if (cursor.logo.startsWith("/") || cursor.logo.startsWith("./")) {
fail(`.cursor-plugin/plugin.json logo must be a plain path relative to the plugin root (got ${JSON.stringify(cursor.logo)}).`);
} else if (!existsSync(resolve(ROOT, cursor.logo))) {
fail(`.cursor-plugin/plugin.json logo references a missing file: ${cursor.logo}`);
}
// Skills are the payload — every client that can be pointed at them should be.
for (const [name, manifest] of [[".claude-plugin/plugin.json", claude], [".codex-plugin/plugin.json", codex], [".cursor-plugin/plugin.json", cursor]]) {
if (manifest.skills !== "./skills/") fail(`${name} skills must be "./skills/".`);
}
if (errors.length > 0) {
console.error("\n >> Manifest check failed:\n");
for (const error of errors) console.error(`- ${error}`);
console.error();
process.exit(1);
}
console.log("Plugin manifests are consistent.");