How-to › Expose your API to agents

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.

Audience
DevRel
Level
intermediate
Topic
Host, transport and secure an MCP server
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A self-hosted server card, as on voxgig.comA server you host, whose entry you want to change without asking anyoneNothing discovers it unless it already knows your domain, and no registry API returns it, so it is documentation with a JSON bodyYou want catalogues to find the server on their own
The official MCP registry with the publisher CLIA server on npm, PyPI, NuGet, crates.io, an OCI registry, or a public URL, and a namespace you can proveA 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 itThe server is private, or runs only inside your network
Third-party MCP cataloguesA catalogue your users already open, such as an editor’s marketplace or the Docker catalogueEach has its own submission path and review, the entry is theirs to remove, and an update reaches users when they scrape or review againOne 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.

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

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

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

  3. 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.