# How to list an MCP server in the official MCP registry

> Publish a server.json to the official MCP registry, prove your namespace from CI with GitHub OIDC, and keep the listing level with each release.

Source: https://voxgig.com/howto/list-a-server-in-the-mcp-registry

- Audience: devrel
- Level: intermediate
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://modelcontextprotocol.io/registry/about),
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](https://voxgig.com/developers) | 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](https://modelcontextprotocol.io/registry/quickstart) | 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](https://docs.docker.com/ai/mcp-catalog-and-toolkit/catalog/) | 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](https://modelcontextprotocol.io/registry/registry-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:

```json title="server.json"
{
  "$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](https://modelcontextprotocol.io/registry/authentication) 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](https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json)
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](https://modelcontextprotocol.io/registry/package-types) 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:

```bash
node demo.mjs
```

```text output
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](https://raw.githubusercontent.com/modelcontextprotocol/registry/main/docs/reference/server-json/official-registry-requirements.md)
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:

```json
{
  "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:

```bash
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:

```bash
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:

```bash
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](https://modelcontextprotocol.io/registry/registry-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](https://modelcontextprotocol.io/registry/github-actions)
gives its shape, with OIDC so the job holds no registry secret at all:

```yaml title="publish-mcp.yml"
      - 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:

```ts title="check-release.mjs"
  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:

```bash
node demo.mjs
```

```text output
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.

```bash
node --test registry.test.mjs
```

```text output
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](https://modelcontextprotocol.io/registry/authentication) 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](https://modelcontextprotocol.io/registry/remote-servers) 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](https://voxgig.com/.well-known/mcp/server-card.json) 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

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Connect a remote MCP server to Claude Desktop](/howto/connect-a-remote-mcp-server-to-claude-desktop)

- [Publish an OpenAPI document at a stable URL for agents](/howto/publish-openapi-at-a-stable-url)

- [Issue and rotate per-agent API keys for an MCP server](/howto/per-agent-api-keys-for-mcp)

## 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.

[^1]: Every page of the registry documentation, the
[quickstart](https://modelcontextprotocol.io/registry/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.

[^2]: The [aggregators page](https://modelcontextprotocol.io/registry/registry-aggregators)
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.

[^3]: The [moderation policy](https://modelcontextprotocol.io/registry/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
`status` to `deleted` while 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.