From 70e6fd867fcead6843847d312f7eaf90932f063a Mon Sep 17 00:00:00 2001 From: jbiskur Date: Tue, 11 Aug 2026 15:21:16 +0100 Subject: [PATCH] feat: support Claude Code alongside the Agent Plugins standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code rejected the repository with "no manifest found at .claude-plugin/marketplace.json". It reads its own manifests, so supporting both clients means shipping both sets. Only skills/ is genuinely shared. concern Agent Plugins Claude Code plugin manifest plugin.json .claude-plugin/plugin.json marketplace .agents/plugins/marketplace.json .claude-plugin/marketplace.json mcp config mcp.json .mcp.json remote transport streamable-http http Schema taken from Claude Code's documentation and cross-checked against Slack's official plugin, which ships .claude-plugin/, .codex-plugin/, .cursor-plugin/ and .agents/ side by side — so this is the established multi-client pattern, not an invention. Verified on Claude Code 2.1.227: `claude plugin marketplace add ./` registers and `claude plugin install usable@usable` installs and enables at 0.1.0. Note a bare `.` is rejected; the ./ form is required. OAuth client id: .mcp.json declares oauth.clientId = mcp_oauth_client, which is where Claude Code reads it (same field Slack's plugin uses). A client_id is a public identifier, not a secret, so it is safe to package. mcp.json stays URL-only because the Agent Plugins schema defines no oauth field and inventing one risks rejection by a strict client. Duplication is a drift risk, so it is guarded rather than trusted: - the two MCP documents must declare the same servers at the same URLs - the two plugin manifests must agree on name and version - oauth blocks are validated fail-closed — only clientId, callbackPort and scopes are permitted, so a clientSecret cannot ship by being an unrecognised field Twelve new self-tests; suite is 27/27. Also added .claude-plugin/ and .mcp.json to the release allowlist, without which the published artifact would have shipped a package Claude Code could not install. Collision now confirmed, not hypothesised: a user-level MCP server named `usable` shadows the plugin's declaration. No plugin:usable:usable server appeared while other plugins' servers did show under that prefix. This was previously recorded as a suspicion for Codex; it is real, and it means the MCP step cannot be verified on a machine that already has such an entry. Documented in authentication.md with the remedy. Claude Code skills are NOT yet confirmed reaching the model. The plugin installs, but Claude Code exposes no CLI listing of available skills, so the matrix records skills as untested rather than assuming parity with Codex. Co-Authored-By: Oz --- .claude-plugin/marketplace.json | 20 ++++ .claude-plugin/plugin.json | 22 ++++ .mcp.json | 11 ++ CHANGELOG.md | 40 +++++-- README.md | 23 +++- docs/authentication.md | 49 ++++++++- docs/supported-clients.md | 58 ++++++++-- examples/README.md | 6 +- examples/claude-code/README.md | 108 +++++++++++++++++++ scripts/build-release.mjs | 8 +- scripts/validate-package.mjs | 185 ++++++++++++++++++++++++++++++++ tests/smoke/validator.test.mjs | 95 +++++++++++++++- 12 files changed, 595 insertions(+), 30 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .mcp.json create mode 100644 examples/claude-code/README.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..c25c304 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "usable", + "description": "Knowledge-first workflow for coding agents, backed by Usable.", + "owner": { + "name": "Flowcore", + "email": "support@usable.dev", + "url": "https://www.usable.dev" + }, + "plugins": [ + { + "name": "usable", + "source": "./", + "description": "Search your team's knowledge before you build. Retrieves complete sources, separates evidence from assumptions, and verifies before claiming success.", + "version": "0.1.0", + "author": { + "name": "Flowcore" + } + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..ddba271 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "usable", + "description": "Knowledge-first workflow backed by Usable. Search team knowledge before implementing, retrieve complete sources, separate evidence from assumptions, and verify before claiming success.", + "version": "0.1.0", + "author": { + "name": "Flowcore", + "email": "support@usable.dev", + "url": "https://www.usable.dev" + }, + "homepage": "https://www.usable.dev", + "repository": "https://github.com/flowcore-io/usable-agent-plugin", + "license": "MIT", + "keywords": [ + "usable", + "knowledge", + "memory", + "mcp", + "retrieval", + "verification" + ] +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c299408 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "usable": { + "type": "http", + "url": "https://usable.dev/api/mcp", + "oauth": { + "clientId": "mcp_oauth_client" + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0e671..aa0b317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,27 +8,49 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## Unreleased ### Added +- Claude Code support alongside the Agent Plugins standard: `.claude-plugin/marketplace.json`, + `.claude-plugin/plugin.json`, and `.mcp.json`. Only `skills/` is shared between the two + formats; filenames and one transport identifier differ, so both sets ship. +- The public OAuth client ID `mcp_oauth_client` is declared for Claude Code in `.mcp.json` + under `oauth.clientId`, and documented for both clients' `add` commands. +- Drift guards for the duplicated configuration: the validator asserts `mcp.json` and + `.mcp.json` declare the same servers pointing at the same URLs, and that the two plugin + manifests agree on name and version. +- `oauth` blocks are validated fail-closed: only `clientId`, `callbackPort`, and `scopes` are + permitted, so a client secret cannot ship by being an unrecognised field. - Usable brand assets: `assets/usable-icon.svg` (composer icon) and `assets/usable-logo.png` (600×600 logo), taken from the Usable brand kit. - Codex interface metadata under the `extensions["com.openai"]` namespace in `plugin.json`: display name, short and long descriptions, developer name, category, website, privacy and terms URLs, brand colour `#347cbf`, icon and logo paths, and a default prompt. - Validation of client-extension file references: paths must be plugin-relative, must stay - inside the plugin root, and must exist. Three new self-tests cover a missing asset, a path - escaping the root, and a namespace without a reverse domain. + inside the plugin root, and must exist. +- Twelve further self-tests covering all of the above; the suite is now 27 cases. ### Changed - `assets/` is now included in the release archive allowlist. - `homepage` and author URL now use the canonical `https://www.usable.dev`, which is where the apex domain redirects. -### Notes -- Codex surfaced the plugin with a generic icon and "Website: Unavailable" because it reads - presentation metadata from an `interface` object, not from the Agent Plugins `homepage` - field. **Whether Codex reads that object from the inline `com.openai` extension has not been - verified visually** — the CLI exposes no way to inspect resolved interface metadata. If the - icon and website still do not render, the fallback is a `.codex-plugin/plugin.json` overlay, - which must be added carefully because a malformed overlay could disturb skill discovery that +### Fixed +- `.claude-plugin/` and `.mcp.json` added to the release-archive allowlist. Without this the + published artifact would have shipped a package Claude Code could not install. + +### Verified +- Claude Code 2.1.227 on macOS: `claude plugin marketplace add ./` registers the marketplace + and `claude plugin install usable@usable` installs and enables the plugin at 0.1.0. + +### Known limitations +- **A user-level MCP server named `usable` shadows the plugin's declaration.** Confirmed on + Claude Code, where no `plugin:usable:usable` server appeared while other plugins' servers did. + This upgrades the Codex-side collision risk from hypothesis to confirmed behaviour, and it + means neither client's MCP step can be verified on a machine that already has such an entry. +- Claude Code skills are not yet confirmed reaching the model. The plugin installs, but Claude + Code exposes no CLI listing of available skills, so this needs an interactive check. +- Whether Codex reads the `interface` object from the inline `com.openai` extension is still + **unverified visually** — the CLI exposes no way to inspect resolved interface metadata. If the + icon and website do not render, the fallback is a `.codex-plugin/plugin.json` overlay, which + must be added carefully because a malformed overlay could disturb skill discovery that currently works. ## 0.1.0 — 2026-08-07 diff --git a/README.md b/README.md index 48a17b1..2a3e1d8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ assumptions, and verify before claiming success. | [`usable-knowledge-workflow`](skills/usable-knowledge-workflow/SKILL.md) | Read-first loop: search Usable, fetch complete sources, rank by verification and freshness, write a knowledge receipt, verify before claiming done | | [`usable-knowledge-capture`](skills/usable-knowledge-capture/SKILL.md) | Write verified outcomes back to Usable — gated on verification, deduplication, and explicit user confirmation | | [`mcp.json`](mcp.json) | Declares the hosted Usable MCP server at `https://usable.dev/api/mcp` over `streamable-http` | +| [`.mcp.json`](.mcp.json) | The same server for Claude Code, which uses transport `http` and reads a different filename | No credentials ship in this package, anywhere. The **release archive** contains only Markdown and JSON — no executable code, no dependencies, no install hooks. A **marketplace or Git @@ -27,22 +28,34 @@ All instruction text is in `skills/`. Read it before enabling. ## Install -**Codex** (verified — loads the package and both skills): +The package ships manifests for both the Agent Plugins standard and Claude Code's own format, +so it installs in either. + +**Codex** — loads the package and both skills: ```bash codex plugin marketplace add flowcore-io/usable-agent-plugin --ref main codex plugin add usable@usable ``` -Confirm the model can see them: - ```bash codex exec "List the names of every skill available to you, one per line, then stop." # expect: usable:usable-knowledge-workflow / usable:usable-knowledge-capture ``` -Other clients, tagged releases with checksum verification, upgrade, rollback, and uninstall: -[`docs/installation.md`](docs/installation.md) and [`examples/codex/README.md`](examples/codex/README.md). +**Claude Code** — marketplace registers, plugin installs and enables: + +```bash +claude plugin marketplace add flowcore-io/usable-agent-plugin +claude plugin install usable@usable +``` + +Per-client detail: [`examples/codex/`](examples/codex/README.md) and +[`examples/claude-code/`](examples/claude-code/README.md). Tagged releases with checksum +verification, upgrade, rollback, and uninstall: [`docs/installation.md`](docs/installation.md). + +If your client already has an MCP server named `usable`, it shadows the plugin's declaration — +see [authentication](docs/authentication.md). ## Authentication diff --git a/docs/authentication.md b/docs/authentication.md index a67740f..5d32cfd 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -59,8 +59,32 @@ GET https://usable.dev/.well-known/oauth-authorization-server | `grant_types_supported` | `authorization_code`, `refresh_token`, `client_credentials`, `device_code` | | `token_endpoint_auth_methods_supported` | `client_secret_basic`, `client_secret_post`, `none` | -Dynamic client registration is available, so clients do not need a pre-registered client ID. -Public clients may register with `none` and use PKCE. +Dynamic client registration is available, so a client can obtain its own ID. Public clients may +register with `none` and use PKCE. + +### The default client ID + +Usable's default MCP configuration uses the public client ID **`mcp_oauth_client`**. Clients +that do not implement dynamic registration, or that prefer a fixed ID, should use it: + +```bash +# Claude Code +claude mcp add --transport http usable https://usable.dev/api/mcp --client-id mcp_oauth_client + +# Codex +codex mcp add usable --url https://usable.dev/api/mcp --oauth-client-id mcp_oauth_client +``` + +An OAuth `client_id` is a public identifier, not a secret — it is safe in a shared config and +in this repository. This package declares it for Claude Code in `.mcp.json` under +`oauth.clientId`, which is the field Claude Code reads. + +A client **secret** is a different thing entirely and must never be packaged. CI enforces the +distinction: the validator permits only `clientId`, `callbackPort`, and `scopes` inside an +`oauth` block and fails on anything else, so a secret cannot slip in by being unrecognised. + +The Agent Plugins `mcp.json` stays URL-only. Its schema does not define an `oauth` field, and +inventing one risks rejection by a strict client. **4. Authorize with PKCE** @@ -98,6 +122,27 @@ Do **not** work around a headless client by committing a shared static credentia `mcp.json` — that defeats per-user authorization and rotation. Track this in [open decision #5](../README.md#open-decisions). +## A user-level server of the same name shadows this one + +If your client already has an MCP server named `usable` at user or project scope, that entry +takes precedence and the plugin's declaration is silently ignored. Confirmed on both Claude +Code and Codex. + +Two consequences worth knowing: + +- You cannot tell from `claude mcp list` or `codex mcp list` whether a `usable` server came + from this plugin or from your own config. On Claude Code a plugin-provided server appears as + `plugin:usable:usable`; a bare `usable` is yours, not the plugin's. +- If your existing entry authenticates with a static bearer token rather than OAuth, you keep + that behaviour — and none of this package's credential hygiene applies to it. A long-lived + token in a client config is worth replacing with the OAuth flow. + +To let the plugin's declaration take effect, remove or rename the user-level entry: + +```bash +claude mcp remove usable # or: codex mcp remove usable +``` + ## Revoking access Revoke the client's grant in your Usable account settings, then remove the stored diff --git a/docs/supported-clients.md b/docs/supported-clients.md index 2084dc0..eaae155 100644 --- a/docs/supported-clients.md +++ b/docs/supported-clients.md @@ -18,10 +18,10 @@ support based on a client advertising Agent Plugins compatibility. Legend: ✅ verified · ⚠️ partial · ❌ not supported · ⏳ untested -| Client | Plugin loading | Agent Skills | `streamable-http` MCP | OAuth 2.1 + PKCE | OS tested | Last tested | Status | +| Client | Plugin loading | Agent Skills | Remote MCP | OAuth 2.1 + PKCE | OS tested | Last tested | Status | |---|---|---|---|---|---|---|---| -| Codex CLI 0.146.0 | ✅ | ✅ | ⏳ | ⏳ | macOS | 2026-08-07 | Partial (steps 1 of 5) | -| Claude Code | ⏳ | ⏳ | ⏳ | ⏳ | — | — | Untested | +| Codex CLI 0.146.0 | ✅ | ✅ | ⏳ | ⏳ | macOS | 2026-08-07 | Partial (1 of 5) | +| Claude Code 2.1.227 | ✅ | ⏳ | ⏳ | ⏳ | macOS | 2026-08-11 | Partial (1 of 5) | | Warp / Oz | ⏳ | ⏳ | ⏳ | ⏳ | — | — | Untested | | Cursor | ⏳ | ⏳ | ⏳ | ⏳ | — | — | Untested | | Opencode | ⏳ | ⏳ | ⏳ | ⏳ | — | — | Untested | @@ -30,9 +30,29 @@ Launch targets are [open decision #2](../README.md#open-decisions). Two clients ✅ across all columns before `1.0.0`. Agent Plugins 1.0.0 was published on 2026-08-06 with launch support announced for ChatGPT and -Codex, Cursor, GitHub Copilot, Kiro, and VS Code. Claude Code is not among them — it uses its -own `.claude-plugin/plugin.json` format — so Claude Code support should be treated as -unlikely until tested, not merely unverified. +Codex, Cursor, GitHub Copilot, Kiro, and VS Code. Claude Code is not among them and uses its +own manifest format, so this package ships both sets of manifests — see +[Supporting two formats](#supporting-two-formats). + +## Supporting two formats + +The two clients disagree on filenames and on one transport identifier, so both are shipped. +Only `skills/` is genuinely shared. + +| Concern | Agent Plugins | Claude Code | +|---|---|---| +| Plugin manifest | `plugin.json` | `.claude-plugin/plugin.json` | +| Marketplace catalogue | `.agents/plugins/marketplace.json` (Codex) | `.claude-plugin/marketplace.json` | +| MCP config | `mcp.json` | `.mcp.json` | +| Remote transport | `streamable-http` | `http` | +| Skills | `skills/` | `skills/` | + +The duplication is a drift risk, so CI guards it: the validator asserts the two MCP documents +declare the same servers pointing at the same URLs, and that the two plugin manifests agree on +name and version. Editing one and forgetting the other fails the build. + +This mirrors what other multi-client plugins do — Slack's official Claude Code plugin ships +`.claude-plugin/`, `.codex-plugin/`, `.cursor-plugin/` and `.agents/` side by side. ## Per-client notes @@ -70,9 +90,29 @@ executed by the client, but the "Markdown and JSON only" property holds strictly release archive, not for a marketplace install. ### Claude Code -Untested, and expected to need a separate format. Claude Code uses `.claude-plugin/plugin.json` -and reads project skills from `.claude/skills/`; it was not part of the Agent Plugins launch -lineup. Supporting it may require a client extension namespace or a distinct package. +Partially verified on 2026-08-11 against Claude Code 2.1.227 on macOS. + +**Step 1 passed.** The marketplace registers and the plugin installs and enables: + +``` +usable@usable Version: 0.1.0 Scope: user Status: ✔ enabled +``` + +See [`../examples/claude-code/README.md`](../examples/claude-code/README.md) for exact steps. +Note `claude plugin marketplace add ./` — a bare `.` is rejected. + +**Skills not yet confirmed reaching the model.** The package installs, but we have not yet +observed `usable:usable-knowledge-workflow` and `usable:usable-knowledge-capture` offered in a +session. Claude Code exposes no CLI listing of available skills, so this needs an interactive +check. + +**Step 2 inconclusive, and for an identified reason.** The test machine had a user-level +`[mcpServers.usable]` entry in `~/.claude.json` pointing at the same URL, which shadows the +plugin's declaration — no `plugin:usable:usable` server appeared, while other plugins' servers +did show under that prefix. This is direct evidence of the collision risk previously recorded +as a hypothesis for Codex. Re-test on a machine with no pre-existing `usable` server. + +**Steps 3–5 untested.** ### Warp / Oz Untested. Supports MCP servers and skills natively; needs verification of whether an Agent diff --git a/examples/README.md b/examples/README.md index 1cf2111..28d1e24 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,9 +7,11 @@ client advertising Agent Plugins compatibility. - [`codex/`](codex/) — verified 2026-08-07 on Codex CLI 0.146.0. Loads the package and both skills; MCP and OAuth steps still outstanding. +- [`claude-code/`](claude-code/) — verified 2026-08-11 on Claude Code 2.1.227. Marketplace + registers and the plugin installs and enables; skills-reaching-the-model and MCP steps still + outstanding. -Still to test: Cursor, Warp / Oz, Opencode, and Claude Code (which uses its own plugin format -and was not part of the Agent Plugins launch lineup). +Still to test: Cursor, Warp / Oz, Opencode. Each entry records the install commands, how to verify, the tested client version, and any deviations found. See [`../docs/supported-clients.md`](../docs/supported-clients.md) for the diff --git a/examples/claude-code/README.md b/examples/claude-code/README.md new file mode 100644 index 0000000..3b8aac4 --- /dev/null +++ b/examples/claude-code/README.md @@ -0,0 +1,108 @@ +# Claude Code + +Verified 2026-08-11 against **Claude Code 2.1.227** on macOS. The marketplace registers and the +plugin installs and enables. See +[`../../docs/supported-clients.md`](../../docs/supported-clients.md) for exactly what is and is +not verified. + +## How Claude Code installs plugins + +Claude Code reads its own manifests, separate from the Agent Plugins ones: + +| File | Purpose | +|---|---| +| `.claude-plugin/marketplace.json` | Marketplace catalogue, at the repository root | +| `.claude-plugin/plugin.json` | Plugin manifest | +| `.mcp.json` | MCP servers — note the leading dot, and `type: "http"` rather than `streamable-http` | +| `skills/` | Shared with Agent Plugins, no duplication needed | + +This repository is both the marketplace and the single plugin it lists, so the marketplace +entry points at `./`. + +## Install from GitHub + +```bash +claude plugin marketplace add flowcore-io/usable-agent-plugin +claude plugin install usable@usable +``` + +## Install from a local clone + +```bash +git clone https://github.com/flowcore-io/usable-agent-plugin.git +cd usable-agent-plugin +claude plugin marketplace add ./ +claude plugin install usable@usable +``` + +Use `./`, not `.`. A bare dot is rejected with +`Invalid marketplace source format. Try: owner/repo, https://..., or ./path`. + +The `@` form is required, and here both are named `usable`, so the +command reads a little oddly but is correct. + +## Verify + +```bash +claude plugin list +``` + +Expect: + +``` +usable@usable + Version: 0.1.0 + Scope: user + Status: enabled +``` + +If the install summary says `Run /reload-plugins to activate.`, run that inside Claude Code. + +Then confirm the skills reached the model — ask it inside a session: + +> Which skills do you have available? + +Plugin skills are namespaced, so expect `usable:usable-knowledge-workflow` and +`usable:usable-knowledge-capture`. + +## MCP server + +The plugin declares the Usable MCP server in `.mcp.json` with the public OAuth client ID +`mcp_oauth_client`. Claude Code performs the OAuth flow and stores tokens itself; no credential +ships in this package. + +**A user-level server named `usable` will shadow it.** If you already have one, the plugin's +declaration is silently ignored. Plugin-provided servers appear as `plugin:usable:usable` in +`claude mcp list`; a bare `usable` entry is your own config, not this plugin's. + +```bash +claude mcp list | grep usable +``` + +To let the plugin's declaration take effect: + +```bash +claude mcp remove usable +``` + +Worth checking while you are there: if your existing entry uses a static +`Authorization: Bearer ...` header, that is a long-lived credential sitting in +`~/.claude.json`. The OAuth flow is the better path, and removing the entry lets the plugin +provide it. + +## Try it + +From a repository whose decisions are recorded in Usable, ask something the team has already +decided. A working install cites retrieved sources. An install that cannot reach Usable says so +explicitly rather than answering confidently from general knowledge — that degraded-mode +honesty is itself the thing being tested. + +## Uninstall + +```bash +claude plugin uninstall usable@usable +claude plugin marketplace remove usable +``` + +Removing files does not revoke your OAuth grant. To fully disconnect, revoke it in your Usable +account settings and clear the stored credentials from Claude Code. diff --git a/scripts/build-release.mjs b/scripts/build-release.mjs index 6365311..4f27794 100644 --- a/scripts/build-release.mjs +++ b/scripts/build-release.mjs @@ -26,6 +26,10 @@ const DIST = join(ROOT, "dist"); const ALLOWLIST = [ "plugin.json", "mcp.json", + // Claude Code reads its own manifests and its own MCP filename. Both clients + // are supported from one package, so both sets ship. + ".claude-plugin/", + ".mcp.json", "skills/", "assets/", "docs/", @@ -71,8 +75,8 @@ mkdirSync(stage, { recursive: true }); for (const entry of ALLOWLIST) { const src = join(ROOT, entry); if (!existsSync(src)) { - if (entry === "mcp.json") { - console.log(` skip ${entry} (absent; skills-only package)`); + if (entry === "mcp.json" || entry === ".mcp.json" || entry === ".claude-plugin/") { + console.log(` skip ${entry} (absent)`); continue; } console.error(`Allowlisted path missing: ${entry}`); diff --git a/scripts/validate-package.mjs b/scripts/validate-package.mjs index e143dbb..d624bff 100644 --- a/scripts/validate-package.mjs +++ b/scripts/validate-package.mjs @@ -24,6 +24,16 @@ const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; const VALID_TRANSPORTS = ["stdio", "streamable-http", "sse"]; + +// Claude Code reads its own manifests and uses "http" where Agent Plugins says +// "streamable-http". Supporting both clients means shipping both files, so the +// duplication is deliberate and guarded by a parity check below. +const CLAUDE_DIR = ".claude-plugin"; +const CLAUDE_MCP = ".mcp.json"; +const CLAUDE_TRANSPORTS = ["stdio", "http", "sse"]; +// An OAuth client_id is a public identifier, not a credential, so it may be +// packaged. Anything that could carry a secret may not. +const CLAUDE_OAUTH_PUBLIC_KEYS = new Set(["clientId", "callbackPort", "scopes"]); const ALLOWED_MANIFEST_KEYS = new Set([ "$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions", @@ -266,6 +276,178 @@ function validateMcp() { if (errors.every((e) => !e.startsWith(CHECK))) pass(`${CHECK} is URL-only and credential-free`); } +// ── Claude Code: .mcp.json ─────────────────────────────────────────────────── +function validateClaudeMcp() { + const CHECK = CLAUDE_MCP; + + if (!existsSync(join(ROOT, CLAUDE_MCP))) { + warn(CHECK, "not present; Claude Code will load skills but no MCP server"); + return; + } + + let mcp; + try { + mcp = readJson(CLAUDE_MCP); + } catch (e) { + fail(CHECK, `invalid JSON — ${e.message}`); + return; + } + + if (!mcp.mcpServers || typeof mcp.mcpServers !== "object") { + fail(CHECK, "missing mcpServers object"); + return; + } + + for (const [name, server] of Object.entries(mcp.mcpServers)) { + if (!CLAUDE_TRANSPORTS.includes(server.type)) { + fail(CHECK, `server "${name}" type must be one of ${CLAUDE_TRANSPORTS.join(", ")}, found ${JSON.stringify(server.type)} — Claude Code uses "http", not "streamable-http"`); + } + if (server.type !== "stdio") { + if (!server.url) { + fail(CHECK, `server "${name}" (${server.type}) needs a url`); + } else if (!server.url.startsWith("https://")) { + fail(CHECK, `server "${name}" url must use https, found ${server.url}`); + } + } + if (server.headers) { + fail(CHECK, `server "${name}" declares headers; credentials must not be packaged`); + } + if (server.env) { + fail(CHECK, `server "${name}" declares env; credential-bearing environment variables must not be packaged`); + } + + // Claude Code accepts an oauth block here. A client_id is a public + // identifier and is safe to ship; a client secret or token is not. This + // fails closed: only explicitly known-public keys are permitted, so a + // future secret-bearing field cannot slip through by being unrecognised. + if (server.oauth !== undefined) { + if (typeof server.oauth !== "object" || server.oauth === null) { + fail(CHECK, `server "${name}" oauth must be an object`); + } else { + for (const key of Object.keys(server.oauth)) { + if (!CLAUDE_OAUTH_PUBLIC_KEYS.has(key)) { + fail(CHECK, `server "${name}" oauth.${key} is not a recognised public field; only ${[...CLAUDE_OAUTH_PUBLIC_KEYS].join(", ")} may be packaged. Secrets and tokens must never ship.`); + } + } + if (server.oauth.clientId !== undefined && typeof server.oauth.clientId !== "string") { + fail(CHECK, `server "${name}" oauth.clientId must be a string`); + } + } + } + } + + if (errors.every((e) => !e.startsWith(CHECK))) { + pass(`${CHECK} declares no credentials (public oauth client id only)`); + } +} + +// ── Parity between the two MCP documents ───────────────────────────────────── +// They cannot be byte-identical because the transport identifiers differ, so +// compare what must agree: which servers exist and where they point. Without +// this, one file could be updated and the other silently left behind. +function validateMcpParity() { + const CHECK = "mcp-parity"; + + if (!existsSync(join(ROOT, "mcp.json")) || !existsSync(join(ROOT, CLAUDE_MCP))) return; + + let a, b; + try { + a = readJson("mcp.json").mcpServers ?? {}; + b = readJson(CLAUDE_MCP).mcpServers ?? {}; + } catch { + return; // shape errors already reported by the per-file checks + } + + const namesA = Object.keys(a).sort(); + const namesB = Object.keys(b).sort(); + + if (namesA.join(",") !== namesB.join(",")) { + fail(CHECK, `mcp.json declares [${namesA}] but ${CLAUDE_MCP} declares [${namesB}]; both clients must be offered the same servers`); + return; + } + + for (const name of namesA) { + if (a[name].url !== b[name].url) { + fail(CHECK, `server "${name}" points at ${a[name].url} in mcp.json but ${b[name].url} in ${CLAUDE_MCP}`); + } + } + + if (errors.every((e) => !e.startsWith(CHECK))) { + pass(`mcp.json and ${CLAUDE_MCP} agree on servers and URLs`); + } +} + +// ── Claude Code: plugin and marketplace manifests ──────────────────────────── +function validateClaudeManifests(rootManifest) { + const CHECK = CLAUDE_DIR; + + const pluginPath = join(ROOT, CLAUDE_DIR, "plugin.json"); + const marketPath = join(ROOT, CLAUDE_DIR, "marketplace.json"); + + if (!existsSync(pluginPath) && !existsSync(marketPath)) { + warn(CHECK, "absent; the package will not be installable in Claude Code"); + return; + } + + if (existsSync(pluginPath)) { + let p; + try { + p = JSON.parse(readFileSync(pluginPath, "utf8")); + } catch (e) { + fail(CHECK, `plugin.json invalid JSON — ${e.message}`); + p = null; + } + if (p) { + if (!p.name) fail(CHECK, "plugin.json missing required \"name\""); + if (rootManifest && p.name && p.name !== rootManifest.name) { + fail(CHECK, `plugin.json name "${p.name}" does not match root plugin.json name "${rootManifest.name}"`); + } + if (rootManifest && p.version && p.version !== rootManifest.version) { + fail(CHECK, `plugin.json version "${p.version}" does not match root plugin.json version "${rootManifest.version}"`); + } + if (!p.description) warn(CHECK, "plugin.json has no description"); + } + } else { + fail(CHECK, `marketplace.json present but ${CLAUDE_DIR}/plugin.json is missing`); + } + + if (existsSync(marketPath)) { + let m; + try { + m = JSON.parse(readFileSync(marketPath, "utf8")); + } catch (e) { + fail(CHECK, `marketplace.json invalid JSON — ${e.message}`); + m = null; + } + if (m) { + if (!m.name) fail(CHECK, "marketplace.json missing required \"name\""); + if (!m.owner || !m.owner.name) fail(CHECK, "marketplace.json missing required \"owner.name\""); + if (!Array.isArray(m.plugins) || m.plugins.length === 0) { + fail(CHECK, "marketplace.json must list at least one plugin"); + } else { + for (const entry of m.plugins) { + if (!entry.name) fail(CHECK, "marketplace.json plugin entry missing \"name\""); + if (typeof entry.source === "string") { + const resolved = resolve(ROOT, entry.source); + if (!resolved.startsWith(ROOT)) { + fail(CHECK, `marketplace.json plugin "${entry.name}" source ${entry.source} escapes the repository root`); + } else if (!existsSync(join(resolved, CLAUDE_DIR, "plugin.json"))) { + fail(CHECK, `marketplace.json plugin "${entry.name}" source ${entry.source} has no ${CLAUDE_DIR}/plugin.json`); + } + } else if (!entry.source) { + fail(CHECK, `marketplace.json plugin "${entry.name}" missing "source"`); + } + if (rootManifest && entry.version && entry.version !== rootManifest.version) { + fail(CHECK, `marketplace.json plugin "${entry.name}" version "${entry.version}" does not match root plugin.json version "${rootManifest.version}"`); + } + } + } + } + } + + if (errors.every((e) => !e.startsWith(CHECK))) pass(`${CHECK} manifests valid and consistent with plugin.json`); +} + // ── FR-1: path containment and symlinks ────────────────────────────────────── function validatePaths() { const CHECK = "paths"; @@ -399,6 +581,9 @@ console.log(`Validating package at ${ROOT}\n`); const manifest = validateManifest(); validateSkills(); validateMcp(); +validateClaudeMcp(); +validateMcpParity(); +validateClaudeManifests(manifest); validatePaths(); validateNoSecrets(); validateConsistency(manifest); diff --git a/tests/smoke/validator.test.mjs b/tests/smoke/validator.test.mjs index e334f32..f9107f0 100644 --- a/tests/smoke/validator.test.mjs +++ b/tests/smoke/validator.test.mjs @@ -36,7 +36,10 @@ function runValidator(dir) { function inSandbox(mutate) { const dir = mkdtempSync(join(tmpdir(), "uap-test-")); try { - for (const entry of ["plugin.json", "mcp.json", "skills", "assets", "docs", "scripts", "LICENSE"]) { + for (const entry of [ + "plugin.json", "mcp.json", ".mcp.json", ".claude-plugin", + "skills", "assets", "docs", "scripts", "LICENSE", + ]) { cpSync(join(ROOT, entry), join(dir, entry), { recursive: true }); } mutate(dir); @@ -208,6 +211,96 @@ const cases = [ expect: (r) => r.code === 1 && /reverse-domain namespace/.test(r.output), describe: "should require reverse-domain extension keys", }, + { + name: "rejects a secret in the Claude oauth block", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + mcp.mcpServers.usable.oauth.clientSecret = "not-a-real-secret-value"; + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /not a recognised public field/.test(r.output), + describe: "should fail closed on unrecognised oauth fields", + }, + { + name: "rejects headers in .mcp.json", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + mcp.mcpServers.usable.headers = { Authorization: "Bearer someactualtokenvalue" }; + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /headers/.test(r.output), + describe: "should reject a packaged header in the Claude MCP config", + }, + { + name: "rejects the Agent Plugins transport in .mcp.json", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + mcp.mcpServers.usable.type = "streamable-http"; + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /Claude Code uses "http"/.test(r.output), + describe: "should catch the transport identifiers being swapped", + }, + { + name: "rejects MCP configs that disagree on url", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + mcp.mcpServers.usable.url = "https://staging.usable.dev/api/mcp"; + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /points at/.test(r.output), + describe: "should catch drift between the two MCP documents", + }, + { + name: "rejects MCP configs that disagree on server set", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + mcp.mcpServers.extra = { type: "http", url: "https://example.invalid/mcp" }; + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /must be offered the same servers/.test(r.output), + describe: "should require both clients to get the same servers", + }, + { + name: "rejects Claude plugin name mismatch", + mutate: (dir) => { + const p = JSON.parse(readFileSync(join(dir, ".claude-plugin", "plugin.json"), "utf8")); + p.name = "usable-something-else"; + writeFileSync(join(dir, ".claude-plugin", "plugin.json"), JSON.stringify(p, null, 2)); + }, + expect: (r) => r.code === 1 && /does not match root plugin.json name/.test(r.output), + describe: "should keep the two plugin manifests in agreement", + }, + { + name: "rejects Claude plugin version mismatch", + mutate: (dir) => { + const p = JSON.parse(readFileSync(join(dir, ".claude-plugin", "plugin.json"), "utf8")); + p.version = "9.9.9"; + writeFileSync(join(dir, ".claude-plugin", "plugin.json"), JSON.stringify(p, null, 2)); + }, + expect: (r) => r.code === 1 && /does not match root plugin.json version/.test(r.output), + describe: "should catch a version that would ship stale in one client", + }, + { + name: "rejects marketplace source with no plugin manifest", + mutate: (dir) => { + const m = JSON.parse(readFileSync(join(dir, ".claude-plugin", "marketplace.json"), "utf8")); + m.plugins[0].source = "./docs"; + writeFileSync(join(dir, ".claude-plugin", "marketplace.json"), JSON.stringify(m, null, 2)); + }, + expect: (r) => r.code === 1 && /has no \.claude-plugin\/plugin\.json/.test(r.output), + describe: "should verify the marketplace points at a real plugin", + }, + { + name: "rejects marketplace missing owner", + mutate: (dir) => { + const m = JSON.parse(readFileSync(join(dir, ".claude-plugin", "marketplace.json"), "utf8")); + delete m.owner; + writeFileSync(join(dir, ".claude-plugin", "marketplace.json"), JSON.stringify(m, null, 2)); + }, + expect: (r) => r.code === 1 && /owner\.name/.test(r.output), + describe: "should require the owner field Claude Code mandates", + }, { name: "rejects an undocumented MCP url", mutate: (dir) => {