Skip to content
Draft
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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host>/.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 |
Expand All @@ -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/<host>/<source>` 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

Expand Down
3 changes: 2 additions & 1 deletion src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ export async function collectPluginFiles(
export async function resolveMcpServers(
project: ResolvedProject,
sourceIds: string[],
target: TargetName,
): Promise<Record<string, unknown> | undefined> {
const merged: Record<string, unknown> = {};
let found = false;
for (const sourceId of sourceIds) {
if (!project.plugins.has(sourceId)) {
continue;
}
const servers = await project.source.readMcpServers(sourceId);
const servers = await project.source.readMcpServers(sourceId, target);
if (!servers) {
continue;
}
Expand Down
5 changes: 5 additions & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host>/<source> file wins for that host.
files: z.record(safeRelativePath, safeRelativePath).optional(),
});

export { configSchema, sourcePluginManifestSchema };
Expand Down
55 changes: 47 additions & 8 deletions src/source.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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),
};
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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/<host>/.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<Record<string, unknown> | 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;
Expand Down
6 changes: 5 additions & 1 deletion src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface SourceProvider {
): Promise<Map<string, FileValue>>;
readMcpServers(
pluginId: string,
target: TargetName,
): Promise<Record<string, unknown> | undefined>;
}

Expand Down
193 changes: 193 additions & 0 deletions tests/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,199 @@ export default defineConfig({
);
});

it("overrides MCP config per target from targets/<host>/.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<string, unknown> };
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<string, unknown> };
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")}";
Expand Down