From 14c2cf099dc1c5374b54c9024b43a0c891aa5f78 Mon Sep 17 00:00:00 2001 From: Karan Date: Wed, 28 Oct 2026 12:54:47 -0400 Subject: [PATCH] @genclass/mcp: the plugin ships the launcher's lockfile (plugin/package.json, package-lock.json) Anthropic's directory validator warned "Launcher lock missing" for mcpServers.genclass in .mcp.json (2026-10-28, genclass-dev/genclass-plugin main @ e911b25): a package-lock.json next to the manifest that declares the server, with exact versions, registry sources and hashes, is required for the Verified badge, and its hold on pinned npx launchers prefers a package that ships a lockfile. - plugin/package.json: private, one dependency, @genclass/mcp at package.json's version. - plugin/package-lock.json: lockfileVersion 3, from `npm install --package-lock-only --ignore-scripts` in the plugin folder (the version, its registry tarball, its sha512 integrity; 659 bytes). No node_modules committed; the root package-lock.json is untouched. The daily sync carries both to genclass-plugin. - test/manifests.test.ts: package.json is a JSON pin; the lock is a lock pin (lockfileVersion 3, the root dependency and the entry at the version, resolved to the registry tarball, every entry with an exact version, a registry source and a sha512 integrity; a missing integrity fails, never rewrites). Only npm writes the lock, and only once npm serves the version, while release-mcp.yml runs the tests at the tag before publishing: between a bump and its release the lock may stay at the previous release, consistent at its own version, while npm does not serve the new one (npm view, in that state only); once npm serves it the test fails until the lock is regenerated. The sync regenerates it with the npm command. - Claude Code 2.1.311 installs a plugin's lockfile when it installs the plugin (npm ci --ignore-scripts --workspaces=false --no-audit --no-fund in the plugin's cache folder); the launchers still run npx. - CHANGELOG (Unreleased), docs/adoption/listings/mcp.md section 3, one sentence in the plugin README. Co-Authored-By: Claude Fable 5.1 --- docs/adoption/listings/mcp.md | 25 +++++-- packages/mcp/CHANGELOG.md | 14 ++++ packages/mcp/plugin/README.md | 3 + packages/mcp/plugin/package-lock.json | 26 ++++++++ packages/mcp/plugin/package.json | 9 +++ packages/mcp/test/manifests.test.ts | 96 +++++++++++++++++++++++++-- 6 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 packages/mcp/plugin/package-lock.json create mode 100644 packages/mcp/plugin/package.json 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", () => {