diff --git a/docs/adoption/listings/mcp.md b/docs/adoption/listings/mcp.md index bb31f5a..7d2c215 100644 --- a/docs/adoption/listings/mcp.md +++ b/docs/adoption/listings/mcp.md @@ -124,12 +124,25 @@ support.claude.com "Anthropic Software Directory Policy". token); the optional dashboard read is the only network request off the machine. Compliance: contact email and the four acknowledgements. Updates: GitHub push webhook. - **What the checks will say.** Local run of `claude plugin validate --profile directory` on the assembled - repository (Claude Code 2.1.309, 2026-10-28): passes with nothing to report. The portal also holds pinned launchers for a reviewer: - expect "Runs a pinned npx or uvx package" (both `.mcp.json` and the hook run `npx -y @genclass/mcp@0.2.1`), and - possibly "MCP server command wasn't read" because the server is not a file inside the plugin folder. An unpinned - launcher would block; `packages/mcp/test/manifests.test.ts` keeps both pins equal to `package.json`'s version. The - way to avoid the hold is to ship the built server (`dist/cli.js`, 125 KB, no dependencies) inside the plugin - folder and run `node ${CLAUDE_PLUGIN_ROOT}/...`; that means committing a build output, so it is left to the owner. + repository (Claude Code 2.1.311, 2026-10-28, with and without the lockfile below): passes with nothing to report; + the lockfile check is the portal's alone. The portal's first validation (2026-10-28, `main` at e911b25, before the + lockfile) passed with one warning, "Launcher lock missing" on `mcpServers.genclass` in `.mcp.json`: ship a + `package-lock.json` (`npm ci`) or `uv.lock` next to the manifest that declares the server; exact versions, + registry sources and hashes are required for the Verified badge. `package.json` and `package-lock.json` at the + plugin root answer it (lockfileVersion 3; `@genclass/mcp` at the plugin's version with its registry tarball and + sha512 integrity; `packages/mcp/test/manifests.test.ts` keeps them at `package.json`'s version, and the daily sync + carries them to the plugin repository). The portal also held the pinned launchers for a reviewer, "Runs a pinned + npx or uvx package" (2 findings: `.mcp.json` and the hook run `npx -y @genclass/mcp@0.2.1`), whose text prefers + "a package that ships a lockfile" (which it now does) or the package's readable source vendored into the plugin + and run from `${CLAUDE_PLUGIN_ROOT}`; whether the lockfile lifts the hold is the reviewer's call. Possibly also + "MCP server command wasn't read" because the server is not a file inside the plugin folder. An unpinned launcher + would block; the manifests test keeps both pins equal to `package.json`'s version. The way to avoid the hold for + certain is to ship the built server (`dist/cli.js`, 125 KB, no dependencies) inside the plugin folder and run + `node ${CLAUDE_PLUGIN_ROOT}/...`; that means committing a build output, so it is left to the owner. Between a + version bump and its release the lock still names the previous release (npm does not serve the new one yet), and a + daily sync in that window carries that mismatch to the plugin repository until the post-release sync. Claude Code + 2.1.311 installs a plugin's lockfile when it installs the plugin (`npm ci --ignore-scripts` in the plugin's cache + folder), so installing the plugin now also fetches `@genclass/mcp` there; the launchers still run `npx`. - **Policy items.** Privacy policy URL: `privacyPolicyUrl` in `plugin.json` (the plugin README's Privacy section). Support: `supportUrl` (GitHub issues). Tool annotations: every tool has `title`, `readOnlyHint`, `destructiveHint` and `openWorldHint`. Test account: none needed (no account exists); for a reviewer's page to test against, the diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 8c066cd..1e7abcf 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- **The plugin ships the launcher's lockfile.** `plugin/package.json` (private; its one dependency is `@genclass/mcp` + at this version) and `plugin/package-lock.json` (lockfileVersion 3: the version, its registry tarball and sha512 + integrity) sit next to `.mcp.json`. Anthropic's directory asked for them on the plugin's first validation + (2026-10-28, the warning "Launcher lock missing": a lockfile with exact versions, registry sources and hashes is + required for the Verified badge), and its policy hold on pinned `npx` launchers prefers "a package that ships a + lockfile". Both files reach [genclass-dev/genclass-plugin](https://github.com/genclass-dev/genclass-plugin) through + its daily sync. Claude Code (2.1.311) installs what a plugin's lockfile lists when it installs the plugin + (`npm ci --ignore-scripts --workspaces=false --no-audit --no-fund` in the plugin's cache folder, so + `node_modules/@genclass/mcp` appears there); the launchers still run `npx -y @genclass/mcp@`. + `test/manifests.test.ts` keeps both files at `package.json`'s version and requires the integrity. Only + `npm install --package-lock-only --ignore-scripts` in `plugin/` writes the lock, and only once npm serves the + version, so a release is: bump, `npm run sync -w @genclass/mcp`, tag; after the publish, the same sync again (it + regenerates the lock from npm) and a commit. In between, the test accepts the previous release's lock while npm + does not serve the new version (it asks `npm view` in that state only). - **The plugin has a repository of its own, [genclass-dev/genclass-plugin](https://github.com/genclass-dev/genclass-plugin)**: the plugin folder at the root, with marketplace files for Claude Code (`.claude-plugin/marketplace.json`), Codex (`.agents/plugins/marketplace.json`) and Cursor, the Gemini CLI manifest and the licence, assembled by diff --git a/packages/mcp/plugin/README.md b/packages/mcp/plugin/README.md index 4fb59df..82dafaf 100644 --- a/packages/mcp/plugin/README.md +++ b/packages/mcp/plugin/README.md @@ -32,6 +32,9 @@ page of the dev server. The page side needs `@genclass/runtime` 0.3.0-beta.1 or | skill `genclass` | `skills/genclass/SKILL.md` | the procedure once Claude reaches for the tools (the explore arguments and Playwright pairing, how to read a report, what to say when done, what to do when the tools are missing); its description carries the triggers (an intermittent frontend bug; after changing code that fetches, submits, autosaves, polls or debounces; before calling a UI change done), as do the server's instructions. Without the plugin, `npx -y @genclass/mcp@0.2.1 init` writes the same file to `.claude/skills/genclass/SKILL.md` | The launcher is pinned to the plugin's version, so the plugin runs the exact release it was reviewed with. +`package.json` and `package-lock.json` next to `.mcp.json` record what that launcher resolves to (the version, its +registry tarball and its sha512 integrity), for the directory's checks and for a Claude Code that installs a plugin's +lockfile (`npm ci --ignore-scripts`) when it installs the plugin. `plugin.json` and `mcp.json` at this folder's root are the same server in the [Agent Plugins](https://agent-plugins.org) format, for Cursor, Codex and other clients that read it (server and skill, no hook). diff --git a/packages/mcp/plugin/package-lock.json b/packages/mcp/plugin/package-lock.json new file mode 100644 index 0000000..4dc6fb5 --- /dev/null +++ b/packages/mcp/plugin/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "genclass-plugin", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "genclass-plugin", + "license": "Apache-2.0", + "dependencies": { + "@genclass/mcp": "0.2.1" + } + }, + "node_modules/@genclass/mcp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@genclass/mcp/-/mcp-0.2.1.tgz", + "integrity": "sha512-Y/Rb96g0JI/uWeYIuEUMTiBNduNc1XeCAw101x+/SkcAQawAo+MhdsDCwuYIMGrPDBrzkV5sP8bdow538iCLvQ==", + "license": "Apache-2.0", + "bin": { + "genclass-mcp": "bin/genclass-mcp.mjs" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/packages/mcp/plugin/package.json b/packages/mcp/plugin/package.json new file mode 100644 index 0000000..2bd3f89 --- /dev/null +++ b/packages/mcp/plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "genclass-plugin", + "private": true, + "description": "The launcher's lockfile. .mcp.json and hooks/hooks.json run npx -y @genclass/mcp@; package-lock.json next to this file records what that resolves to (exact version, registry source, integrity), which Anthropic's plugin directory reads. Nothing installs this package: the plugin runs the server through npx.", + "license": "Apache-2.0", + "dependencies": { + "@genclass/mcp": "0.2.1" + } +} diff --git a/packages/mcp/test/manifests.test.ts b/packages/mcp/test/manifests.test.ts index 123b4dc..c4d3a02 100644 --- a/packages/mcp/test/manifests.test.ts +++ b/packages/mcp/test/manifests.test.ts @@ -5,12 +5,21 @@ // keeps the repo-root copy of the skill (skills/genclass/SKILL.md, what skill directories such as skills.sh read) // byte-identical to the plugin's. The second describe checks the skill and the server's instructions: the // instructions carry the triggers, the skill the procedure (docs/runtime/AGENT.md section 4.2). +// The plugin folder also ships the launcher's lockfile for Anthropic's directory (its "Launcher lock missing" warning: +// a package.json and package-lock.json next to the manifest that declares the server, with exact versions, registry +// sources and hashes): plugin/package.json names @genclass/mcp at package.json's version, plugin/package-lock.json +// what npm resolved that to. Only `npm install --package-lock-only --ignore-scripts` in packages/mcp/plugin writes the +// lock, and only once npm serves the version, so a version bump leaves it at the previous release until the new one +// is published: the lock pin allows that while npm does not serve package.json's version (asked with `npm view` in +// that state only; an unreachable npm counts as unknown, and the lagging lock passes with a note that vitest shows +// with --silent=false), fails once npm serves it, and the sync regenerates the lock with that npm command. A release +// is therefore: bump, sync, tag; after the publish, sync again and commit. import { execFileSync } from "node:child_process"; -import { lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLIENTS, hookCommand, installMarkdown, pinned, serverConfig } from "../src/clients.js"; +import { CLIENTS, PACKAGE, hookCommand, installMarkdown, pinned, serverConfig } from "../src/clients.js"; import { VERSION } from "../src/http.js"; import { INSTRUCTIONS, TOOLS } from "../src/tools.js"; @@ -22,11 +31,13 @@ const SYNC = process.env.GENCLASS_SYNC === "1"; type Json = Record; -/** A file and what it must hold for version V: a JSON edit (compared as data) or a text edit (compared as text). */ +/** A file and what it must hold for version V: a JSON edit (compared as data), a text edit (compared as text), or + * the launcher's lockfile, which only npm writes (checkLock; the header says when it may lag). */ interface Pin { file: string; json?: (cur: Json) => Json; text?: (cur: string) => string; + lock?: true; } /** `@genclass/mcp@` in prose and code blocks. */ @@ -57,6 +68,9 @@ const PINS: Pin[] = [ file: "packages/mcp/plugin/hooks/hooks.json", json: (j) => ({ ...j, hooks: { ...j.hooks, UserPromptSubmit: j.hooks.UserPromptSubmit.map((g: Json) => ({ ...g, hooks: g.hooks.map((h: Json) => (/@genclass\/mcp\b/.test(h.command) ? { ...h, command: hookCommand(V) } : h)) })) } }), }, + // the launcher's lockfile (see the header): package.json names the version, the lock what npm resolved it to + { file: "packages/mcp/plugin/package.json", json: (j) => ({ ...j, dependencies: { ...j.dependencies, [PACKAGE]: V } }) }, + { file: "packages/mcp/plugin/package-lock.json", lock: true }, { file: "packages/mcp/plugin/plugin.json", json: (j) => ({ ...j, version: V }) }, { file: "packages/mcp/plugin/mcp.json", json: (j) => ({ ...j, mcpServers: { ...j.mcpServers, genclass: { type: "stdio", ...serverConfig(V) } } }) }, { file: ".cursor-plugin/marketplace.json", json: (j) => ({ ...j, plugins: j.plugins.map((p: Json) => (p.name === "genclass" ? { ...p, version: V } : p)) }) }, @@ -77,9 +91,83 @@ function read(file: string): string { return readFileSync(join(repo, file), "utf8"); } +const PLUGIN_DIR = join(pkgDir, "plugin"); +const REGISTRY = "https://registry.npmjs.org/"; +const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; +/** npm's codes for a version the registry does not serve (`npm view`: E404; `npm install`: ETARGET). */ +const UNPUBLISHED = new Set(["E404", "ETARGET"]); + +/** Runs npm in `cwd` with its output captured: `code` is "ok", npm's error code, or "unknown" when it gave none. */ +function npm(args: string[], cwd: string): { code: string; out: string } { + try { + return { code: "ok", out: execFileSync("npm", args, { cwd, stdio: "pipe", timeout: 60_000, shell: process.platform === "win32" }).toString() }; + } catch (e) { + const err = e as { stdout?: Buffer; stderr?: Buffer; message: string }; + const out = `${err.stdout ?? ""}${err.stderr ?? ""}` || err.message; + return { code: /(?:npm (?:error|ERR!) code |"code":\s*")(E[A-Z0-9_]+)/.exec(out)?.[1] ?? "unknown", out }; + } +} + +/** Whether npm serves @genclass/mcp at `v`; null when npm could not say (unreachable, for one). */ +function npmServes(v: string): boolean | null { + const r = npm(["view", `${PACKAGE}@${v}`, "version", "--json"], PLUGIN_DIR); + if (r.code === "ok") return r.out.includes(`"${v}"`); + if (UNPUBLISHED.has(r.code)) return false; + console.warn(`manifests.test: npm could not say whether ${PACKAGE}@${v} is published (${r.code}), so the plugin's lockfile is checked at its own version only\n${r.out}`); + return null; +} + +/** What the lock must hold at version `v` (the directory's words: exact versions, registry sources and hashes): + * lockfileVersion 3, the root's one dependency on @genclass/mcp at v, that entry resolved to v's registry tarball, and + * every entry with an exact version, a registry source and a sha512 integrity. The problems; none when it holds. */ +function lockProblems(lock: Json, v: string): string[] { + const out: string[] = []; + if (lock.lockfileVersion !== 3) out.push(`lockfileVersion ${JSON.stringify(lock.lockfileVersion)}, want 3`); + const deps = lock.packages?.[""]?.dependencies; + if (JSON.stringify(deps) !== JSON.stringify({ [PACKAGE]: v })) out.push(`root dependencies ${JSON.stringify(deps)}, want ${PACKAGE} at ${v}`); + const main = `node_modules/${PACKAGE}`; + const entries = Object.entries(lock.packages ?? {}).filter(([k]) => k !== "") as [string, Json][]; + if (!entries.some(([k]) => k === main)) out.push(`no ${main} entry`); + for (const [k, e] of entries) { + if (!EXACT.test(String(e.version))) out.push(`${k}: version ${JSON.stringify(e.version)} is not exact`); + if (typeof e.resolved !== "string" || !e.resolved.startsWith(REGISTRY)) out.push(`${k}: resolved ${JSON.stringify(e.resolved)} is not a registry source`); + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(e.integrity))) out.push(`${k}: no sha512 integrity`); + } + const m = lock.packages?.[main]; + if (m && m.version !== v) out.push(`${main}: version ${m.version}, want ${v}`); + const tarball = `${REGISTRY}${PACKAGE}/-/${PACKAGE.split("/")[1]}-${v}.tgz`; + if (m && m.resolved !== tarball) out.push(`${main}: resolved ${m.resolved}, want ${tarball}`); + return out; +} + +/** The lock pin: at V, or, between a version bump and its release while npm does not serve V, at the previous release + * and consistent at that version. With GENCLASS_SYNC=1 a lock that is not at V is regenerated by npm first. */ +function checkLock(file: string): void { + const path = join(repo, file); + const lock = (): Json => (existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {}); + let problems = lockProblems(lock(), V); + let unpublished = false; + if (problems.length && SYNC) { + const r = npm(["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"], PLUGIN_DIR); + if (r.code === "ok") problems = lockProblems(lock(), V); + else if (UNPUBLISHED.has(r.code)) unpublished = true; + else throw new Error(`npm could not regenerate ${file} (${r.code}):\n${r.out}`); + } + if (problems.length) { + const own = lock().packages?.[`node_modules/${PACKAGE}`]?.version as string | undefined; + if (own && own !== V && EXACT.test(own) && (unpublished || npmServes(V) !== true)) { + console.warn(`manifests.test: ${file} is at ${own} while npm does not serve ${PACKAGE}@${V}; after the release, npm run sync -w @genclass/mcp regenerates it`); + expect(lockProblems(lock(), own), `${file} at ${own}`).toEqual([]); + return; + } + } + expect(problems, `${file}: npm install --package-lock-only --ignore-scripts in packages/mcp/plugin writes it (npm run sync -w @genclass/mcp does that once npm serves ${PACKAGE}@${V})`).toEqual([]); +} + describe("versions and pinned launchers (every manifest names package.json's version)", () => { for (const p of PINS) { it(p.file, () => { + if (p.lock) return checkLock(p.file); const cur = read(p.file); if (p.json) { const want = p.json(JSON.parse(cur)); @@ -90,7 +178,7 @@ describe("versions and pinned launchers (every manifest names package.json's ver if (SYNC && want !== cur) writeFileSync(join(repo, p.file), want); else expect(cur).toBe(want); } - }); + }, p.lock ? 120_000 : undefined); // the lock pin may run npm twice } it("the server reports package.json's version", () => {