Skip to content

Serve a private MCP registry

The official MCP Registry lists public servers and says so. Private servers belong in a private registry implementing the same API. The gateway is that registry. Your mcp: configuration already is the enterprise catalog, the approved servers, with their credentials and policy. The gateway serves it over the registry’s frozen v0.1 API, and every entry’s connect URL is the gateway’s own door.

Registry-aware hosts and SDKs take a registry base URL. Give them

https://<admin-host>/registry/v0.1

on the admin listener, behind the same OIDC bearer as the rest of the admin API.

A registry entry hands the host a URL to connect to, and the gateway refuses to guess one from a listener or a Host header. Set the deployment’s public URL once:

public_url: https://ai.example.com

It is a deployment-wide scalar like auth, one owner across all sources, writable at PUT /admin/v1/config/sources/{source}/public_url. Until it is set, every /registry/v0.1 endpoint answers 501 and names this key.

A server becomes a registry entry when its config carries a registry block:

mcp:
- name: docs
url: https://docs.internal/mcp
registry:
name: com.example/docs
version: "1.4.0"
description: Search and read the internal docs.

registry.name is a reverse-DNS registry name, namespace, one slash, short name. The server keeps answering at /mcp/docs, and now also answers at /mcp/com.example/docs, which is the URL the registry hands out:

{
"server": {
"name": "com.example/docs",
"description": "Search and read the internal docs.",
"version": "1.4.0",
"remotes": [
{"type": "streamable-http", "url": "https://ai.example.com/mcp/com.example/docs"}
]
},
"_meta": {"io.modelcontextprotocol.registry/official": {"status": "active", "isLatest": true}}
}

The upstream URL never appears. A host discovers the same address that policy governs. That is the point of serving the registry from the gateway rather than beside it.

A server without a registry block is not listed. Discoverable is opt-in.

POST /registry/v0.1/publish takes a server.json document. The public registry authenticates a namespace with DNS and GitHub challenges. Here the namespace is a Cedar resource instance, so who may publish under com.acme/* is a line of admin policy:

@id("acme-publishes-acme")
permit(
principal in Role::"acme-platform",
action == Action::"publishRegistryServer",
resource == Resource::"registry/com.acme"
);

Governance, the fields server.json has no vocabulary for, rides in the document’s one publisher extension point:

{
"name": "com.acme/issues",
"description": "Issue tracker tools.",
"version": "2.0.1",
"remotes": [{"type": "streamable-http", "url": "https://issues.acme.internal/mcp"}],
"_meta": {
"io.modelcontextprotocol.registry/publisher-provided": {
"pistra": {
"credential": "acme-issues",
"cost_per_call": 5
}
}
}
}

credential names an entry in the credentials list. api_key is the alternative and must be an env reference like ${TOKEN}. A raw secret in a document built to be listed is refused. cost_per_call, restore and name (the /mcp/<name> path segment; defaults to the short half of the registry name) complete the vocabulary.

The publish is a configuration write wearing the registry’s schema. The entry lands in the namespace’s own source, registry-com-acme (a dot becomes a dash and a dash inside a label becomes two, so com.a-b and com.a.b never share a source), through the same composition, validation and compare-and-swap as any config write. It is readable, editable and deletable at /admin/v1/config/mcp/issues like anything else, and a read says which source holds it. Removing an entry is that config delete. There is no separate moderation API.

  • Not versioned storage. One live entry per name, latest wins. A client connecting through /mcp/<registry name> reaches whatever is deployed regardless of which version it believed in, so keeping old versions listable would promise something routing cannot honor. GET .../versions exists for API compatibility and lists one.
  • Not a package index. A packages-only server.json is refused. A package is something a host installs and runs itself, and there is no endpoint in that for the gateway to put policy in front of.
  • Not a clock. Entries carry no publishedAt/updatedAt on reads, because the store keeps revisions, not timestamps. updated_since is accepted but inert. The publish response carries the one moment the gateway witnessed.

Related: Manage the configuration through the API for sources, preconditions and per-object paths; Restrict what an admin caller can do for the Cedar policy this rides on.