diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 1e90d221e..71cf5d45c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -16,17 +16,7 @@ "mcp-server" ], "skills": "./skills", - "mcpServers": { - "rea": { - "command": "node", - "args": [ - "./scripts/plugin-mcp-launcher.mjs", - "rea-agents@6.4.0" - ], - "startup_timeout_sec": 30, - "cwd": "." - } - }, + "mcpServers": "./.mcp.json", "interface": { "displayName": "REA", "shortDescription": "Reverse engineer anything with agents", diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..8463e6859 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "rea": { + "command": "node", + "args": [ + "./scripts/plugin-mcp-launcher.mjs", + "rea-agents@6.4.0" + ], + "cwd": "." + } + } +} diff --git a/docs/releasing.md b/docs/releasing.md index 4e651dd2e..192378cd5 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -242,7 +242,9 @@ tag remains in main's ancestry. Keep the released tag unchanged. The synchronization must include `.release-please-manifest.json`, `package.json`, both root versions in `package-lock.json`, `CHANGELOG.md`, `server.json`, both plugin manifests (`.claude-plugin/plugin.json` and -`.codex-plugin/plugin.json`), and the versioned documentation examples. Run +`.codex-plugin/plugin.json`), the Codex server declaration (`.mcp.json`), both +marketplaces (`.claude-plugin/marketplace.json` and +`.agents/plugins/marketplace.json`), and the versioned documentation examples. Run `npm run docs:generate`, `npm run docs:check`, and the release configuration tests against the combined tree. After the merge, verify that the released tag is an ancestor of main: diff --git a/package.json b/package.json index bcfa09f3e..d7795a3e2 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "scripts/prepare.mjs", ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", + ".mcp.json", "skills", "README.md", "DISCLAIMER.md", diff --git a/release-please-config.json b/release-please-config.json index 5543ecc6d..133b81777 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -49,7 +49,7 @@ }, { "type": "json", - "path": ".codex-plugin/plugin.json", + "path": ".mcp.json", "jsonpath": "$.mcpServers.rea.args[1]" }, { diff --git a/scripts/plugin-mcp-launcher.mjs b/scripts/plugin-mcp-launcher.mjs index 71198caf9..f75935faa 100644 --- a/scripts/plugin-mcp-launcher.mjs +++ b/scripts/plugin-mcp-launcher.mjs @@ -1,9 +1,10 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { access, mkdtemp, rm } from "node:fs/promises"; import { constants, tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { packageRunnerCommand } from "./lib/packageRunnerCommand.mjs"; @@ -26,6 +27,32 @@ if ( } async function launch(packageSpecifier) { + // npm installs already carry the release-pinned runtime. Run it in this + // process so the plugin keeps the caller's working directory and the + // runtime retains ownership of provider and worker cleanup. + const { default: bundledPackage } = await import("../package.json", { + with: { type: "json" }, + }); + let bundledRuntimeMatchesPin = + packageSpecifier === `${bundledPackage.name}@${bundledPackage.version}`; + if (bundledRuntimeMatchesPin) { + try { + await access(new URL("../dist/mcp/main.js", import.meta.url)); + } catch (cause) { + if (!isMissing(cause)) throw cause; + bundledRuntimeMatchesPin = false; + } + } + if (bundledRuntimeMatchesPin) { + process.argv = [ + process.execPath, + fileURLToPath(new URL("./rea.mjs", import.meta.url)), + "mcp", + ]; + await import("./rea.mjs"); + return; + } + const [command, ...args] = packageRunnerCommand( "npx", packageSpecifier, @@ -93,3 +120,12 @@ async function launch(packageSpecifier) { } }); } + +function isMissing(cause) { + return ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ); +} diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 668a22430..6dda321a9 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -71,21 +71,38 @@ export async function verifyPackagePack({ root, workspace }) { const manifest = JSON.parse( (await exec("tar", ["-xOf", tarball, `package/${path}`])).stdout, ); + const serverConfig = path.startsWith(".codex") + ? JSON.parse( + ( + await exec("tar", [ + "-xOf", + tarball, + "package/.mcp.json", + ]) + ).stdout, + ) + : manifest; + const expectedArgs = [ + `${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`, + `${packedManifest.name}@${packedManifest.version}`, + ]; if ( manifest.version !== packedManifest.version || manifest.skills !== "./skills" || - manifest.mcpServers?.rea?.command !== "node" || - (path.startsWith(".codex") && manifest.mcpServers?.rea?.cwd !== ".") || - JSON.stringify(manifest.mcpServers?.rea?.args) !== - JSON.stringify([ - `${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`, - `${packedManifest.name}@${packedManifest.version}`, - ]) + (path.startsWith(".codex") + ? manifest.mcpServers !== "./.mcp.json" || + serverConfig.mcpServers?.rea?.cwd !== "." + : false) || + serverConfig.mcpServers?.rea?.command !== "node" || + JSON.stringify(serverConfig.mcpServers?.rea?.args) !== + JSON.stringify(expectedArgs) ) throw new Error( `Packaged plugin ${path} does not match the exact runtime and canonical skill release`, ); } + if (!packedFiles.includes("package/.mcp.json")) + throw new Error("Packaged Codex MCP server declaration is missing"); for (const path of [ "scripts/plugin-mcp-launcher.mjs", "scripts/lib/packageRunnerCommand.mjs", diff --git a/tests/boundary/process/pluginMcpLauncher.test.ts b/tests/boundary/process/pluginMcpLauncher.test.ts index 9f47a1a7a..093425e7d 100644 --- a/tests/boundary/process/pluginMcpLauncher.test.ts +++ b/tests/boundary/process/pluginMcpLauncher.test.ts @@ -10,7 +10,7 @@ const launcher = fileURLToPath( new URL("../../../scripts/plugin-mcp-launcher.mjs", import.meta.url), ); -const start = (path: string, packageSpecifier = "rea-agents@6.4.0") => { +const start = (path: string, packageSpecifier = "rea-agents@6.4.0-rc.1") => { const child = spawn(process.execPath, [launcher, packageSpecifier], { env: { ...process.env, diff --git a/tests/conformance/releaseConfiguration.test.ts b/tests/conformance/releaseConfiguration.test.ts index 65f785929..8bb6687d1 100644 --- a/tests/conformance/releaseConfiguration.test.ts +++ b/tests/conformance/releaseConfiguration.test.ts @@ -20,6 +20,7 @@ const pluginManifests = [ ".claude-plugin/plugin.json", ".codex-plugin/plugin.json", ] as const; +const pluginServerConfig = ".mcp.json"; describe("release configuration", () => { it("keeps doctor and setup runtime support aligned with package engines", async () => { @@ -54,13 +55,16 @@ describe("release configuration", () => { path: "server.json", jsonpath: "$.packages[0].version", }, - ...pluginManifests.flatMap((path) => - ["$.version", "$.mcpServers.rea.args[1]"].map((jsonpath) => ({ - type: "json", - path, - jsonpath, - })), - ), + ...pluginManifests.map((path) => ({ + type: "json", + path, + jsonpath: "$.version", + })), + { + type: "json", + path: pluginServerConfig, + jsonpath: "$.mcpServers.rea.args[1]", + }, ...[ ".claude-plugin/marketplace.json", ".agents/plugins/marketplace.json", @@ -164,7 +168,11 @@ describe("plugin manifests", () => { files: string[]; }; expect(pkg.files).toEqual( - expect.arrayContaining([...pluginManifests, "skills"]), + expect.arrayContaining([ + ...pluginManifests, + pluginServerConfig, + "skills", + ]), ); for (const path of pluginManifests) expect(await readJson(path)).toMatchObject({ skills: "./skills" }); @@ -186,7 +194,7 @@ describe("plugin manifests", () => { }); it.each(pluginManifests)( - "pins the plugin manifest %s and its server launch to the package version", + "pins the plugin manifest %s and Codex's server config path", async (path) => { const packageJson = (await readJson("package.json")) as { name: string; @@ -195,20 +203,33 @@ describe("plugin manifests", () => { expect(await readJson(path)).toMatchObject({ version: packageJson.version, - mcpServers: { - rea: { - command: "node", - args: [ - `${path.startsWith(".claude") ? "${CLAUDE_PLUGIN_ROOT}/" : "./"}scripts/plugin-mcp-launcher.mjs`, - `${packageJson.name}@${packageJson.version}`, - ], - ...(path.startsWith(".codex") ? { cwd: "." } : {}), - }, - }, + ...(path.startsWith(".codex") + ? { mcpServers: `./${pluginServerConfig}` } + : {}), }); }, ); + it("pins the plugin server launch from the shared MCP declaration", async () => { + const packageJson = (await readJson("package.json")) as { + name: string; + version: string; + }; + + expect(await readJson(pluginServerConfig)).toMatchObject({ + mcpServers: { + rea: { + command: "node", + args: [ + "./scripts/plugin-mcp-launcher.mjs", + `${packageJson.name}@${packageJson.version}`, + ], + cwd: ".", + }, + }, + }); + }); + it.each(pluginManifests)( "stores %s in the form Release Please rewrites it to", async (path) => {