Being on npm and behind a public URL has not put your MCP server in any client catalogue. A user who wants it types the package name into a config file by hand, gets the transport wrong, and files the bug against you. The registry the catalogues feed from has no entry, because nobody wrote one.
What you get
You will end up with a server.json that validates, a listing you can fetch back from the
registry API, and a release job that publishes again on every tag. This is for you if you
maintain an MCP server that other people install.
Short answer
Write a server.json against the official schema, start it with mcp-publisher init, log in
with mcp-publisher login github, and publish with mcp-publisher publish. The registry
stores metadata only, so the npm package goes out first and carries the server’s name as
mcpName. Automate it in the release workflow with GitHub OIDC, and check the package is on
npm before the publish step runs.
You will need
A published npm package or a public server URL, a GitHub account or a domain you control,
and Node 22 or later for the checks below. Verified 2026-09-25 against Node 22.22.2, ajv
8.20.0, and ajv-formats 3.0.1, with mcp-publisher 1.8.1 run by hand. The registry
documents itself under modelcontextprotocol.io/registry,
and every page there opens with a notice that the registry is in preview and may reset its
data.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A self-hosted server card, as on voxgig.com | A server you host, whose entry you want to change without asking anyone | Nothing discovers it unless it already knows your domain, and no registry API returns it, so it is documentation with a JSON body | You want catalogues to find the server on their own |
| The official MCP registry with the publisher CLI | A server on npm, PyPI, NuGet, crates.io, an OCI registry, or a public URL, and a namespace you can prove | A namespace tied to a GitHub login or a DNS record, a release step that must run after the package publish, and a listing that goes stale the release you forget it | The server is private, or runs only inside your network |
| Third-party MCP catalogues | A catalogue your users already open, such as an editor’s marketplace or the Docker catalogue | Each has its own submission path and review, the entry is theirs to remove, and an update reaches users when they scrape or review again | One listing that every catalogue can read |
Voxgig maintains the site at voxgig.com, whose server card is the self-hosted example here. This page compares it with the official registry and with third-party catalogues.
The official registry is metadata only. It stores your server.json, checks that the
package it names exists and carries your server’s name, and never hosts code. A self-hosted
card is a file on your domain that nothing crawls. The third-party catalogues sit downstream
by design: the registry’s own documentation calls them
aggregators, asks them to
scrape it about once an hour, and tells them to keep their own copy.2
Write server.json against the schema
mcp-publisher init writes a template with TODO placeholders and a package entry it
detects from package.json. The finished file for a server that ships as an npm package and
also runs at a public URL:
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "com.example/acme-docs",
"title": "Acme docs",
"description": "Search and read the Acme product documentation.",
"websiteUrl": "https://docs.example.com",
"repository": {
"url": "https://github.com/example/acme-docs-mcp",
"source": "github"
},
"version": "1.2.0",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "@example/acme-docs-mcp",
"version": "1.2.0",
"transport": {
"type": "stdio"
}
}
],
"remotes": [
{
"type": "streamable-http",
"url": "https://docs.example.com/mcp"
}
]
}
Three fields carry rules the schema alone does not state. The name is a namespace, a slash,
and a server name. The
authentication guide ties the
namespace to how you log in: io.github.<user>/... for a GitHub login, and the reverse of a
domain you prove by DNS or HTTPS for anything else. The version is an exact version, never
a range, and the schema
says ranges are rejected. Its test for a range is broader than semver’s: any dotted version
with an x in it counts, so the prerelease 1.0.0-next.1 is refused, and latest is reserved.
A package’s registryBaseUrl may name only the public registries
the package types guide lists, so a
mirror or a private registry is refused.
mcp-publisher validate checks a file by sending it to the registry, which checks in two
passes: the file’s shape, then its contents. A file that fails the first pass never reaches the
second, so the version range in the broken example surfaces only once the shape errors are
fixed. The validator here runs the same schema offline, with the prose rules added, and reports
everything at once:
node demo.mjs
server.json
valid
fixtures/server-broken.json
/name must match pattern "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$"
/packages/0 must have required property 'transport'
/remotes/0/type must be one of streamable-http, sse
/version is a range, and the registry rejects ranges
/packages/0/registryBaseUrl must be https://registry.npmjs.org; private registries and mirrors are refused
/remotes/0/url must be https
/_meta/com.example/build is dropped on publish; only io.modelcontextprotocol.registry/publisher-provided is kept
The last line is a rule to know before you use _meta for build stamps. The
official registry requirements
say that only the io.modelcontextprotocol.registry/publisher-provided key survives
publishing, capped at 4 KB, and that every other key is dropped without a word.
Prove the namespace and publish once by hand
The registry verifies two things at publish time: that you own the namespace, and that you
own the package. For npm the second is a field in package.json that must equal the server
name:
{
"name": "@example/acme-docs-mcp",
"version": "1.2.0",
"mcpName": "com.example/acme-docs"
}
Publish the package first, because the registry reads that field from npm, not from your checkout. Then log in and publish the server:
mcp-publisher login github
The login is a device flow: it prints a URL and a code, and grants io.github.<user>/*. A
com.example namespace needs mcp-publisher login dns instead, with a TXT record on the
apex of the domain, or login http with a file under /.well-known/. Then:
mcp-publisher publish
The command prints the name and version it published. Fetch it back from the API by name,
with the slash URL-encoded, and latest as the version:
curl "https://registry.modelcontextprotocol.io/v0.1/servers/com.example%2Facme-docs/versions/latest"
The response carries your server.json under server and a second _meta block at the
top level that the registry owns: status, publishedAt, updatedAt, and isLatest. That
outer block is where a deprecation or a removal shows up, and it is the field the
aggregators are told to
keep current in their own copies.
Publish from the release workflow
A listing published by hand is a listing that drifts. The version in the registry stays at the last release someone remembered, while npm and the code move on. The fix is a job on the release tag, and the GitHub Actions guide gives its shape, with OIDC so the job holds no registry secret at all:
- name: Publish the package first, so the registry can find it
run: npm publish --access public
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: Check the package is on npm with the right mcpName
run: node check-release.mjs server.json
- run: ./mcp-publisher validate server.json
- run: ./mcp-publisher login github-oidc
- run: ./mcp-publisher publish server.json
The job needs id-token: write in its permissions for login github-oidc to mint a token,
and check-release.mjs runs between the npm publish and the registry publish. It fetches
each npm package’s listing from npm and the server’s latest version from the registry. It
fails the job on the two mistakes that otherwise fail later with less to go on:
for (const p of (server.packages ?? []).filter((x) => x.registryType === 'npm')) {
const published = packuments?.[p.identifier]?.versions?.[p.version]
if (!published) findings.push(fail(`${p.identifier}@${p.version} is not on npm: publish the package before the server`))
else if (published.mcpName !== server.name) findings.push(fail(`${p.identifier}@${p.version} carries mcpName ${published.mcpName ?? '(none)'}, server.json says ${server.name}`))
else findings.push(ok(`${p.identifier}@${p.version} is on npm with mcpName ${server.name}`))
}
The demo walks one release through the checks with the npm and registry responses as fixtures:
node demo.mjs
before npm publish
ok server.json and package.json agree on 1.2.0
FAIL @example/acme-docs-mcp@1.2.0 is not on npm: publish the package before the server
info the registry lists 1.1.0, this release is 1.2.0: publish moves it
after npm publish, with the wrong mcpName
ok server.json and package.json agree on 1.2.0
FAIL @example/acme-docs-mcp@1.2.0 carries mcpName io.github.example/acme-docs, server.json says com.example/acme-docs
info the registry lists 1.1.0, this release is 1.2.0: publish moves it
after npm publish, before mcp-publisher publish
ok server.json and package.json agree on 1.2.0
ok @example/acme-docs-mcp@1.2.0 is on npm with mcpName com.example/acme-docs
info the registry lists 1.1.0, this release is 1.2.0: publish moves it
after both
ok server.json and package.json agree on 1.2.0
ok @example/acme-docs-mcp@1.2.0 is on npm with mcpName com.example/acme-docs
ok the registry already lists 1.2.0
the next release, with server.json forgotten
FAIL server.json is 1.2.0, package.json is 1.3.0: bump server.json before publishing
ok @example/acme-docs-mcp@1.2.0 is on npm with mcpName com.example/acme-docs
ok the registry already lists 1.2.0
The first stage is the ordering pitfall: a server.json that names a package version npm
does not have yet. The last stage is the drift pitfall: a release that bumped package.json
and left server.json behind, which the registry would accept and list as an old version
of a new package. The workflow in the previous section avoids the second by writing the tag’s version into
server.json before either publish.
Check it worked
The literal to look for is is not on npm against the first fixture, and valid for the
committed server.json. Eleven tests pin both, and the shape of every message the release
check can print.
node --test registry.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 253.069772
The tenth test lists two npm packages whose listings differ, and asserts that each is
checked against its own. The last test hands the fetcher a fake fetch and asserts the two
URLs it asks for, with the slash in the server name encoded. It also asserts that a 404 from
the registry comes back as not listed rather than as an error, and that a service that never
answers fails the check at its timeout.
When it goes wrong
The publish step reports “Registry validation failed for package” and stops. The package on
npm has no mcpName, or it names a different server. Add the field, publish the package again, and
publish the server again; the registry reads npm, not your working tree.
The publish step reports “You do not have permission to publish this server” and stops. The
namespace in name does not match how you logged in. A GitHub login grants
io.github.<user>/* and nothing else, and an organization namespace needs an Owner of that
organization.
The release job publishes the package, and the check step fails with the package not on npm.
npm had not served the new version yet when the check looked, and the registry would have
seen the same. Run the job again; a pause after npm publish narrows the race without
closing it.
DNS login fails with a signature error although the record is there. The authentication guide wants the TXT record on the apex of the domain, not under a selector. It says a stale record from a previous key is tried first and fails the check.
The registry lists an old version. Nobody ran publish after the release. The release check
prints the gap as an info line before every publish, and the workflow closes it.
When not to do this
Do not list a server that only runs inside your network. The remote servers guide says a remote must be publicly accessible at its URL, and a listing nobody can reach is a bug report waiting for a reporter.
Do not publish a server.json whose package version is not on npm yet. The registry checks
the package at publish time, so the publish fails, and a job that continues past that
failure leaves the old version listed and the new one nowhere.
Do not treat the registry as where people read about your server. It stores a description
and a websiteUrl, and the moderation policy is deliberately thin.3 Keep the
documentation on your own domain and point websiteUrl at it. The Voxgig site’s
server card is that kind of page: the
same fields as a registry entry, at a URL the site controls. It is worth keeping beside a
registry listing, but it does not replace one.
Do not build a client on the registry API without a cache. Its documentation offers no uptime or durability guarantee, and it asks consumers to scrape hourly and keep their own copy. A client that queries it on every start has ignored the one operating instruction it was given.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, ajv 8.20.0, and ajv-formats 3.0.1. Every
output block is what the command preceding it printed. mcp-publisher 1.8.1 was run by
hand against the committed server.json, which it reported valid, and against the broken
fixture, which the registry refused with the name pattern as its one reported error. No
server was published: the npm and registry responses in the output are fixtures in the
shape the two APIs returned for real servers on the day.
Footnotes
-
Every page of the registry documentation, the quickstart included, opens with the same box: the registry is in preview, and breaking changes or data resets may occur before general availability. A registry is a thing other systems are built to depend on, and this one begins each page by asking them not to, yet. ↩︎ Back to text
-
The aggregators page states in bold that the registry does not provide uptime or data durability guarantees. It asks aggregators to scrape it on a regular but infrequent basis, once an hour being its example, and to persist the data in their own store. Server metadata is described as generally immutable except for
status, which is the one field the page asks them to keep current. So the design is a source of truth that expects to be copied, and a copy that is expected to check back for the one field that changes. ↩︎ Back to text -
The moderation policy describes itself as quite permissive. It removes illegal content, malware, spam, and servers that do not function. It will not remove servers that are low quality, that have security vulnerabilities, or that do the same thing as other servers. Removal sets
statustodeletedwhile the metadata stays readable through the API. A listing, on this policy, is a claim of existence and little else, which is also what the registry says it is for. ↩︎ Back to text