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
91 changes: 91 additions & 0 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,97 @@ against a temp fixture via [`bintastic`](https://github.com/scalvert/bintastic).
`agent-plugins` target with `clients.codex` writes the same
`.agents/plugins/marketplace.json`, so the two need separate output roots.

## MCP dialect facts

`src/mcp.ts` renders one authored MCP config into each client's dialect. Each
dialect rule carries a citation (`getMcpDialectCitations`), and the citation
test in `tests/install-snippet.test.ts` fails if a rule has no documentation
URL or date. All were verified on 2026-10-21:

| Dialect | Rule | Source |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `claude` | `${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}` expand in a stdio server's `command`, `args`, and `env` | <https://code.claude.com/docs/en/plugins-reference#environment-variables> |
| `claude` | no `type` means stdio; Streamable HTTP is `http` (`streamable-http` is an alias) | <https://code.claude.com/docs/en/mcp> |
| `claude` | `cwd` is not a field Claude Code reads for a plugin stdio server, so a server with `cwd` is a build error | <https://code.claude.com/docs/en/plugins-reference#environment-variables> |
| `cursor` | `${CURSOR_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_ROOT}` expand in `command`, `args`, `env`, and `cwd`; `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` don't | <https://cursor.com/docs/reference/plugins#mcp-servers> |
| `cursor` | no plugin-data variable is documented, so a data variable is a build error | <https://cursor.com/docs/reference/plugins#mcp-servers> |
| `cursor` | Cursor plugins can infer the transport from `command` or `url`, so `type` is dropped | <https://cursor.com/docs/reference/plugins#mcp-servers> |
| `copilot` | `${PLUGIN_ROOT}`, with `${CLAUDE_PLUGIN_ROOT}` as an alias | <https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference> |
| `copilot` | `${PLUGIN_DATA}` is documented only for Agent Plugins packages, so legacy-format data variables stay as authored | <https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference> |
| `copilot` | remote servers may be labelled `http`, `sse`, or `streamable-http` | <https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference> |
| `agent-plugins` | closed server variants; one-token `command`; `args`, `env`, `cwd` expand `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` | <https://agent-plugins.org/specification> (§7.2.1, §9) |
| `codex` | the `agent-plugins` rules, plus per-server auth in `extensions["com.openai"].auth` | <https://developers.openai.com/codex/plugins/build> |

### Claude Code ignores `cwd`

Claude Code's plugins reference lists the fields where `${...}` resolves for
an MCP stdio server as `command`, `args`, and `env`; its MCP page lists the
same three. Neither documents `cwd`. In a check with Claude Code 2.1.284, a
plugin server with `"cwd": "${CLAUDE_PLUGIN_ROOT}/scripts"` connected, but its
process ran in the session's working directory, not `scripts/`. pluginpack
0.11 copied `cwd` to `claude` output as authored, so a server that relied on
it started in the wrong directory. The `claude` dialect now fails the build for
any server with `cwd`, and the error says to resolve paths in `args` with
`${CLAUDE_PLUGIN_ROOT}` or to use a claude-only `mcp/config.json` override.

### Open point: Copilot and the legacy `.mcp.json`

The `copilot` target emits a legacy plugin: a manifest plus `.mcp.json`. The
cited Copilot CLI reference documents placeholder expansion for MCP servers
in two other places, and neither covers the legacy shared `.mcp.json`:

- Agent Plugins packages (`mcp.json`): `${PLUGIN_ROOT}` and `${PLUGIN_DATA}`
expand in `args`, `env` values, and `cwd`, not in `command`.
- MCP servers in a plugin-shipped agent's own frontmatter: `${PLUGIN_ROOT}`
(with the `${CLAUDE_PLUGIN_ROOT}` and `${COPILOT_PLUGIN_ROOT}` aliases)
expands in `command` and `args`, and the reference says this substitution
applies only there.

So the `copilot` rows above are the closest documented behavior, not a
documented rule for `.mcp.json`. pluginpack writes a `./` `command` or `cwd`
as `${PLUGIN_ROOT}/...` for `copilot`. The docs neither confirm nor rule out
that Copilot CLI expands it there. Copilot CLI 1.0.102 loads such a plugin
and lists the command as written; whether it expands the variable at launch
is unverified. pluginpack keeps this rewriting until Copilot documents the
legacy format or a launch check shows it is wrong.

### Conflict: Codex per-server `extensions`

The Agent Plugins 1.0 specification (§7.2.1) closes each MCP server variant:
an unknown field makes the server entry invalid, and the client skips it. The
vendored `tests/fixtures/agent-plugins/mcp.schema.json` agrees
(`additionalProperties: false`). OpenAI's
[plugin packaging docs](https://developers.openai.com/codex/plugins/build)
(verified 2026-10-21) put per-server MCP auth in the same file, under
`extensions["com.openai"].auth` on each server. A server written that way is
not valid Agent Plugins 1.0.

pluginpack resolves the conflict per target:

- `codex` writes Codex-only output, so it passes a per-server `extensions`
object through. That object may hold only `com.openai`, whose value must be
an object. `validate` accepts the same shape. A server with this object
does not validate against the vendored schema, so the schema oracle covers
only codex servers without it.
- `agent-plugins` keeps the strict 1.0 shape, with or without
`clients.codex`. Its package is meant for every conforming client, and
Copilot CLI skips an invalid server entry. The error says this and names
the workaround.

Workaround for a project that builds both targets: keep the portable config
in `shared/<plugin>/mcp/config.json`. Set `overrides: "overrides/codex/<plugin>"`
on the codex plugin, and put a full copy of `config.json` with
`extensions["com.openai"]` in `overrides/codex/<plugin>/mcp/config.json`. An
override replaces the whole file; it is not merged.

### Command files

A `./` or `${<root variable>}/` `command` names a file inside the plugin. If
the artifact has no such file, the build prints a warning on every target.
The build does not fail, because a file such as a compiled binary can be
added after the build. `validate` reports an error for an Agent Plugins
package (`codex`, `agent-plugins`) whose command file is missing on disk.

## Update-check hook facts

The generated update-check hook (`updateCheck` config, claude/cursor targets)
Expand Down
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,19 +474,47 @@ For Agent Plugins output, a `${<root variable>}/bin/server` command becomes
`./bin/server`. Anything a conforming client would silently skip fails the
build instead:

- a shell string as `command`;
- a shell string as `command`, including a `./` or `${<root variable>}/`
path followed by arguments (`./bin/server --stdio`): clients run the whole
value as one file name, so arguments go in `args`;
- a `${VAR}` other than `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` (left unexpanded),
or any placeholder in `url` or `headers`;
- a `cwd` that isn't `./<path>`, `${PLUGIN_ROOT}`, `${PLUGIN_DATA}`, or a
path under one of them that stays inside it (`.` and bare relative paths
fail; omit `cwd` to use the plugin root);
- a field outside `type`, `command`, `args`, `env`, `cwd` (stdio) or `type`,
`url`, `headers` (remote);
`url`, `headers` (remote). The one exception is the `codex` target, which
also passes through a per-server `extensions["com.openai"]` object, where
Codex reads MCP auth. The `agent-plugins` target rejects it, even with
`clients.codex`, because other clients skip a server that has it. To build
both targets, set `overrides: "overrides/codex/<plugin>"` on the codex
plugin and put a copy of `config.json` with the extension in
`overrides/codex/<plugin>/mcp/config.json`. See
[CONFORMANCE.md](CONFORMANCE.md#mcp-dialect-facts);
- an `env` entry named `PLUGIN_ROOT` or `PLUGIN_DATA`, or a header set twice;
- plain `http` to a non-loopback host.

With `format: "legacy"`, `codex` writes `.mcp.json` as authored.

On every target, a `./bin/server` or `${<root variable>}/bin/server` command
must name a file the plugin ships, and so must an `args` entry that starts
with `${<root variable>}/`, such as `${PLUGIN_ROOT}/dist/server.js` (a
directory the plugin ships also counts). If the build output has no such
file, the build prints a warning, because you can still add the file after
the build. `validate` fails an Agent Plugins package whose command file is
missing.

The `claude` target rejects a stdio server that sets `cwd`. Claude Code
ignores `cwd` for plugin MCP servers and starts them in the user's working
directory; it resolves `${CLAUDE_PLUGIN_ROOT}` only in `command`, `args`, and
`env` ([plugins reference](https://code.claude.com/docs/en/plugins-reference#environment-variables),
[MCP docs](https://code.claude.com/docs/en/mcp)). Remove `cwd` and write
plugin paths in `args` with the root variable, for example
`"args": ["${PLUGIN_ROOT}/server/index.js"]`. If other targets need the
`cwd`, keep it in the shared `config.json`, set
`overrides: "overrides/claude/<plugin>"` on the claude plugin, and put a copy
without `cwd` in `overrides/claude/<plugin>/mcp/config.json`.

A `./bin/server` command or `./server` `cwd` becomes
`${<root variable>}/bin/server` for targets whose clients don't resolve
plugin-relative paths. Config already written in
Expand Down
36 changes: 33 additions & 3 deletions src/agent-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { promises as fs } from "node:fs";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { exists } from "./fs.js";
import { AGENT_PLUGINS_MCP_SCHEMA, renderMcpConfig } from "./mcp.js";
import {
AGENT_PLUGINS_MCP_SCHEMA,
pluginCommandFiles,
renderMcpConfig,
type McpDialect,
} from "./mcp.js";
import { deepMerge, stripUndefined } from "./targets/shared.js";
import { error, parseMatter, readJson } from "./targets/validation-shared.js";
import type { ManifestBuildContext } from "./targets/types.js";
Expand Down Expand Up @@ -171,11 +176,14 @@ export async function hasPackageManifest(pluginDir: string): Promise<boolean> {
* manifest, the `mcp.json` shape, and each discovered skill — every check
* here is something a conforming client would reject or silently skip.
* Returns the parsed manifest, or `undefined` when it couldn't be read.
* `mcpDialect` is `"codex"` only for Codex-only output, which may carry
* Codex's per-server `extensions`.
*/
export async function validatePackage(
pluginDir: string,
pluginName: string,
issues: ValidationIssue[],
mcpDialect: PackageMcpDialect = "agent-plugins",
): Promise<Record<string, unknown> | undefined> {
const manifest = await readJson(
path.join(pluginDir, packageLayout.manifest),
Expand All @@ -186,11 +194,14 @@ export async function validatePackage(
return undefined;
}
validateManifest(manifest, pluginName, issues);
await validateMcp(pluginDir, pluginName, issues);
await validateMcp(pluginDir, pluginName, mcpDialect, issues);
await validateSkills(pluginDir, pluginName, issues);
return manifest;
}

/** The MCP dialects a package's `mcp.json` can be written in. */
export type PackageMcpDialect = Extract<McpDialect, "agent-plugins" | "codex">;

function validateManifest(
manifest: Record<string, unknown>,
pluginName: string,
Expand Down Expand Up @@ -275,6 +286,7 @@ function validateManifest(
async function validateMcp(
pluginDir: string,
pluginName: string,
dialect: PackageMcpDialect,
issues: ValidationIssue[],
): Promise<void> {
const file = path.join(pluginDir, packageLayout.mcp);
Expand Down Expand Up @@ -308,7 +320,7 @@ async function validateMcp(
let rendered: unknown;
try {
rendered = (
renderMcpConfig({ [name]: server }, "agent-plugins", pluginName)
renderMcpConfig({ [name]: server }, dialect, pluginName)
.mcpServers as Record<string, unknown>
)[name];
} catch (err) {
Expand All @@ -334,6 +346,24 @@ async function validateMcp(
}; expected ${JSON.stringify(rendered)}.`,
);
}
// A ./ command is a file inside the package (spec §7.2.1); if it isn't
// there, the client can't start the server.
for (const { command, file } of pluginCommandFiles({ [name]: rendered })) {
if (!(await isFile(path.join(pluginDir, file)))) {
error(
issues,
`${pluginName}: mcp.json server "${name}" "command" is ${JSON.stringify(command)}, but the package has no file "${file}" — the client can't start the server.`,
);
}
}
}
}

async function isFile(file: string): Promise<boolean> {
try {
return (await fs.stat(file)).isFile();
} catch {
return false;
}
}

Expand Down
Loading
Loading