Problem
A plugin whose MCP server runs its own npm package writes an unpinned invocation into mcpServers:
{ "command": "npx", "args": ["-y", "cyber-asana", "mcp"] }
Nothing pins that. A consumer installs plugin 0.10.0, gets 0.10.0's skills, and then npx resolves the latest published server. Skills and server drift apart silently, and the drift widens with every release the consumer does not reinstall.
This is the same defect class that makes unpinned invocations in skill prose a problem — an agent mid-workflow silently gets a different version than the instructions were written against.
Why the build should own it
- The version is known at build time and nowhere else. A runtime placeholder cannot work: only
${PLUGIN_ROOT} and ${PLUGIN_DATA} expand in mcp.json, so a ${PLUGIN_VERSION} in args would arrive at the client literally.
- There is already a precedent for exactly this shape. Hook casing is authored canonically and derived per vendor. "Author abstractly, stamp concretely at build" is the established pattern.
build already owns every derived manifest. It composes each vendor manifest from the canonical one plus harnesses.<vendor> overrides.
- It replaces per-repo scripts.
cyberuni/cyber-asana hand-maintains five manifests and textually rewrites the version line across them with a bespoke scripts/sync-plugin-version.mjs. That script cannot pin args, and every adopting repo would otherwise write its own.
Current state
mcpServers appears nowhere in packages/universal-plugin/src — the block rides through the build as opaque JSON. build special-cases only hooks.
plugin version moves the version through the manifests that carry a version field; it has no notion of an invocation embedded in args.
Proposed behaviour
At build, rewrite the package specifier in a mcpServers entry's args to <pkg>@<plugin version> when — and only when — the package is the plugin's own.
The build must not stamp its version onto an unrelated tool. npx -y some-other-cli is not the plugin, and guessing by name match is not sound for a plugin published under a different name than its server package. This needs an explicit marker in the canonical manifest, e.g.:
{
"mcpServers": {
"cyber-asana": {
"command": "npx",
"args": ["-y", "cyber-asana", "mcp"],
"pinToPluginVersion": true
}
}
}
...with the marker stripped from every derived manifest, since it is a build directive and not part of any vendor's schema.
Open questions:
- Should an already-pinned specifier be left alone, overwritten, or rejected as a conflict?
- Does
plugin doctor warn on an unpinned self-invocation that carries no marker?
Related
Problem
A plugin whose MCP server runs its own npm package writes an unpinned invocation into
mcpServers:{ "command": "npx", "args": ["-y", "cyber-asana", "mcp"] }Nothing pins that. A consumer installs plugin 0.10.0, gets 0.10.0's skills, and then
npxresolves the latest published server. Skills and server drift apart silently, and the drift widens with every release the consumer does not reinstall.This is the same defect class that makes unpinned invocations in skill prose a problem — an agent mid-workflow silently gets a different version than the instructions were written against.
Why the build should own it
${PLUGIN_ROOT}and${PLUGIN_DATA}expand inmcp.json, so a${PLUGIN_VERSION}inargswould arrive at the client literally.buildalready owns every derived manifest. It composes each vendor manifest from the canonical one plusharnesses.<vendor>overrides.cyberuni/cyber-asanahand-maintains five manifests and textually rewrites theversionline across them with a bespokescripts/sync-plugin-version.mjs. That script cannot pinargs, and every adopting repo would otherwise write its own.Current state
mcpServersappears nowhere inpackages/universal-plugin/src— the block rides through the build as opaque JSON.buildspecial-cases onlyhooks.plugin versionmoves the version through the manifests that carry aversionfield; it has no notion of an invocation embedded inargs.Proposed behaviour
At build, rewrite the package specifier in a
mcpServersentry'sargsto<pkg>@<plugin version>when — and only when — the package is the plugin's own.The build must not stamp its version onto an unrelated tool.
npx -y some-other-cliis not the plugin, and guessing by name match is not sound for a plugin published under a different name than its server package. This needs an explicit marker in the canonical manifest, e.g.:{ "mcpServers": { "cyber-asana": { "command": "npx", "args": ["-y", "cyber-asana", "mcp"], "pinToPluginVersion": true } } }...with the marker stripped from every derived manifest, since it is a build directive and not part of any vendor's schema.
Open questions:
plugin doctorwarn on an unpinned self-invocation that carries no marker?Related
src/pin/pin.tsalready has the correct matcher, including the-yspelling that ad-hoc regexes miss:/(npx|upx)\s+(?:--yes\s+|-y\s+)?([@a-z0-9/._-]+)@(\S+)/. It is read-only today, consumed only bybundleto discover dependencies — worth reusing rather than re-deriving.plugin version— bump the canonical manifest and every version-carrying file in sync #32 / feat: support plugin version management across all runtime manifests #1 moved the version through the files that carry aversionfield. This is the same concern reaching one place those did not: an invocation insideargs.cyberuni/cyber-asana, which ships an MCP server as its own npm package and currently launches it unpinned.