Skip to content
Skip to main content
The Model Context Protocol, npm and GitHub Actions logos as app tiles over a dark emerald network background, representing publishing an MCP server to the official MCP Registry
8 min readBy Carlos Aragon

Publish an MCP Server to the Official Registry

Publishing to the official MCP Registry is five commands and one gotcha. The registry stores metadata, not code, so you publish your package first, and the registry refuses the entry unless that published package carries an ownership marker pointing back at your server name. For npm that marker is mcpName in package.json — and you want it in there before your first publish, not after.

The Whole Thing, In Order

Here is the entire flow before I explain any of it, because most people reading this just want the sequence:

# 1. mark the package (package.json)
#    "mcpName": "io.github.you/your-server"

# 2. ship the artifact
npm publish --access public

# 3. get the CLI
brew install mcp-publisher

# 4. describe the server
mcp-publisher init          # writes server.json — edit name + version

# 5. claim the namespace and publish
mcp-publisher login github
mcp-publisher publish

# 6. prove it landed
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.you/your-server"

That's it. If it fails, it almost always fails at step 5 with a message about validation, and the cause is almost always a mismatch between step 1 and step 4. The rest of this post is why, what changes for non-npm packages, and how to stop doing it by hand.

What the Registry Is — And What It Isn't

The MCP Registry is a metadata index. It knows your server's name, description, repository, version, transport, required environment variables, and where the package lives. It does not host a single byte of your code. Think npm's search page without npm's storage.

That design decision explains every friction point you'll hit. Because the registry doesn't own the artifact, it has to prove that whoever is claiming the name io.github.you/your-server actually controls the package that name points to. It does that by reaching into the published artifact and looking for a marker you put there. No marker, no entry. The package is the proof.

Worth saying plainly: you do not need the registry to distribute an MCP server. When I shipped the Hyros MCP server, distribution was a README, an npm package and a config snippet, and it picked up around 100 installs in the first 24 hours on that alone. The registry doesn't replace that. What it adds is a verified name-to-package link and a feed that clients and downstream catalogues can read, which is the part a README can never give you.

It's also still labelled preview, and the docs are explicit that data resets can happen before general availability. Publish, but keep your install instructions in the README too. Don't make a preview service your only distribution path.

Do This Before Your First npm publish

This is the one step that costs people a version number. The ownership marker has to be inside the published artifact. npm versions are immutable — you cannot edit package.json on a version that's already out. So if you publish 1.0.0 and then discover the marker is missing, your fix is to cut 1.0.1 whose only change is one line of JSON. Harmless, but avoidable, and it looks sloppy in a changelog.

{
  "name": "@you/weather-mcp",
  "version": "1.0.1",
  "mcpName": "io.github.you/weather"    // <- the marker
}

The value of mcpName is your registry server name, not your npm package name. They're allowed to differ and usually do — the npm name is scoped to your npm org, the registry name is scoped to whatever namespace you can prove you own. Under GitHub authentication that namespace is io.github.your-username/ and the registry will reject anything else.

Where the Marker Goes For Each Package Type

Same idea, five different hiding places. Get this row right and the publish step is boring.

registryTypeWhere the marker livesWhat it looks like
npmpackage.json"mcpName": "io.github.you/x"
pypiREADME / package description<!-- mcp-name: io.github.you/x -->
nugetREADME<!-- mcp-name: io.github.you/x -->
ociImage annotation / DockerfileLABEL io.modelcontextprotocol.server.name="…"
mcpbThe URL itself, plus a hashURL must contain mcp; add fileSha256

Two notes people trip on. For PyPI and NuGet the marker can be an HTML comment, so it doesn't have to be visible on your package page — but it does have to survive into the rendered description, which means it belongs in the README that actually gets uploaded. For OCI images, the container registries currently accepted are Docker Hub, GHCR, Google Artifact Registry, Azure Container Registry and MCR; a self-hosted registry won't validate.

server.json: The Part That Has To Match

Run mcp-publisher init and you get a scaffold derived from your project. It's close, but it is not correct out of the box — the version it guesses is usually stale and it invents a placeholder environment variable you probably don't need.

