Skip to content

feat(build): pin an MCP server's npx invocation to the plugin's own version - #57

Open
unional wants to merge 3 commits into
mainfrom
cyberlegion/unit-9ec3951b2d246f4e
Open

feat(build): pin an MCP server's npx invocation to the plugin's own version#57
unional wants to merge 3 commits into
mainfrom
cyberlegion/unit-9ec3951b2d246f4e

Conversation

@unional

@unional unional commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

What

plugin build now 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 npx resolves the latest published
server. 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.json expands only
${PLUGIN_ROOT} and ${PLUGIN_DATA}, so a ${PLUGIN_VERSION} in args would arrive at the client
literally.

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":

  • The marker is required; a name match is never used. A plugin may publish its server under a
    package name that is not the plugin's, and npx -y widget-cli sitting in the same block must never
    be stamped with this plugin's version.
  • The marker never reaches a vendor. It is a build directive, not part of any vendor's schema, so
    it is stripped from every derived manifest and derived MCP file. The authored plugin.json and
    mcp.json are left as written.
  • Delivery follows the hooks rule. With nothing marked, the canonical mcpServers declaration
    passes 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.json with the
    vendor's mcpServers repointed there.
  • copilot-cli reads the canonical manifest directly, so it has no derived manifest to receive
    the pin — the build warns rather than pretending otherwise, the same remedy ADR-0011 uses for a
    handler a vendor cannot run.
  • Guards warn and leave the entry alone; the build stays green. A command that is not
    npx/upx, a manifest with no version, or args carrying no package specifier.

The matcher is reused, not re-derived: the runner words, the -y / --yes spellings, and the
scoped-name @ split now live once in src/pin/pin.ts and serve both callers, with the skill-prose
extractor 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 doctor does not warn on an unpinned self-invocation carrying no marker — not in this
PR.

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 args package name equals the plugin's own npm package name, read from the
declared packagePath's package.json — and it is worth having. But doctor is a skill with no
behavioral 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 canonical mcpServers declaration (inline block, path, or path
    list, mirroring readCanonicalHooks), pins, strips, delivers per vendor.
  • .agents/spec/plugin/build/ — the node's use cases and behavior table, plus 16 additive scenarios
    in the frozen suite (zero deletions, no existing scenario narrowed or rewritten).
  • Root spec.md placement map records the rule separating this from the skill pins plugin bundle
    owns: the dividing line is the object, not the word "pin". ADR-0010 §1's derived-vs-authored table
    gains the row.
  • governances/plugin-design.md and the docs site's build page document the marker.

Verification

pnpm verify green — 620 tests across 34 files, rebased onto main. Both SDD gates approved by cold
judges: the spec gate returns all three lenses pass with ALIGNED: true, and the impl gate names a
test 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

unional and others added 3 commits August 29, 2026 19:27
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-bot

changeset-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d2b07e0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
universal-plugin Minor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(build): pin an MCP server's npx invocation to the plugin's own version

1 participant