Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions docs/adoption/listings/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<version>`.
`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
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp/plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
26 changes: 26 additions & 0 deletions packages/mcp/plugin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions packages/mcp/plugin/package.json
Original file line number Diff line number Diff line change
@@ -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@<version>; 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"
}
}
96 changes: 92 additions & 4 deletions packages/mcp/test/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -22,11 +31,13 @@ const SYNC = process.env.GENCLASS_SYNC === "1";

type Json = Record<string, any>;

/** 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@<any version>` in prose and code blocks. */
Expand Down Expand Up @@ -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)) }) },
Expand All @@ -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));
Expand All @@ -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", () => {
Expand Down
Loading