Three things are published per release, in this order, and none of them can be unpublished:
vitaminmcp — the launcher, pinning the sha256 of those jars and assetsserver.json, pointing at that npm versionThe order is forced. A package cannot pin bytes that are not downloadable yet, and the registry
rejects a server.json naming an npm package that does not exist or does not claim this server
name. Why it is built this way at all is design.md §16.
.github/workflows/release.yml does all three when a version
tag is pushed. What follows is the one-time setup it needs, and the first release, which is worth
doing by hand.
The package name vitaminmcp is claimed by whoever publishes it first, so publish before
announcing anything.
Publishing to npm needs a second factor, and the first publish is the awkward one. npm requires 2FA to publish, no longer accepts new TOTP enrolments, and is restricting the tokens that used to bypass 2FA. So:
https://npmjs.com/settings/<user>/tfa. On Windows, Windows Hello registers as one — no separate
hardware key needed. An authenticator app is not an option any moreSet this up once and CI needs no npm credentials at all. On npmjs.com, package settings → Trusted Publisher → GitHub Actions, with:
| Organization or user | Backas03 |
| Repository | VitaminMCP |
| Workflow filename | release.yml — the name only, not the path |
The workflow already grants id-token: write, and the npm CLI prefers OIDC over a token wherever
it finds it. NPM_TOKEN stays as a fallback and can be deleted once a release has gone out this
way.
| What you see | What it is |
|---|---|
404 Not Found - PUT .../vitaminmcp from CI |
Not a missing package. npm answers an authorization failure with 404 so it does not disclose whether a package exists, and the one you are creating does not. The token cannot publish |
403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required |
The honest version of the same thing, which you only get locally |
400 ... "otp" with value "[object Object]" |
An npm CLI bug on Windows: the one-time-password prompt passes an object through. Pass --otp=<code> on the command line instead, or upgrade npm |
404 ... Adding a new TOTP 2FA is no longer supported |
Register a passkey or security key instead |
400 ... NPM package ownership validation failed. Expected mcpName 'X', got 'x' |
From the MCP registry, not npm. mcpName is compared exactly, capitalisation included. A published npm version cannot be edited, so getting this wrong costs a patch release |
400 ... NPM package 'vitaminmcp' exists, but version 'X' was not found (status: 404) |
Also from the MCP registry: npm had not started serving the version it had just accepted, which takes a minute or so. The workflow waits for npm before this step and retries this refusal, so seeing it means npm took longer than ten minutes. Rerun the failed job once npm serves the version |
A granular token, if you use one at all, has to be scoped to all packages for a first publish. Scoped to selected packages it cannot create one that is not in its list yet — which a new package never is.
The workflow publishes with --provenance, which links the tarball to the workflow run that built
it. That needs id-token: write, which the workflow already declares.
Nothing to register in advance. The namespace io.github.Backas03/* is proved by GitHub
authentication — interactively with mcp-publisher login github, and from CI with
mcp-publisher login github-oidc, which is why the workflow needs id-token: write for that too.
The GitHub account’s own capitalisation is part of the name. The registry does not fold it, and
grants exactly io.github.Backas03/*, so a lowercase server.json is refused:
403 Forbidden — You do not have permission to publish this server.
You have permission to publish: io.github.Backas03/*
Attempting to publish: io.github.backas03/vitaminmcp
The registry checks that npm/package.json carries
"mcpName": "io.github.Backas03/vitaminmcp"
which is what stops someone else’s package from claiming this server name. Do not remove it.
The version lives in one place —
build-logic/src/main/kotlin/vitaminmcp.java-conventions.gradle.kts.
Everything else copies it:
node npm/scripts/stamp-checksums.mjs --sync
That writes the version into npm/package.json and server.json (both places it appears there).
The Claude Code plugin manifests carry it too and are not synced — set the version in
.claude-plugin/marketplace.json and claude-code/.claude-plugin/plugin.json by hand. Commit
all five files together. The release workflow refuses to build if the first three disagree with
the tag, which is deliberate: a mismatch has to fail in the repository, not halfway through
publishing.
Put them in .github/release-notes/<version>.md and commit it before tagging — the workflow reads
the file from the tagged commit. It becomes the body of the GitHub release, and the Modrinth
changelog is read back from that body. Without the file, GitHub generates notes from the merged
pull requests. 3.2.1’s is an example of the usual shape:
a one-line summary, what breaks against the last version (usually nothing), then Added / Fixed /
Upgrading, and credit for outside contributions.
Do not create the release with gh to get notes onto it. It works — the workflow finds the
release and only uploads its assets — but a release is authored by whoever created it, and the
author can never be changed. Released this way it shows up as yours rather than github-actions’,
which is how 3.2.0 and 3.2.1 went out. Replacing such a release is not an option either: the
workflow would build new jars for it, and the npm version already published pins the old ones.
git tag 2.0.0
git push origin 2.0.0
The workflow builds dist, creates the release with the agent/server jars, five native runner
assets and the optional Windows x64 viewer asset, then stamps the checksums from the
jars it just built, publishes to npm, and publishes server.json. Watch it — the first two steps
are irreversible before the third runs.
Between npm and the registry it waits until npm serves the new version: the registry checks the package by fetching that version from npm, and a version npm has just accepted is served only a minute or so later. 3.2.1’s registry step ran straight after the publish and was refused for it.
Worth doing once rather than debugging the workflow against an unpublished name.
Creating a release with gh pushes the tag, so the workflow runs too. That is fine: each of
its three publish steps checks whether it has already happened and carries on rather than failing,
so whichever of you gets there second does nothing. The same guard makes a rerun safe after a
failure halfway down, which matters because nothing above the failure can be undone.
From a clean checkout at the commit you want released:
./gradlew dist
gh release create 2.0.0 --title 2.0.0 --generate-notes \
build/dist/VitaminMCP.jar build/dist/mcp-server.jar \
build/dist/runners/bot-runner-win-x64.exe \
build/dist/runners/bot-runner-linux-x64 \
build/dist/runners/bot-runner-linux-arm64 \
build/dist/runners/bot-runner-darwin-x64 \
build/dist/runners/bot-runner-darwin-arm64 \
build/dist/assets/bot-runner-viewer-win-x64.tgz
cd npm && node scripts/stamp-checksums.mjs --tag 2.0.0 && npm publish --access public
--tag reads the hashes from the release you just created rather than from the jars on disk. Use
it whenever the release already exists: a shadow jar is not byte-reproducible, so the same source
built twice gives two different files, and only one of them is the one people will download.
npm publish asks for a one-time password here, which is why the first release is easier by hand
than through a token.
If npm succeeded but the registry step did not, finish it without cutting anything again. While the failed run is recent, rerunning its failed job reuses the runners it already built:
gh run rerun <run-id> --failed
Otherwise dispatch the workflow for the version, which builds everything again:
gh workflow run Release --ref master -f version=2.0.0
Every step checks whether its own work is already done, so either publishes only what is missing.
Then install the publisher and claim the name. On Windows:
Invoke-WebRequest -Uri "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_windows_amd64.tar.gz" -OutFile mcp-publisher.tar.gz; tar xf mcp-publisher.tar.gz mcp-publisher.exe
mcp-publisher login github
mcp-publisher publish
login github opens a browser. publish reads server.json from the working directory; add
--dry-run first to validate without publishing.
Check it landed:
curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=vitaminmcp"
npm/ can be exercised against a release that already exists, or against local jars:
node npm/bin/vitaminmcp.mjs --jars
Downloads what this version pins and prints where the jars went, without starting a server.
VITAMINMCP_SERVER_JAR and VITAMINMCP_RUNNER_JAR point it at jars you built instead, which is
how to run the launcher against unreleased changes:
VITAMINMCP_SERVER_JAR=$PWD/build/dist/mcp-server.jar \
VITAMINMCP_NODE_RUNNER=$PWD/npm/runner/runner.mjs \
node npm/bin/vitaminmcp.mjs
npm pack --dry-run lists exactly what would be published. It should contain the launcher and
Node runner source, with no jars or platform binaries.
claude mcp add vitaminmcp -- npx -y vitaminmcp |
from npm |
| the server’s entry in a client’s MCP catalogue | from the registry |
/mcp__vitaminmcp__setup |
from the server itself, once connected |
VitaminMCP.jar for the Minecraft server |
from the GitHub release, by hand or by that prompt |
The plugin stays a manual install. It goes on a machine none of the above can reach.