Originally published at webofmike.com on 2026-10-13. The demo repo and every command in it were run before publishing.
A private MCP registry is a catalogue of approved MCP servers that your clients discover from instead of the open public one. I built private-mcp-registry to stand one up on agentregistry v0.4.0: three servers declared in YAML, served through the official MCP Registry v0.1 API, checked against the official OpenAPI document, and discovered by a client that knows nothing about agentregistry. Run as a script, the whole quickstart took 49 seconds on my laptop.
Why run a private MCP registry?
The public MCP Registry is open. Anyone can publish to it. On 2026-09-29 I searched it for fetch and got 70 servers at their latest version. None of them was mcp-server-fetch, the reference fetch server from the MCP project. A team that tells its developers "install the fetch server" has not said which of the 70 it means.
A private registry answers that once. The platform team decides which servers exist, which package they come from, and which version is approved. Every client reads that list. The approved list also becomes the baseline you need for everything else: you cannot flag a shadow MCP server without a list of the servers that are not shadows.
What changed in agentregistry v0.4.0
v0.4.0, released 2026-08-03, rebuilt agentregistry as a declarative control plane. Every artifact is now a Kubernetes-style resource under ar.dev/v1alpha1, managed with arctl apply, arctl get and arctl delete. MCP servers, agents, skills, prompts, plugins and models share the format.
Two things in the release notes matter for this demo:
-
It is not backward compatible with v0.3.x. The release says there is no in-place upgrade. The v0.3.x CLI has no
arctl applyat all. The machine I built this on had v0.3.3 in/usr/local/bin, so the repo downloads v0.4.0 into its own./binand verifies the checksum. -
The official registry API is back, opt-in and read-only. agentregistry started as a fork of the official registry, then moved its native API to
v1alpha1resources, which took it off the upstreamserver.jsoncontract. v0.4.0 adds a compatibility layer that re-exposes MCPServer resources through the v0.1 read endpoints. It is off by default.
The quickstart
Prerequisites are Docker with Compose v2, uv, Node.js for npx, curl and jq. The full command list, including the arctl download, is in the repo README.
The compose file is agentregistry's own compose file at the v0.4.0 tag, with the compatibility API switched on:
# The point of this demo: serve GET /v0.1/servers in the official
# MCP Registry shape. Off by default because it is anonymous and
# flattens every namespace. See README "Before you expose this".
AGENT_REGISTRY_MCP_REGISTRY_COMPAT_ENABLED: "true"
Start it:
docker compose up -d --wait
curl -s localhost:12121/v0/version
{"version":"v0.4.0","git_commit":"aef6563","build_time":"2026-08-03"}
Then publish the servers. One file holds three MCPServer resources, one for each source type: the MCP "everything" reference server from npm, the reference fetch server from PyPI, and DeepWiki's public remote endpoint. This is the PyPI entry:
apiVersion: ar.dev/v1alpha1
kind: MCPServer
metadata:
namespace: com.example
name: fetch
spec:
title: Fetch
description: "Fetches a URL and returns the page as markdown."
source:
package:
origin:
type: pypi
identifier: mcp-server-fetch
pypi:
version: "2026.8.18"
serverName: io.github.modelcontextprotocol/server-fetch
transport:
type: stdio
arctl apply -f registry/servers.yaml --dry-run
arctl apply -f registry/servers.yaml
✓ MCPServer/everything (latest) created
✓ MCPServer/fetch (latest) created
✓ MCPServer/deepwiki (latest) created
The package version is pinned in the manifest, and the manifest lives in Git. Adding a server to the approved list is now a pull request someone reviews.
Read it back through the official API path:
curl -s localhost:12121/v0.1/servers | jq '.servers[].server | {name, version}'
{
"name": "com.example/deepwiki",
"version": "0.0.0"
}
{
"name": "com.example/everything",
"version": "2026.8.31"
}
{
"name": "com.example/fetch",
"version": "2026.8.18"
}
The server name is <namespace>/<name>. Setting metadata.namespace: com.example gives you the reverse-DNS prefix the official naming convention uses.
About the 49 seconds: that is a script running every quickstart command in the README, from downloading arctl through the last discovery call, on an Apple silicon laptop. The agentregistry server image was not cached and the uv and npm caches were empty. The postgres:16 image was already on the machine, so a first run will also pull that. Reading and typing along adds more. Ten minutes is a comfortable ceiling.
Is agentregistry really compatible with the official MCP Registry API?
For reads, yes, and you can check it instead of taking my word for it. scripts/check_conformance.py fetches the official OpenAPI document at a pinned commit and validates three kinds of response against it:
-
GET /v0.1/serversagainst theServerListschema -
GET /v0.1/servers/{name}/versionsagainstServerList -
GET /v0.1/servers/{name}/versions/latestagainstServerResponse
It then validates every server object against the server.json schema named in its own $schema field, which is the 2025-12-11 schema on static.modelcontextprotocol.io.
uv run scripts/check_conformance.py
GET /v0.1/servers
PASS envelope vs ServerList
PASS server com.example/deepwiki vs 2025-12-11/server.schema.json
PASS server com.example/everything vs 2025-12-11/server.schema.json
PASS server com.example/fetch vs 2025-12-11/server.schema.json
...
3 servers checked, 0 schema violations
Every response passed: the list and versions envelopes against ServerList, the single-version body against ServerResponse, and each server object against the 2025-12-11 server.json schema.
A checker that never fails proves nothing, so I tested it against a bad entry too. That test turned up a real gap, covered in the gotchas below.
"Compatible" has limits, and they are worth knowing before you point a fleet of clients at it:
-
Read-only. The spec's
POST /v0.1/publishand the status endpoints are not served. You publish througharctl applyor the native API. The spec marks publish as optional, so this is allowed. It does mean tooling built on the official publish flow will not work here. -
The default listing differs. The public registry's
GET /v0.1/serversreturned every version of a server when I queried it without aversionparameter. agentregistry's returns only thelatesttag. Clients that passversion=latestsee the same behavior from both. -
versionfilters on the tag. The spec describesversionas a server version such as1.2.3. agentregistry matches it againstmetadata.tag. If your tags and package versions are the same string, you will not notice. -
Remote servers report
0.0.0. A remote entry has no package to take a version from, so it falls back to the tag, andlatestbecomes0.0.0. - Tokens are ignored. The official generic registry API lets a subregistry require authentication. agentregistry's compatibility doc says presented tokens are ignored on these routes and every caller sees the same catalogue.
How does a client discover servers from it?
scripts/discover.py uses only the v0.1 read API. It searches, takes the first match it knows how to launch (npx for npm, uvx for PyPI, streamable HTTP for remotes), connects with the MCP Python SDK, and lists the tools the server returns:
uv run scripts/discover.py fetch
http://localhost:12121/v0.1/servers?search=fetch
com.example/fetch 2026.8.18 pypi
connecting to com.example/fetch: uvx mcp-server-fetch==2026.8.18
1 tools: fetch
The npm entry launched as npx -y @modelcontextprotocol/server-everything@2026.8.31 and listed 13 tools. The remote entry connected to DeepWiki and listed 3.
The same client, with one flag changed, reads the public registry:
uv run scripts/discover.py fetch --no-connect --registry https://registry.modelcontextprotocol.io
It lists and does not launch. Running a package picked from an open catalogue, without review, is the thing a private registry exists to prevent. The public registry took about 30 seconds to answer each search when I ran this.
agentregistry also runs its own MCP server, on port 31313 in this compose file, with list_servers, get_server and matching tools for agents, skills, prompts and the other kinds. An agent that has it configured can ask the registry what exists without any HTTP client code:
npx -y @modelcontextprotocol/inspector@2.8.0 --cli http://localhost:31313/ \
--transport http --method tools/call --tool-name list_servers \
--tool-arg namespace=com.example --tool-arg search=fetch
For VS Code, agentregistry's docs say the gallery reads the organization Copilot policy McpGalleryServiceUrl and appends /v0.1/servers to it. I did not test VS Code for this post.
Gotchas
Why is /v0.1/servers empty?
{"servers":[],"metadata":{"count":0}}
That was my first result, with three servers applied. My first manifests set metadata.tag to a version on every entry, which seemed like good hygiene. The compatibility API's default listing only returns rows whose tag is the literal string latest, so all three were stored, visible to arctl get, and absent from /v0.1/servers. GET .../versions/latest returned 404 too.
The repo's servers.yaml omits metadata.tag, so every entry gets latest. registry/pinned-tag.yaml reproduces the problem:
arctl apply -f registry/pinned-tag.yaml
curl -s 'localhost:12121/v0.1/servers?search=pinned' # empty
curl -s 'localhost:12121/v0.1/servers?search=pinned&version=2026.8.18' # found
The registry's MCP list_servers tool returns every tag by default. So the same entry is visible over MCP and invisible over the v0.1 API.
arctl get mcps says "No mcps found"
arctl get mcps lists the default namespace, and these servers live in com.example. Ask by qualified name:
arctl get mcp com.example/fetch
Descriptions over 100 characters
agentregistry accepted a 132-character spec.description and served it through /v0.1/servers unchanged. The official server.json schema caps description at 100 characters, so the conformance check fails:
FAIL server com.example/longdesc vs 2025-12-11/server.schema.json: description: 'Answers questions about public GitHub repositories. This se... acters.' is too long
A client that validates against the official schema can reject that entry. Keep descriptions under 100 characters, and run the conformance check in CI against the manifests you are about to apply.
Before you expose it
The compatibility API is off by default for a reason. In the OSS build it is anonymous and flattens every namespace into one catalogue. In this compose file, the native API and the MCP server on 31313 also answered without any credentials. That is fine on localhost. Anywhere else, put it behind a gateway that authenticates callers, and treat the list of server names as information about your internal tooling.
Two limits of a registry entry are worth stating plainly.
A registry entry pins a package version. It does not pin what the server says about its tools at runtime. The server.json schema has no field for tool definitions at all. A server whose tool descriptions change after approval still passes a name check, which is the problem in MCP tool poisoning. The registry tells a client which package to run. Pinning the tool definitions it returns is a separate control.
A registry is also a write path and a list path, and that pair can carry data in both directions, as the RubyGems incident in Your Artifact Registry Is a Two-Way Channel for Agents showed. Here the write path is arctl apply. Keep it in CI, under one identity, and do not hand agents write access to the registry they discover from.
What's next
The demo stops at discovery. agentregistry v0.4.0 can also deploy what it catalogs, through Deployment and Runtime resources for local Docker and Kubernetes, and compose agents from MCP servers, skills and prompts. The next step is taking one approved server from this catalogue to a running deployment, with the conformance check as a gate in front of arctl apply. The code is at github.com/themsquared/private-mcp-registry.
Frequently asked questions
How do I run a private MCP registry?
Run agentregistry v0.4.0 and its Postgres database with Docker Compose, declare each approved server as an ar.dev/v1alpha1 MCPServer resource in YAML, and publish the file with arctl apply. Set AGENT_REGISTRY_MCP_REGISTRY_COMPAT_ENABLED=true and the registry also serves GET /v0.1/servers in the official MCP Registry shape. Run as a script, the full quickstart took 49 seconds on my laptop.
Is agentregistry compatible with the official MCP Registry API?
For reads, yes. With the compatibility API enabled, agentregistry v0.4.0 serves the three v0.1 read endpoints: list servers, list versions, and get one version. Its responses passed validation against the official OpenAPI document's ServerList and ServerResponse schemas and the 2025-12-11 server.json schema. There is no publish endpoint, and a description over 100 characters is served as-is even though the official schema rejects it.
Why does agentregistry's /v0.1/servers return an empty list?
The compatibility API's default listing only returns entries whose tag is the literal string latest. An MCPServer published with metadata.tag set to a version and nothing else is stored, and arctl get finds it, but GET /v0.1/servers skips it and GET .../versions/latest returns 404. Omit metadata.tag so the entry gets latest, or have clients pass ?version=.
Can an AI agent discover MCP servers from agentregistry?
Two ways. Any client that speaks the official v0.1 read API can list and fetch servers from /v0.1/servers, and the same client code works against the public MCP Registry. agentregistry also runs its own MCP server, on port 31313 in the demo, with tools such as list_servers and get_server, so an agent can query the catalogue over MCP directly.
Canonical version, with machine-readable markdown at https://webofmike.com/private-mcp-registry/index.md: https://webofmike.com/private-mcp-registry/
Top comments (0)