From 444d6fe24b761ae6bd33de25672014bca4ad442c Mon Sep 17 00:00:00 2001 From: Eshwar Sundar Date: Mon, 27 Jul 2026 16:29:49 +0530 Subject: [PATCH 1/2] feat: per-target MCP config overrides + plugin-root files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two capabilities that let a local MCP-server plugin (e.g. glean-vnext) live as a normal source plugin and be folded into an emitted plugin: 1. Per-target MCP config override — target is threaded through SourceProvider.readMcpServers -> readMcpServers (applies resolveTargetOverride to .mcp.json) -> resolveMcpServers -> targets/engine.ts. So targets//.mcp.json wins for that host only; the manifest mcpServers form has no per-file override. 2. Plugin-root files — files: record(safeRelativePath, safeRelativePath) on the source-plugin manifest; emitted verbatim at the plugin root in readPluginFiles with target overrides on the source path, tracked as managed output, with collision / unsafe-path / missing-source guards. Rebased onto the 0.8.0 target-registry refactor (src/targets.ts -> src/targets/). npm run test:all green (80 tests: format/lint/typecheck/ build/docs), incl. 4 new tests: per-target MCP override, plugin-root files, collision guard, missing-source guard. --- README.md | 20 ++++- src/render.ts | 3 +- src/schema.ts | 5 ++ src/source.ts | 55 ++++++++++-- src/targets/engine.ts | 6 +- src/types.ts | 1 + tests/core.test.ts | 193 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index de34f87..35d7586 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 | @@ -338,7 +340,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 c8a8909..4d756c9 100644 --- a/src/render.ts +++ b/src/render.ts @@ -35,6 +35,7 @@ export async function collectPluginFiles( export async function resolveMcpServers( project: ResolvedProject, sourceIds: string[], + target: TargetName, ): Promise | undefined> { const merged: Record = {}; let found = false; @@ -42,7 +43,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 93e29a3..1a0301a 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -124,6 +124,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 7a20eb8..c15e8c2 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, @@ -21,8 +21,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), }; } @@ -72,6 +72,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; } @@ -103,18 +134,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}`, { cause: error }, ); } diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 0ea1514..962d63d 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -131,7 +131,11 @@ export async function emitFromDefinition( files.set(toPosix(definition.hooksPath(pluginPath)), sourceHooksFile); } - const mcpServers = await resolveMcpServers(project, pluginConfig.from); + const mcpServers = await resolveMcpServers( + project, + pluginConfig.from, + target, + ); const mcpConfigPath = definition.mcpConfigPath(pluginPath); if (mcpServers && mcpConfigPath) { files.set(toPosix(mcpConfigPath), json({ mcpServers })); diff --git a/src/types.ts b/src/types.ts index 9290a00..0ba42aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,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 c892e5a..e42aeeb 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1338,6 +1338,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("generates the update-check hook for claude and cursor", async () => { const project = await fixtureProject({ "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; From e3fda9b0ad68649304bf9067232698d921e13c85 Mon Sep 17 00:00:00 2001 From: Eshwar Sundar Date: Mon, 27 Jul 2026 22:27:05 +0530 Subject: [PATCH 2/2] refactor: rename source-plugin manifest `files` -> `additionalFiles` (review) Addresses review on #11: - Rename the manifest field to additionalFiles: it reads clearly as files added on top of the component/static files pluginpack supports by default, vs. the generic "files". - Drop the inline schema comment; document the field in the README public API section ("Additional Plugin-Root Files") instead. Updates source.ts (field read + error messages), tests, and README. npm run test:all green (80 tests, docs in sync). --- README.md | 8 ++++---- src/schema.ts | 6 +----- src/source.ts | 12 ++++++------ tests/core.test.ts | 10 +++++----- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 35d7586..8859cd7 100644 --- a/README.md +++ b/README.md @@ -340,15 +340,15 @@ skills/release-notes/targets/cursor/SKILL.md skills/release-notes/targets/claude/SKILL.md ``` -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`. +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 `additionalFiles`. -## Plugin-Root Files +## Additional 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`: +A source plugin that needs files at its emitted root beyond the component and static files pluginpack supports by default — a bundled MCP server, a launcher script, or a `package.json` to set Node's module type — declares them under `additionalFiles` in `plugin.pluginpack.json`: ```json { - "files": { + "additionalFiles": { "dist/index.js": "dist/index.js", "start.mjs": "start.mjs", "package.json": "package.json" diff --git a/src/schema.ts b/src/schema.ts index 1a0301a..63116ba 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -124,11 +124,7 @@ 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(), + additionalFiles: z.record(safeRelativePath, safeRelativePath).optional(), }); export { configSchema, sourcePluginManifestSchema }; diff --git a/src/source.ts b/src/source.ts index c15e8c2..fbbedf7 100644 --- a/src/source.ts +++ b/src/source.ts @@ -76,18 +76,18 @@ async function readPluginFiles( // 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 additionalFiles = plugin.manifest.additionalFiles; + if (additionalFiles) { + for (const [dest, source] of Object.entries(additionalFiles)) { const destPath = toPosix(dest); if (!isSafeRelativePath(destPath)) { throw new Error( - `Source plugin "${plugin.id}" files destination "${dest}" must be a safe relative path.`, + `Source plugin "${plugin.id}" additionalFiles 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.`, + `Source plugin "${plugin.id}" additionalFiles destination "${dest}" collides with another emitted file.`, ); } const resolved = await resolveTargetOverride( @@ -97,7 +97,7 @@ async function readPluginFiles( ); if (!(await exists(resolved))) { throw new Error( - `Source plugin "${plugin.id}" files source "${source}" could not be read.`, + `Source plugin "${plugin.id}" additionalFiles source "${source}" could not be read.`, ); } files.set(destPath, await fs.readFile(resolved)); diff --git a/tests/core.test.ts b/tests/core.test.ts index e42aeeb..9c9675a 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1423,7 +1423,7 @@ export default defineConfig({ plugins: { srv: { "plugin.pluginpack.json": `${JSON.stringify({ - files: { + additionalFiles: { "dist/index.js": "dist/index.js", "start.mjs": "start.mjs", "package.json": "package.json", @@ -1488,7 +1488,7 @@ export default defineConfig({ plugins: { srv: { "plugin.pluginpack.json": `${JSON.stringify({ - files: { "skills/s1/SKILL.md": "extra.md" }, + additionalFiles: { "skills/s1/SKILL.md": "extra.md" }, })}\n`, "extra.md": "# extra\n", skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, @@ -1498,7 +1498,7 @@ export default defineConfig({ const root = project.baseDir; await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( - /files destination "skills\/s1\/SKILL.md" collides with another emitted file/, + /additionalFiles destination "skills\/s1\/SKILL.md" collides with another emitted file/, ); }); @@ -1518,7 +1518,7 @@ export default defineConfig({ plugins: { srv: { "plugin.pluginpack.json": `${JSON.stringify({ - files: { "start.mjs": "start.mjs" }, + additionalFiles: { "start.mjs": "start.mjs" }, })}\n`, skills: { s1: { "SKILL.md": skill("s1", "S1.") } }, }, @@ -1527,7 +1527,7 @@ export default defineConfig({ const root = project.baseDir; await expect(build({ cwd: root, target: "claude" })).rejects.toThrow( - /files source "start.mjs" could not be read/, + /additionalFiles source "start.mjs" could not be read/, ); });