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.1on the admin listener, behind the same OIDC bearer as the rest of the admin API.
Tell the gateway where its front door is
Section titled “Tell the gateway where its front door is”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.comIt 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.
Make a configured server discoverable
Section titled “Make a configured server discoverable”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.
Let a team publish its own servers
Section titled “Let a team publish its own servers”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.
What this registry deliberately is not
Section titled “What this registry deliberately is not”- 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 .../versionsexists 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/updatedAton reads, because the store keeps revisions, not timestamps.updated_sinceis 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.