# A Private MCP Registry in 10 Minutes with agentregistry

*Originally published at [webofmike.com](https://webofmike.com/private-mcp-registry/?utm_source=hashnode&utm_medium=syndication&utm_campaign=private-mcp-registry) 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](https://github.com/themsquared/private-mcp-registry) to stand one up on [agentregistry](https://aregistry.ai/docs/) 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](https://registry.modelcontextprotocol.io) 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](https://webofmike.com/shadow-mcp-servers/) 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](https://github.com/agentregistry-dev/agentregistry/releases/tag/v0.4.0) matter for this demo:

1. **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 apply` at 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 `./bin` and verifies the checksum.
2. **The official registry API is back, opt-in and read-only.** agentregistry started as a fork of the [official registry](https://github.com/modelcontextprotocol/registry), then moved its native API to `v1alpha1` resources, which took it off the upstream `server.json` contract. 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](https://docs.astral.sh/uv/), Node.js for `npx`, `curl` and `jq`. The full command list, including the arctl download, is in the [repo README](https://github.com/themsquared/private-mcp-registry#quickstart).

The compose file is agentregistry's own compose file at the v0.4.0 tag, with the compatibility API switched on:

```yaml
      # 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:

```bash
docker compose up -d --wait
curl -s localhost:12121/v0/version
```

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

```yaml
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
```

```bash
arctl apply -f registry/servers.yaml --dry-run
arctl apply -f registry/servers.yaml
```

```text
✓ 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:

```bash
curl -s localhost:12121/v0.1/servers | jq '.servers[].server | {name, version}'
```

```json
{
  "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](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml) at a pinned commit and validates three kinds of response against it:

- `GET /v0.1/servers` against the `ServerList` schema
- `GET /v0.1/servers/{name}/versions` against `ServerList`
- `GET /v0.1/servers/{name}/versions/latest` against `ServerResponse`

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

```bash
uv run scripts/check_conformance.py
```

```text
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/publish` and the status endpoints are not served. You publish through `arctl apply` or 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/servers` returned every version of a server when I queried it without a `version` parameter. agentregistry's returns only the `latest` tag. Clients that pass `version=latest` see the same behavior from both.
- **`version` filters on the tag.** The spec describes `version` as a server version such as `1.2.3`. agentregistry matches it against `metadata.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, and `latest` becomes `0.0.0`.
- **Tokens are ignored.** The official [generic registry API](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/generic-registry-api.md) lets a subregistry require authentication. agentregistry's [compatibility doc](https://github.com/agentregistry-dev/agentregistry/blob/v0.4.0/docs/mcp-registry-compatibility.md) 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:

```bash
uv run scripts/discover.py fetch
```

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

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

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

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

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

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

```text
  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](https://webofmike.com/mcp-tool-poisoning-pin-definitions/). 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](https://webofmike.com/registry-covert-channel/) 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](https://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=<tag>.

**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/*

