diff --git a/README.md b/README.md index 6c069bd..1cbe5c9 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,8 @@ A target can emit a source plugin directly, rename it, or merge multiple source A source plugin declares MCP servers with a standard `.mcp.json` file at its root (`{ "mcpServers": { "name": { ... } } }`), or with an `mcpServers` key in `plugin.pluginpack.json`. The file wins if both are present, and merging plugins with the same server name is an error. +The `.mcp.json` file form supports per-target overrides: a `targets//.mcp.json` file next to the base wins for that host only. This lets one source ship different server definitions per app (for example, a `${CLAUDE_PLUGIN_ROOT}/start.mjs` invocation for Claude and a `cwd: "."` + `./start.mjs` invocation for Codex). The manifest (`mcpServers` in `plugin.pluginpack.json`) form has no per-file override — authors who need per-target MCP config should use the file form. + Each target wires that MCP config into its native shape: | Target | How MCP is wired | @@ -303,7 +305,23 @@ skills/release-notes/targets/cursor/SKILL.md skills/release-notes/targets/claude/SKILL.md ``` -Resolution order is target override first, then the base file. +Resolution order is target override first, then the base file. The same override mechanism applies to static files (README/CHANGELOG/LICENSE) and to MCP config (`.mcp.json`) and declared plugin-root `files`. + +## Plugin-Root Files + +A source plugin that needs files at its emitted root beyond component and static files — a bundled MCP server, a launcher script, or a `package.json` to set Node's module type — declares them in `plugin.pluginpack.json`: + +```json +{ + "files": { + "dist/index.js": "dist/index.js", + "start.mjs": "start.mjs", + "package.json": "package.json" + } +} +``` + +The map is destination (emitted plugin root relative) -> source (source plugin relative). Files are emitted verbatim at the plugin root, are tracked as managed output, and support target overrides: a `targets//` file wins for that host. A destination that collides with a component or static file is an error, and `pluginpack` does not build bundles — produce build output before running `pluginpack build` so the declared source paths exist. ## Other Shapes diff --git a/src/render.ts b/src/render.ts index 27d992f..c144b86 100644 --- a/src/render.ts +++ b/src/render.ts @@ -33,6 +33,7 @@ export async function collectPluginFiles( export async function resolveMcpServers( project: ResolvedProject, sourceIds: string[], + target: TargetName, ): Promise | undefined> { const merged: Record = {}; let found = false; @@ -40,7 +41,7 @@ export async function resolveMcpServers( if (!project.plugins.has(sourceId)) { continue; } - const servers = await project.source.readMcpServers(sourceId); + const servers = await project.source.readMcpServers(sourceId, target); if (!servers) { continue; } diff --git a/src/schema.ts b/src/schema.ts index 88ce225..4baa614 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -89,6 +89,11 @@ const sourcePluginManifestSchema = metadataSchema.extend({ name: z.string().optional(), description: z.string().optional(), mcpServers: z.record(z.string(), z.unknown()).optional(), + // Arbitrary files emitted verbatim at the emitted plugin's root, in addition + // to its component and static files. Map of destination (plugin-root + // relative) -> source (source-plugin relative). Supports target overrides: + // a targets// file wins for that host. + files: z.record(safeRelativePath, safeRelativePath).optional(), }); export { configSchema, sourcePluginManifestSchema }; diff --git a/src/source.ts b/src/source.ts index 04ea5b8..005c27f 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { componentDirs, staticFiles } from "./components.js"; -import { exists, toPosix, walkFiles } from "./fs.js"; +import { exists, isSafeRelativePath, toPosix, walkFiles } from "./fs.js"; import type { FileValue, SourcePlugin, @@ -19,8 +19,8 @@ export function createFilesystemSourceProvider( return { readPluginFiles: (pluginId, target) => readPluginFiles(pluginOrThrow(plugins, pluginId), target), - readMcpServers: (pluginId) => - readMcpServers(pluginOrThrow(plugins, pluginId)), + readMcpServers: (pluginId, target) => + readMcpServers(pluginOrThrow(plugins, pluginId), target), }; } @@ -70,6 +70,37 @@ async function readPluginFiles( files.set(fileName, await fs.readFile(resolved)); } } + + // Arbitrary files the source plugin declares in plugin.pluginpack.json + // (e.g. a bundled server, launcher, or a package.json). Emitted verbatim at + // the plugin root, with target overrides on the source path. + const declaredFiles = plugin.manifest.files; + if (declaredFiles) { + for (const [dest, source] of Object.entries(declaredFiles)) { + const destPath = toPosix(dest); + if (!isSafeRelativePath(destPath)) { + throw new Error( + `Source plugin "${plugin.id}" files destination "${dest}" must be a safe relative path.`, + ); + } + if (files.has(destPath)) { + throw new Error( + `Source plugin "${plugin.id}" files destination "${dest}" collides with another emitted file.`, + ); + } + const resolved = await resolveTargetOverride( + plugin.dir, + path.resolve(plugin.dir, source), + target, + ); + if (!(await exists(resolved))) { + throw new Error( + `Source plugin "${plugin.id}" files source "${source}" could not be read.`, + ); + } + files.set(destPath, await fs.readFile(resolved)); + } + } return files; } @@ -101,18 +132,26 @@ async function resolveTargetOverride( // A source plugin declares MCP servers via a .mcp.json file (standard // { mcpServers: {...} } shape) or an mcpServers key in plugin.pluginpack.json. -// The file takes precedence when both are present. +// The file takes precedence when both are present. The file form supports +// per-target overrides: targets//.mcp.json wins for that host. The +// manifest form has no per-file override; authors who need per-target MCP +// config should use the .mcp.json file form. async function readMcpServers( plugin: SourcePlugin, + target: TargetName, ): Promise | undefined> { - const filePath = path.join(plugin.dir, ".mcp.json"); - if (await exists(filePath)) { + const resolved = await resolveTargetOverride( + plugin.dir, + path.join(plugin.dir, ".mcp.json"), + target, + ); + if (await exists(resolved)) { let parsed: unknown; try { - parsed = JSON.parse(await fs.readFile(filePath, "utf8")); + parsed = JSON.parse(await fs.readFile(resolved, "utf8")); } catch (error) { throw new Error( - `Invalid JSON in ${filePath}: ${(error as Error).message}`, + `Invalid JSON in ${resolved}: ${(error as Error).message}`, ); } const servers = (parsed as { mcpServers?: unknown }).mcpServers; diff --git a/src/targets.ts b/src/targets.ts index 82e06e6..8ef3207 100644 --- a/src/targets.ts +++ b/src/targets.ts @@ -108,7 +108,11 @@ async function emitPlugins( files.set(toPosix(path.join(pluginPath, relativePath)), value); } - const mcpServers = await resolveMcpServers(project, pluginConfig.from); + const mcpServers = await resolveMcpServers( + project, + pluginConfig.from, + target, + ); if (mcpServers && options.mcp === "file") { files.set( toPosix(path.join(pluginPath, ".mcp.json")), diff --git a/src/types.ts b/src/types.ts index 066eeb8..c832430 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,7 @@ export interface SourceProvider { ): Promise>; readMcpServers( pluginId: string, + target: TargetName, ): Promise | undefined>; } diff --git a/tests/core.test.ts b/tests/core.test.ts index 041fc76..1fdfe63 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -721,6 +721,199 @@ export default defineConfig({ ); }); + it("overrides MCP config per target from targets//.mcp.json", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "mcp-override-plugins", + version: "1.0.0", + metadata: { description: "MCP override", author: { name: "X" }, license: "MIT" }, + targets: { + cursor: { outDir: "dist/cursor", plugins: { filed: { from: ["filed"], components: ["skills"] } } }, + claude: { outDir: "dist/claude", plugins: { filed: { from: ["filed"] } } } + } +}); +`, + plugins: { + filed: { + ".mcp.json": `${JSON.stringify( + { mcpServers: { glean: { command: "node", args: ["start.mjs"] } } }, + null, + 2, + )}\n`, + targets: { + cursor: { + ".mcp.json": `${JSON.stringify( + { + mcpServers: { + "glean-local": { + command: "node", + args: ["./start.mjs"], + cwd: ".", + }, + }, + }, + null, + 2, + )}\n`, + }, + }, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + // cursor picks up the target override (different server name + args) + const cursorMcp = JSON.parse( + await readFile(path.join(root, "dist/cursor/filed/.mcp.json"), "utf8"), + ) as { mcpServers: Record }; + expect(cursorMcp.mcpServers).toMatchObject({ + "glean-local": { command: "node", args: ["./start.mjs"], cwd: "." }, + }); + expect(cursorMcp.mcpServers).not.toHaveProperty("glean"); + + // claude keeps the base .mcp.json (no override present for claude) + const claudeMcp = JSON.parse( + await readFile( + path.join(root, "dist/claude/plugins/filed/.mcp.json"), + "utf8", + ), + ) as { mcpServers: Record }; + expect(claudeMcp.mcpServers).toMatchObject({ + glean: { command: "node", args: ["start.mjs"] }, + }); + expect(claudeMcp.mcpServers).not.toHaveProperty("glean-local"); + }); + + it("emits arbitrary plugin-root files declared in plugin.pluginpack.json", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-plugins", + version: "1.0.0", + metadata: { description: "Files", author: { name: "X" }, license: "MIT" }, + targets: { + cursor: { outDir: "dist/cursor", plugins: { srv: { from: ["srv"], components: ["skills"] } } }, + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + files: { + "dist/index.js": "dist/index.js", + "start.mjs": "start.mjs", + "package.json": "package.json", + }, + })}\n`, + "start.mjs": "import './dist/index.js';\n", + "package.json": `{ "name": "srv", "type": "module" }\n`, + dist: { "index.js": "console.log('bundle');\n" }, + targets: { + cursor: { "start.mjs": "import './dist/index.js'; // cursor\n" }, + }, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root }); + + // cursor plugin root carries the declared files verbatim + await expect( + readFile(path.join(root, "dist/cursor/srv/dist/index.js"), "utf8"), + ).resolves.toContain("bundle"); + await expect( + readFile(path.join(root, "dist/cursor/srv/package.json"), "utf8"), + ).resolves.toContain('"type": "module"'); + // target override applies to a declared file's source + await expect( + readFile(path.join(root, "dist/cursor/srv/start.mjs"), "utf8"), + ).resolves.toContain("// cursor"); + + // claude keeps the base start.mjs (no override) + await expect( + readFile(path.join(root, "dist/claude/plugins/srv/start.mjs"), "utf8"), + ).resolves.not.toContain("// cursor"); + + // declared files are managed (tracked in the managed manifest) + const manifest = JSON.parse( + await readFile( + path.join(root, "dist/cursor/.pluginpack/cursor.json"), + "utf8", + ), + ) as { files: string[] }; + expect(manifest.files).toContain("srv/dist/index.js"); + expect(manifest.files).toContain("srv/package.json"); + expect(manifest.files).toContain("srv/start.mjs"); + }); + + it("rejects a plugin-root files destination that collides with a component file", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-collide-plugins", + version: "1.0.0", + metadata: { description: "Collide", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + files: { "skills/s1/SKILL.md": "extra.md" }, + })}\n`, + "extra.md": "# extra\n", + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /files destination "skills\/s1\/SKILL.md" collides with another emitted file/, + ); + }); + + it("rejects a plugin-root files source that cannot be read", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "files-missing-plugins", + version: "1.0.0", + metadata: { description: "Missing", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { outDir: "dist/claude", plugins: { srv: { from: ["srv"] } } } + } +}); +`, + plugins: { + srv: { + "plugin.pluginpack.json": `${JSON.stringify({ + files: { "start.mjs": "start.mjs" }, + })}\n`, + skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( + /files source "start.mjs" could not be read/, + ); + }); + it("detects cross-target output path collisions", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}";