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) => {