{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
  "name": "io.github.you/weather",          // === mcpName in package.json
  "description": "An MCP server for weather information.",
  "repository": { "url": "https://github.com/you/weather-mcp", "source": "github" },
  "version": "1.0.1",
  "packages": [
    {
      "registryType": "npm",
      "identifier": "@you/weather-mcp",     // the npm package name
      "version": "1.0.1",                   // must exist on npm already
      "transport": { "type": "stdio" }
    }
  ]
}

Three equalities have to hold or the publish is rejected: name equals mcpName, the package identifier is a package that exists, and the package version is a version that has actually been published. That last one bites in CI, where it's easy to bump the git tag and forget the JSON.

If you're publishing a hosted server rather than a package, you swap packages for a remotes entry pointing at your HTTPS endpoint. Do that only after you've sorted authentication on the remote server, because a public listing is an advertisement and your endpoint will get found.

Do It Once By Hand, Then Never Again

My rule for anything I ship publicly: the release is a git tag, and everything downstream of the tag is a robot's problem. I already release my npm packages that way — push v0.1.4, GitHub Actions builds, tests and publishes, and no npm token ever touches my laptop. The registry step slots into the same workflow after the npm step, and it doesn't even need a secret if you use OIDC:

name: Publish to MCP Registry
on:
  push:
    tags: ["v*"]

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write        # <- required for OIDC, easiest thing to forget
      contents: read
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with: { node-version: "lts/*" }
      - run: npm ci
      - run: npm run test --if-present
      - run: npm run build --if-present
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Install mcp-publisher
        run: |
          curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher

      - name: Keep server.json in sync with the tag
        run: |
          VERSION=${GITHUB_REF#refs/tags/v}
          jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' \
            server.json > server.tmp && mv server.tmp server.json

      - run: ./mcp-publisher login github-oidc
      - run: ./mcp-publisher publish

The jq step is the one I'd argue for hardest. The official example has it commented out and only rewrites the top-level version. Rewrite both — the top-level version and the package version — because the drift between those two is the single most common cause of a red release job. Derive them from the tag and the file can never disagree with reality.

If OIDC isn't an option, the alternative is login github --token with a PAT carrying read:org and read:user. Prefer OIDC. It's one fewer long-lived credential in a repo, and the id-token: write permission is free.

The Three Errors You'll Actually See

The CLI's error messages are terse and one of them is a catch-all, so here's the translation table:

MessageWhat it really meansFix
Registry validation failed for packageMarker missing, misspelled, or on a version that isn't publishedCheck the marker in the published artifact, then the name and version equalities
You do not have permission to publish this serverYour auth method doesn't own that namespaceWith GitHub auth the name must start io.github.your-username/
Invalid or expired Registry JWT tokenYour login expired between init and publishRe-run mcp-publisher login github

The first row is the one worth internalising: it is a single message covering namespace mismatch, version mismatch, a missing marker and a schema error. It will not tell you which. So when you hit it, don't debug creatively — walk the three equalities in order and one of them will be wrong.

A quick sanity check that costs nothing: pull your own published package back down and look for the marker in what npm actually serves, not in your working tree.

npm view @you/weather-mcp mcpName
# io.github.you/weather   <- if this is empty, that's your bug

Is It Worth Publishing At All?

Depends entirely on who your server is for, and I think the honest split is sharper than the ecosystem hype suggests.

Internal servers: no. If your MCP server wraps a private API for one team, a listing on a public index does nothing for you and quietly publishes your tool names, descriptions and required environment variables to anyone reading the API. That is a small but real disclosure. Keep it in your own repo.

Public tools: yes, and do it early. Namespaces are claimed on a first-come basis, and the annoying part isn't publishing — it's that the marker has to be in the package before the package ships. Adding one line to package.json on day one costs you nothing and keeps the option open. Deciding six versions later costs you a release.

The wider point is that MCP is quietly turning into a packaging problem rather than a protocol problem. Which is progress — but it also means the failure modes are now supply-chain shaped. A registry entry proves a name maps to a package. It proves nothing about what the package does when a model calls it, which is exactly the gap that makes tool poisoning worth understanding before you start installing servers off an index. And while you're deciding what to publish, it's worth asking whether the thing you're building should be an MCP server at all or a skill— and what the server will cost in context tokens every single time it loads.

If you do one thing today:

Open the package.json of any MCP server you might ever publish and add "mcpName": "io.github.you/that-server". One line, no behaviour change, and it means the registry is a ten-minute decision later instead of a version bump.

Frequently Asked Questions

Does the MCP Registry host my server code?

No. The MCP Registry stores metadata only — the server name, description, repository, version, transport and a pointer to where the package actually lives. Your code still ships through npm, PyPI, NuGet, a container registry or a GitHub release. That is why you publish the package first and the registry entry second, and why the registry needs an ownership marker inside the published artifact to prove the two belong together.

What does the error “Registry validation failed for package” mean?

It means the registry fetched your published package and could not find a valid ownership marker matching your server name. It is a catch-all, so check three things in order: the marker exists in the version you actually published, the marker string is character-for-character identical to the name field in server.json, and the version in server.json matches a version that exists in the package registry. Republishing a fixed version of the package resolves it in most cases.

Can I use my own domain instead of io.github.username?

Yes. GitHub authentication only authorises names under io.github.your-username, but the registry also supports DNS authentication, which lets you claim a namespace derived from a domain you control by proving ownership with an Ed25519 key published in DNS. That is the right path for a company-branded server name. If you publish under GitHub auth first and move to a domain later, the new name is a separate registry entry, not a rename.

Do I need to publish to the registry for people to install my MCP server?

No, and that is worth being honest about. Anyone can install your server from npm or a Docker image with a config snippet from your README, which is how nearly every MCP server was distributed before the registry existed. The registry buys you discoverability inside clients and downstream catalogues that read from it, plus a verified link between the server name and the package. It is a distribution channel, not a requirement.

How do I publish a remote MCP server that has no package?

A remote server is published with a remotes entry pointing at your HTTPS endpoint instead of a packages entry, and ownership is proven against the domain rather than a package artifact. You will want DNS or HTTP authentication for the namespace, and you should have authentication on the endpoint itself before you advertise it publicly, because a registry listing is an invitation for traffic you did not previously have.

Building an MCP server your team will actually depend on?

Getting it listed is the easy half. The hard half is the part nobody demos: auth on a remote endpoint, tool descriptions that don't eat half the context window, a release pipeline that can't ship a broken version, and knowing which of your integrations should be an MCP server versus a plain API call. I build and ship this kind of thing for teams — including the unglamorous release and observability plumbing around it. If you've got a server that works on your laptop and you're not sure what production looks like, that's a good conversation.

Related Posts

AI Agents

MCP Tasks: How Long-Running MCP Tools Stop Timing Out

A tool that takes two minutes cannot be a blocking JSON-RPC call — something between your client and your server will kill it. The MCP Tasks extension hands back a task handle instead: taskId, ttlMs, pollIntervalMs, five states, three methods. Here is the full lifecycle, the migration from the 2025-11-25 experimental version, and the four bugs I hit rolling my own poll loop.

AI Agents

MCP Sampling Is Deprecated. Use Elicitation Instead.

Spec revision 2026-07-28 deprecated Sampling, Roots and Logging together under SEP-2577, with removal eligible from 2027-07-28. The migration path is one line: call LLM provider APIs directly. Elicitation survived — because asking the human is the one thing no provider API can do for you — and it grew a URL mode that's now mandatory for anything secret. Plus the delivery change that turns blocking tool handlers into re-entrant ones, and the phishing attack hiding in URL mode.

AI Agents

MCP Apps: Interactive UI Inside Your AI Client

MCP Apps are the first official Model Context Protocol extension (shipped Jan 26, 2026): an MCP tool can now return a real interface — a dashboard, form, chart, or multi-step wizard — that the client renders in a sandboxed iframe right inside the chat, instead of plain text. Three parts make it work: a ui:// resource (bundled HTML/JS), a tool linked to it via _meta.ui.resourceUri, and an App class that speaks two-way JSON-RPC over postMessage so the UI can receive the tool result, call server tools, and push the user's selection back into the model's context. It's a cross-client standard — Claude, ChatGPT, VS Code, and Goose already render the same UI resource. Reach for an App only when the user needs to see or manipulate something; plain text tools still win for short answers. Bonus: rendering data in a UI instead of narrating 500 rows back through the model can cut token cost, not add it.