bump-mcp — Pin an MCP server to a new version in the nexus catalogue
roster/mcps.yaml is the closed catalogue of the five nexus MCPs (atlas-aci, junction, crystalium, tonberry, atomos). Under the default integrity.enforcement: strict posture it is what eidolons verify checks an installed image against — so a stale pin is not cosmetic. It means every consumer installs an old image and cannot reach newer versions with eidolons mcp use <name>@<ver>.
add-eidolon handles Eidolons (roster/index.yaml). This skill handles MCP servers (roster/mcps.yaml). They are different files with different shapes.
Invariants you must respect
- Never copy a digest from a release note. Resolve every digest from the registry yourself. A wrong digest breaks
eidolons verifyfor every consumer understrict. - Git tags are
v-prefixed; image tags usually are not.git tag v2.0.0butghcr.io/rynaro/atlas-aci:2.0.0.imagetools inspect …:v2.0.0will silently return nothing. Check both. - Backfill releases the catalogue skipped. If
releases:jumps from0.2.3to the new version, every intermediate release is unreachable viamcp use. Record them, with digests resolved from the registry. catalogue_versionis for the file's shape, not its contents. Adding releases does not bump it. Adding a new field does.- Bump
updated_at. - Do not sweep unrelated working-tree changes into the commit. This repo's tree frequently carries uncommitted
.mcp.json/.gitignoreedits.git addthe exact paths.
Phase 0 — Confirm the upstream release is real
gh release view v<VER> --repo <owner>/<repo> --json tagName,isDraft,publishedAt
For kind: oci-image MCPs, confirm the image exists and is a multi-arch index:
docker buildx imagetools inspect ghcr.io/rynaro/<name>:<VER> --raw \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["mediaType"], len(d.get("manifests",[])), "platforms")'
If the release workflow signs with cosign and attests SBOM/provenance, say so in the CHANGELOG — it is why the digest can be trusted.
Phase A — Capture and verify every digest
Not just the new one. Every version you will record.
for t in 0.3.0 0.3.1 0.4.0 2.0.0; do
printf '%-8s %s\n' "$t" \
"$(docker buildx imagetools inspect ghcr.io/rynaro/<name>:$t --format '{{.Manifest.Digest}}')"
done
Release dates: gh release view v<VER> --json publishedAt. Some tags have an image but no GitHub Release object — fall back to the git tag date (git log -1 --format=%aI <tag>) and say which you used.
Phase B — Edit roster/mcps.yaml
Under the MCP's versions: block:
latest:→ the new versionpins.stable:→ the new versionreleases:→ add every version withdigest:andreleased_at:- top-of-file
updated_at:→ today
If you are backfilling, leave a comment saying why the catalogue skipped them. A future reader will want to know it was an oversight, not a policy.
Then re-verify what you wrote against the registry, reading the digests back out of the file rather than out of your own scrollback:
python3 - <<'PY' > /tmp/dg.txt
import yaml
d = yaml.safe_load(open('roster/mcps.yaml'))
e = [m for m in d['mcps'] if m['name'] == '<name>'][0]
for v, r in sorted(e['versions']['releases'].items()):
print(v, r['digest'])
PY
while read -r v dg; do
real=$(docker buildx imagetools inspect "ghcr.io/rynaro/<name>:$v" --format '{{.Manifest.Digest}}' 2>/dev/null)
[ "$real" = "$dg" ] && echo "$v OK" || echo "$v MISMATCH recorded=$dg real=$real"
done < /tmp/dg.txt
Phase C — The test fixture is coupled to pins.stable
This is the trap. cli/tests/mcp_images.bats hardcodes the stable digest:
ATLAS_ACI_PINNED="sha256:…"
and S8 asserts "image present at the stable digest ⇒ DRIFT=no". If you bump the catalogue and forget the fixture, S8 keeps passing — vacuously. It asserts no-drift against a digest nothing pins any more. The hardcoded constant is a proxy for "the catalogue's stable digest".
Bump the fixture, and make sure a guard derives the value instead of trusting it. S8-guard does this today for atlas-aci (pure awk, bash 3.2 safe). If you add a fixture for another MCP, add its guard too.
Prove the guard has teeth before you trust it:
# revert the fixture to the OLD digest -> S8-guard and S8 must BOTH fail
bats cli/tests/mcp_images.bats | grep '^not ok'
# restore -> both pass
Note CRYSTALIUM_PINNED is deliberately a non-matching digest — S9 exercises the DRIFT=yes path. Do not "fix" it.
Phase D — CHANGELOG
Add to ## [Unreleased] under ### Changed. The house style is a dense paragraph, not a bullet fragment: what moved, the digest, why it matters, what breaks for consumers.
Link to an immutable ref. If you link to an upstream artefact under .spectra/changes/<id>/, link to the tag, not blob/main — the ESL lifecycle archives that folder to .spectra/changes/archive/<date>-<id>/ and blob/main will 404:
https://github.com/<owner>/<repo>/blob/v2.0.0/.spectra/changes/<id>/RETRO.md ✅
https://github.com/<owner>/<repo>/blob/main/.spectra/changes/<id>/RETRO.md ❌ breaks on archive
State breaking changes plainly. For a schema-epoch bump: "the on-disk DB is derived data with no in-place migration — re-index once."
Phase E — ESL change
This repo runs ESL. Open a change before editing (trivial routes need no spec):
tb() { docker run --rm -i --user "$(id -u):$(id -g)" \
-v "$PWD:$PWD:z" -w "$PWD" --cap-drop ALL --security-opt no-new-privileges \
ghcr.io/rynaro/tonberry@sha256:<pinned> "$@"; }
tb propose --change_id mcp-<name>-<ver-dashed> --maker vivi --checker kupo \
--has_code true --spec_ref spec.md --project_root "$PWD"
tb right_size --change_id … --files_touched 5 --rubric_score 3 --tradeoff_present false --project_root "$PWD"
has_code: true — you are editing cli/tests/*.bats. Write spec.md, thread acceptance_checks via compose_manifest --patch, then transition. A catalogue bump right-sizes to lite.
Phase F — Verification
make schema # jq + yq structural checks CI runs
EIDOLONS_NEXUS="$(pwd)" bash cli/eidolons mcp show <name> # CLI reads the catalogue
make test # parallel; needs GNU `parallel`
bats cli/tests/ # sequential fallback (~2m30s) when `parallel` is absent
Before blaming your change for a red test, check whether it is red on a clean tree. Stash and re-run the single file:
git stash push -q roster/mcps.yaml cli/tests/mcp_images.bats
bats cli/tests/<file>.bats | grep '^not ok'
git stash pop -q
At time of writing, cli/tests/doctor_deep.bats DD-7 fails on a clean tree. Say so in the PR rather than silently accepting a red suite.
Phase G — Branch, commit, PR
Branch fix/mcp-<name>-<version-dashed> (mirrors the fix/roster-<eidolon>-<version> convention). Stage exact paths. Never push to main directly. Open a PR; do not merge without the user's word.
Traps, learned the hard way
imagetools inspect ghcr.io/x:v2.0.0returns nothing while:2.0.0works. Thevis a git convention, not a registry one.- A hardcoded digest in a test is a proxy. Derive it from the catalogue or a bump will make the test pass vacuously.
blob/mainlinks to ESL change folders rot the moment the change is archived.- The catalogue can silently skip releases for months. Check
releases:againstgh release listbefore you assume you are bumping by one. - The local wiring (
.mcp.json,eidolons.mcp.lock) is separate from the catalogue. Those are consumer artefacts, often untracked or carrying unrelated edits. Update them witheidolons mcp upgrade <name>, in their own commit, or not at all.