# Publish MCP Server

> Use when publishing or re-publishing an MCP server — bumping its version and pushing the release to npm, the official MCP Registry, and Smithery. Trigger phrases — "publish the mcp server", "release a new mcp version", "deploy the mcp server", "ship an mcp update", "list my mcp server on the registry".

- Skill: `laurells/publish-mcp-server` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add laurells/publish-mcp-server`
- Raw SKILL.md: https://api.skillmd.com/api/skills/laurells/publish-mcp-server/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: laurells (https://skillmd.com/u/laurells)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/laurells/publish-mcp-server

---


# Publish an MCP server

This skill walks through releasing an MCP server to npm, the official MCP
Registry, and Smithery. It does **not** assume any particular repo layout —
determine the actual setup first, then follow the release steps against
that reality.

This file holds the flow and the stop-gates. Detailed rationale, full
error tables, and one-time-setup specifics live in `references/` — load
the relevant file when a check fails or you need the "why," not up front.

---

## Step 0 — Determine the actual setup (never assume)

Before touching anything, establish facts about *this* project instead of
guessing. Where a check below only yields a heuristic rather than a
guarantee, that's called out explicitly — treat those as evidence to weigh,
not as settled conclusions.

1. **Where does `npm publish` actually run from?** Find the package's
   `package.json` and check its `repository.url`. Compare that to
   `git remote -v` in the current checkout.
   - **Heuristic, not proof:** a match (or no `repository` field) is
     evidence — not confirmation — of a single-repo setup; a mismatch is
     evidence, not proof, of a mirror/sync setup (the field could just be
     stale or wrong). If the result is a mismatch, or anything else about
     the setup is unclear, **stop here and ask the user to confirm the
     actual release flow before proceeding to Step 1** — don't invent a
     mirror-sync step that doesn't exist, and don't skip one that does.
     Only proceed without asking when the match is clean *and* step 0.2/0.3
     below don't surface anything contradicting it.
2. **Is there existing release documentation?** Check the package's own
   `README.md`, a `CONTRIBUTING.md`, or a `docs/` folder for anything
   describing how past releases were done. Trust that over any assumption.
3. **Has this package been published before?** Run `npm view <package-name>
   version` (if the name is already known) to see whether this is a first
   publish or a version bump, and whether the local `version` in
   `package.json` is ahead of what's live.
4. **What transport does the server use?** Local stdio (spawned via
   `npx`/`node`, no public URL) or a hosted remote HTTP endpoint? Check
   the server's own README/config for how clients actually connect to it.
   This decides which Smithery path applies in Step 4. If it's not
   obvious, this is one of the things to ask about in the single gate
   below — don't guess it from what's common in older docs.
5. **What semver level does this change need?** Patch/minor/major, per
   semver applied to the actual change — not a default:
   - **Patch** (`x.y.Z`) — bug fixes, docs, internal refactors; no change
     to the tool/API surface a client would notice.
   - **Minor** (`x.Y.z`) — new tools, new optional parameters, new
     capabilities — backwards-compatible additions.
   - **Major** (`X.y.z`) — anything that breaks an existing client: a
     removed/renamed tool, a required-parameter change, a changed
     response shape, a changed transport/auth requirement.
   If the user has already said what kind of change this is ("just a
   bugfix", "I added a new tool"), map that to the level above. If it
   isn't obvious from the actual diff, this is one of the things to
   confirm in the gate below — don't guess silently, since picking the
   wrong level here is exactly the kind of inconsistency this skill
   exists to prevent.

Only proceed to the steps below once you know: which directory to publish
from, whether any sync/build step precedes it, what the current live
version is, the server's transport, and the semver level for this release.

**Stop here — one consolidated round of questions, then one explicit
go-ahead, before touching Steps 2–5. Ask all of this together, don't
re-ask it piecemeal as you reach each step:**

1. **Scope** — which destinations does this release cover? npm (Step 2)
   is assumed; confirm whether the MCP Registry (Step 3), Smithery
   (Step 4), and/or the one-time "other listings" (Step 5) are in scope
   for this release. Treat silence on Registry/Smithery/Step 5 as **no**,
   not as implied yes.
2. **Semver level** (only if step 0.5 above didn't already settle it) —
   patch, minor, or major?
3. **MCP Registry login method** (only if Registry is in scope) — GitHub
   is the default `mcp-publisher` login this skill assumes; confirm that's
   right if it's not already obvious.
4. **Server transport** (only if Smithery is in scope and step 0.4 above
   didn't already settle it) — remote HTTP URL, or local stdio needing
   MCPB bundling?
5. **Go-ahead** — explicit "yes, publish this version" for *this specific
   release*. These are public, hard-to-reverse actions (npm blocks
   unpublishing a version after 72 hours; MCP Registry/Smithery listings
   have no clean delete), so a general "publish this" request earlier in
   the conversation doesn't count as this go-ahead — get it here, for this
   release, then proceed without asking again per-destination.

What this gate does **not** cover: the *mechanical* waits inside Steps
2–4 (completing a browser/device-code login, entering a 2FA code). Those
are physical actions the user performs in the moment the CLI actually
prompts for them, not decisions — each step still pauses in place for
those regardless of what's answered here.

---

## Step 1 — Bump the version, commit, and tag

Using the semver level determined in Step 0 (and confirmed in the gate,
if it wasn't obvious):

**If the package does *not* ship a `server.json`**, `npm version` does the
whole job in one command — bump, commit, and tag together (requires a
clean working tree, which it enforces itself):

```bash
npm version <patch|minor|major>   # bumps package.json, commits "vX.Y.Z", tags vX.Y.Z
```

**If the package *does* ship a `server.json`** (used by the official MCP
Registry), `npm version`'s auto-commit won't pick that file up, and it
refuses to run at all against a dirty tree — so do it manually instead of
fighting the tool. Regenerate the lockfile too, not just `package.json` —
a hand-edited version bump leaves `package-lock.json`'s own version field
stale unless something re-syncs it:

```bash
# bump `version` in package.json AND server.json's version field
# (and the matching packages[].version field) to the same value —
# the registry rejects a server.json whose version doesn't match npm
npm install --package-lock-only   # syncs package-lock.json's version to match
git add package.json package-lock.json server.json
git commit -m "vX.Y.Z"
git tag "vX.Y.Z"
```

**Either way** (whichever path above applies), push the **commit** now —
that's cheap to correct later if something's off. **Don't push the tag
yet**, even though `npm version` already created it locally:

```bash
git push
```

Hold `git push --tags` (or `git push origin "vX.Y.Z"`) until Step 2 below
confirms the npm publish actually succeeded. A tag that's already been
pushed for a version that never made it to npm is a mess to clean up —
deleting a pushed tag is a disruptive, hard-to-reverse action in its own
right — so the fix is simply not pushing it until it's earned. The tag
push happens at the end of Step 2's verify block.

**Out of scope, on purpose:** this skill creates a git tag but not a
GitHub Release. If your workflow wants one, run something like
`gh release create vX.Y.Z --generate-notes` yourself afterward — release
notes are a separate, more editorial step this skill won't presume to
write for you.

If step 0 revealed a sync step (e.g. a monorepo package copied into a
separate release repo), do the sync — and the commit+tag+commit-push
above — in the release repo (the one Step 0 established as where
`npm publish` actually runs from), not the monorepo. Build first, then
copy/commit/push. Don't invent file lists; copy whatever step 0 showed
you actually gets synced.

## Step 2 — Publish to npm

**Pre-flight checks — run before the auth check below, and before
`npm publish` itself** (see `references/npm-publish.md` for why each one
matters and what to do if one fails):

```bash
git status          # working tree should be clean
npm run build        # if the package defines a build script — must succeed
npm test             # if the package defines a test script — must pass
npm run lint         # if the package defines a lint script — must pass
npm pack --dry-run   # preview exactly what would be published, without publishing
```

If any of these are dirty, fail, or look wrong in the file list, **stop**
— see `references/npm-publish.md` for what each failure means.

Only move on once all of the above pass. `npm publish` requires an
authenticated npm session:

```bash
npm whoami   # errors if not logged in
```

If that errors, **stop here and tell the user to log in** or configure a
token — don't authenticate on their behalf (`references/npm-publish.md`
has the exact commands). Wait for them to confirm before continuing.

The pre-flight `npm run build` above already produced the build this
publishes — don't build again here. From the directory established in
step 0:

```bash
npm publish
```

**Verify — don't assume exit 0 means it's live:**

```bash
npm view <package-name> version
```

Confirm this returns the version you just bumped to. Only report this
step done once it matches. If `npm publish` failed instead, see
`references/npm-publish.md` for the error table (403 duplicate version,
402 payment required, EOTP, collaborator/403, 401 — each has a distinct,
non-interchangeable recovery).

**Now — and only now — push the tag from Step 1:**

```bash
git push origin "vX.Y.Z"
```

This is the point the tag stops being provisional. If `npm publish`
failed above, don't push the tag — fix the failure, and if the fix
changes the version number, the old local tag is now wrong too (delete
and recreate it locally, at the new version, before eventually pushing).

## Step 3 — Publish to the official MCP Registry

(Skip this step entirely if Registry wasn't in scope per the Step 0 gate.)

One-time setup: install the `mcp-publisher` CLI, then log in with the
method confirmed in the Step 0 gate (GitHub is the default). Login blocks
on a browser approval — run the command, then wait for the user to
confirm they've completed it before continuing (see
`references/mcp-registry.md` for login-method and JWT-lifetime detail):

```bash
mcp-publisher login github        # device-code flow
```

Then, from the directory containing `server.json`, run the dry run — but
don't chain straight into the real publish after it:

```bash
mcp-publisher publish --dry-run
```

**Observed, not a documented guarantee:** `--dry-run` has been seen to
**not** be a true no-op on at least one CLI version — it can actually
submit the release. So check before deciding whether to run the plain
publish:

```bash
curl -s "https://registry.modelcontextprotocol.io/v0/servers?search=<name>"
```

- If the version you just bumped to is **already listed** with `status:
  active`, the dry-run submitted it — you're done. Do **not** run
  `mcp-publisher publish`; it will 400 with "cannot publish duplicate
  version."
- If it's **not listed yet**, the dry-run only validated. Proceed:

```bash
mcp-publisher publish
```

Then re-run the same `curl` check to confirm the real publish landed —
don't report this step done on the CLI's exit code alone. If it failed
instead, see `references/mcp-registry.md` for the error table (400
duplicate version, 401 expired token, schema validation errors, 403
naming/ownership).

**Documented registry rule (fact, not heuristic):** if auth is via
GitHub, `server.json`'s `name` field must be namespaced
`io.github.<github-username>/...` (or a verified org equivalent).

## Step 4 — Publish/update on Smithery

(Skip this step entirely if Smithery wasn't in scope per the Step 0 gate.)

Smithery's `mcp publish` command only accepts a URL (hosted/remote
servers) or an **MCPB bundle** — never a raw `npx` command. Use the
transport confirmed in the Step 0 gate: if it's a hosted remote HTTP
endpoint, publish that URL directly and skip bundling entirely —

```bash
smithery mcp publish https://your-server-url
```

Otherwise (local stdio): **stop and load `references/smithery.md` in full
before writing `manifest.json` or running anything below.** It's not
optional background reading — it lists the required `manifest.json`
fields (`user_config` entries, `sensitive` flags, entry-point wiring), and
a bundle built without them fails `mcpb validate` or, worse, silently
ships without required config. Once you've read it and built
`manifest.json` accordingly:

```bash
npx @anthropic-ai/mcpb validate manifest.json
npx @anthropic-ai/mcpb pack . <package-name>.mcpb
smithery auth login
smithery mcp publish <package-name>.mcpb -n <namespace>/<package-name>
```

`smithery auth login` opens a browser-based login and blocks until the
user completes it — wait for their confirmation before running `publish`.
Note the printed "Namespace" (org-scoped); publish needs
`<namespace>/<name>`, not just the package name.

**Verify — don't assume the CLI's exit code is the last word:**

```bash
curl -s "https://registry.smithery.ai/servers/<namespace>/<name>"
```

`smithery mcp search` specifically **lags** — returning nothing there is
not evidence of failure, don't use it as the check. If publish or
bundling failed instead, see `references/smithery.md` for the error table
and the republish procedure for future versions.

## Step 5 — Other listings (one-time, not per-release, and optional)

(Skip this step entirely unless "other listings" was confirmed in scope
per the Step 0 gate — they're not part of a routine release.) See
`references/other-listings.md` for PulseMCP, Glama, mcp.so, and
awesome-mcp-servers submission details.

---

## Checklist

1. Determine setup facts (step 0): publish location/sync, live version,
   transport, semver level — don't guess.
2. Consolidated gate (end of step 0): scope, semver level (if not already
   settled), registry login method, transport (if not already settled),
   and go-ahead — asked once, together.
3. Bump `version` using the semver level from the gate (`npm version`, or
   manual bump + `npm install --package-lock-only` + commit + tag if
   `server.json` is present), sync if step 0 showed a sync step exists.
   Push the commit now; **hold the tag push** until publish is verified.
   (A GitHub Release is a deliberate non-goal — do that separately if
   wanted.)
4. Pre-flight checks (build happens here, once), then `npm publish`
   (no rebuild), then verify with `npm view <package-name> version` —
   only once that's confirmed, push the tag. Don't push a tag for a
   version that didn't actually make it to npm.
5. If Registry was in scope: dry-run, check the registry API *before*
   deciding whether to run the plain `publish`, then verify again after.
6. If Smithery was in scope and it's local stdio: load
   `references/smithery.md` before writing `manifest.json` — not
   optional. Then URL-publish (remote) or MCPB bundle+publish (local),
   and verify via the registry API/releases page — not
   `smithery mcp search`, which lags.
7. If "other listings" was in scope: work through step 5's list. Otherwise
   stop here.

