feat(build): pin an MCP server's npx invocation to the plugin's own version - #57
Open
unional wants to merge 3 commits into
Open
feat(build): pin an MCP server's npx invocation to the plugin's own version#57unional wants to merge 3 commits into
unional wants to merge 3 commits into
Conversation
A plugin whose MCP server is its own npm package writes an unpinned invocation, so a consumer
gets one release's skills beside whatever npx resolves as latest for the server. The version is
known at build time and nowhere else — mcp.json expands only ${PLUGIN_ROOT} and ${PLUGIN_DATA}, so
a runtime placeholder would reach the client literally.
Specifies the opt-in marker, what the build stamps, and where the pinned map is delivered: the
build node's use cases and behavior table, sixteen additive scenarios in the frozen suite, the root
placement map's rule separating this from the skill pins plugin bundle owns, and the version-policy
decision's derived-vs-authored table.
Refs #56
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ewsv5uK3mATSAW3RhLkPRs
An mcpServers entry opts in with "pinToPluginVersion": true, and the build rewrites its package specifier to <pkg>@<manifest version>. Opt-in is required and a name match is never used — a plugin may publish its server under a package name that is not the plugin's, and an unrelated npx invocation sitting in the same block must never be stamped with this plugin's version. The marker is a build directive, so it is stripped from every derived manifest and derived MCP file and the authored files are left as written. Delivery follows the rule hooks already follows: with nothing marked the declaration passes through and nothing is derived; with an entry marked the pinned map is delivered inline when the canonical declared it inline, else as <vendor-dir>/mcp.json with the vendor's mcpServers repointed there. A vendor the canonical manifest serves directly has no derived manifest to receive the pin, so it is warned about rather than pretended at. The runner words, the -y/--yes spellings, and the scoped-name split now live once in the pin domain and serve both callers; the skill-prose extractor is refactored onto them with its behavior unchanged. Refs #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ewsv5uK3mATSAW3RhLkPRs
The shipped plugin-design governance and the docs site's build page both state the manifest field set and what the build derives, so both need the new marker: how to opt in, why opting in is required rather than inferred from the package name, what happens to an already-pinned specifier, which guards leave an entry alone, and why the canonical-served vendor is warned instead. Refs #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ewsv5uK3mATSAW3RhLkPRs
🦋 Changeset detectedLatest commit: d2b07e0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
plugin buildnow stamps the plugin's version onto an MCP invocation that asks for it.A plugin whose MCP server is its own npm package writes an unpinned invocation:
{ "command": "npx", "args": ["-y", "my-server", "mcp"] }A consumer installs plugin 1.4.0, gets 1.4.0's skills, and then
npxresolves the latest publishedserver. Skills and server drift apart silently, and the drift widens with every release the consumer
does not reinstall. The version is known at build time and nowhere else —
mcp.jsonexpands only${PLUGIN_ROOT}and${PLUGIN_DATA}, so a${PLUGIN_VERSION}inargswould arrive at the clientliterally.
An entry opts in with the marker:
{ "command": "npx", "args": ["-y", "my-server", "mcp"], "pinToPluginVersion": true }and the derived manifest carries
["-y", "my-server@1.4.0", "mcp"]with the marker gone.Following the precedent the issue names — hook casing is authored canonically and derived per vendor,
"author abstractly, stamp concretely at build":
package name that is not the plugin's, and
npx -y widget-clisitting in the same block must neverbe stamped with this plugin's version.
it is stripped from every derived manifest and derived MCP file. The authored
plugin.jsonandmcp.jsonare left as written.mcpServersdeclarationpasses through as authored and nothing is derived. With an entry marked, the pinned map is
delivered inline when the canonical declared it inline, else as
<vendor-dir>/mcp.jsonwith thevendor's
mcpServersrepointed there.copilot-clireads the canonical manifest directly, so it has no derived manifest to receivethe pin — the build warns rather than pretending otherwise, the same remedy ADR-0011 uses for a
handler a vendor cannot run.
commandthat is notnpx/upx, a manifest with noversion, orargscarrying no package specifier.The matcher is reused, not re-derived: the runner words, the
-y/--yesspellings, and thescoped-name
@split now live once insrc/pin/pin.tsand serve both callers, with the skill-proseextractor refactored onto them and its behavior unchanged.
The two open questions
1. An already-pinned specifier is overwritten — with a warning naming the version it replaced,
and silently when the authored version already matches.
The marker is a declaration that this package's version is the plugin's, which makes the specifier
a derived value, and ADR-0010 §1 says a derived value is never also authored. Leaving it alone would
fork a second writer beside the build and reintroduce exactly the drift this closes — a hand-pin
would silently go stale on the next release. Rejecting it as a conflict would fail a dev-time command
that runs constantly, for a situation with one obvious correct answer. Overwriting keeps the build
idempotent and the canonical manifest untouched; the warning is what stops the overwrite from being
silent.
2.
plugin doctordoes not warn on an unpinned self-invocation carrying no marker — not in thisPR.
To find one, doctor would have to match on the package name, and unsound name-matching is the exact
reason the marker exists: it would fire on
npx -y widget-cli. There is a narrow sound version —warn only when the
argspackage name equals the plugin's own npm package name, read from thedeclared
packagePath'spackage.json— and it is worth having. Butdoctoris a skill with nobehavioral node in the spec corpus, so that check needs its own node rather than riding on the build
node's contract. Recorded as a follow-up rather than smuggled in here.
Scope
src/pin/pin.ts— shared runner/flag/specifier parsing;pinMcpServers(pure domain).src/build/build.ts— resolves the canonicalmcpServersdeclaration (inline block, path, or pathlist, mirroring
readCanonicalHooks), pins, strips, delivers per vendor..agents/spec/plugin/build/— the node's use cases and behavior table, plus 16 additive scenariosin the frozen suite (zero deletions, no existing scenario narrowed or rewritten).
spec.mdplacement map records the rule separating this from the skill pinsplugin bundleowns: the dividing line is the object, not the word "pin". ADR-0010 §1's derived-vs-authored table
gains the row.
governances/plugin-design.mdand the docs site's build page document the marker.Verification
pnpm verifygreen — 620 tests across 34 files, rebased ontomain. Both SDD gates approved by coldjudges: the spec gate returns all three lenses pass with
ALIGNED: true, and the impl gate names atest exercising every one of the 15 frozen MCP scenarios with none left unverified.
Closes #56
🤖 Generated with Claude Code
https://claude.ai/code/session_01Ewsv5uK3mATSAW3RhLkPRs