Building an Internal MCP Registry & Catalog

12 min read Module 6 of 10 Topic 18 of 30

What you'll learn

  • Design a registration schema that captures what governance and discovery actually need
  • Implement server self-registration with periodic health heartbeats
  • Wire a federation gateway or agent client to resolve servers dynamically from the registry rather than static config
  • Reason about registry availability itself: what happens when the registry is a single point of failure
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The registry pattern introduced in Lesson 9 becomes essential once the number of internal MCP servers grows past what any team can track by memory or a shared spreadsheet. This lesson builds a minimal but production-viable version, and closes with a question teams often overlook: what happens when the registry itself, now a piece of shared infrastructure everything else depends on, becomes unavailable.

Registration Schema

Capture only what discovery and governance genuinely need: identity, connection details, ownership, and live status.

from pydantic import BaseModel
from datetime import datetime

class MCPServerRegistration(BaseModel):
    name: str                      # e.g. "crm", globally unique namespace
    endpoint: str                  # https://mcp.internal/crm/v2
    version: str                   # semver of the server itself
    owning_team: str               # for on-call/escalation
    required_scopes: list[str]     # OAuth scopes a client needs, from Module 4
    last_heartbeat: datetime
    status: str                    # "healthy" | "degraded" | "unreachable"

Self-Registration and Heartbeats

Each MCP server registers itself at startup and sends a periodic heartbeat, rather than an administrator manually maintaining entries that drift out of date.

import asyncio, httpx

REGISTRY_URL = "https://mcp-registry.internal/api/servers"

async def register_and_heartbeat(name: str, endpoint: str, version: str, scopes: list[str]):
    async with httpx.AsyncClient() as client:
        await client.put(f"{REGISTRY_URL}/{name}", json={
            "endpoint": endpoint, "version": version,
            "owning_team": "platform-crm", "required_scopes": scopes,
        })
        while True:
            try:
                await client.post(f"{REGISTRY_URL}/{name}/heartbeat", timeout=5)
            except httpx.RequestError:
                pass  # Registry will mark this server unreachable after missed heartbeats
            await asyncio.sleep(30)

# Run alongside the MCP server itself at startup
asyncio.create_task(register_and_heartbeat("crm", "https://mcp.internal/crm/v2", "2.1.0", ["crm:read", "crm:write"]))
# Registry side: mark servers unreachable after 3 missed heartbeat intervals
async def sweep_stale_servers():
    while True:
        cutoff = datetime.utcnow() - timedelta(seconds=90)
        await db.execute(
            "UPDATE servers SET status = 'unreachable' WHERE last_heartbeat < $1 AND status != 'unreachable'",
            cutoff,
        )
        await asyncio.sleep(30)

Registry-Driven Resolution

The federation gateway (or a direct client) resolves servers dynamically from the registry rather than static configuration, and skips anything marked unhealthy.

flowchart TD
    Server1["CRM Server"] -->|self-register\n+ heartbeat every 30s| Registry["Internal MCP Registry"]
    Server2["Support Server"] -->|self-register\n+ heartbeat| Registry
    Server3["KB Server\n(crashed)"] -.->|"heartbeat missed"| Registry
    Registry -->|"status: unreachable\nafter 90s"| Registry

    Gateway["Federation Gateway"] -->|"resolve healthy servers"| Registry
    Registry -->|"[crm: healthy, support: healthy,\nkb: unreachable, excluded]"| Gateway
    Gateway --> Server1
    Gateway --> Server2

    style Registry fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style Server1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Server2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Server3 fill:#fef2f2,stroke:#dc2626,color:#0F172A
    style Gateway fill:#fff7ed,stroke:#f59e0b,color:#0F172A
async def resolve_healthy_servers() -> dict[str, str]:
    """Called at gateway startup and periodically, excludes unreachable servers
    from the aggregated catalog so agents never see tools likely to fail."""
    resp = await httpx.AsyncClient().get(f"{REGISTRY_URL}?status=healthy")
    return {s["name"]: s["endpoint"] for s in resp.json()}

The Registry as a Potential Single Point of Failure

There is an easy-to-miss irony in a registry built to improve reliability: if every client resolves servers fresh from the registry on every connection attempt with no fallback, the registry’s own uptime becomes a hard dependency for reaching MCP servers that are, themselves, perfectly healthy. A registry outage in that design would take down access to the entire fleet even though nothing about the fleet itself is actually broken, which is a worse outcome than the problem the registry was built to solve.

Two complementary mitigations close this gap. First, run the registry itself with the same redundancy discipline as any critical service, multiple replicas behind a load balancer, its own health checks, rather than treating it as low-stakes internal tooling exempt from the production rigor applied elsewhere in this course. Second, and more importantly, clients should cache their last successful resolution and fall back to it (with a clear log warning) if the registry is unreachable at connection time, rather than failing the connection outright.

_resolution_cache: dict[str, str] = {}

async def resolve_with_fallback(name: str) -> str | None:
    """Prefer a fresh registry lookup, but fall back to the last known-good
    endpoint if the registry itself is unreachable, rather than failing outright."""
    try:
        resp = await httpx.AsyncClient().get(f"{REGISTRY_URL}/{name}", timeout=3)
        resp.raise_for_status()
        endpoint = resp.json()["endpoint"]
        _resolution_cache[name] = endpoint  # Refresh the cache on every successful lookup
        return endpoint
    except httpx.RequestError:
        if name in _resolution_cache:
            logging.warning(f"Registry unreachable, using cached endpoint for '{name}'")
            return _resolution_cache[name]
        return None  # No cached fallback available; caller must handle this

This closes Module 6. With versioning, namespacing, and discovery, including its own failure mode, in place, Module 7 turns to the security threats specific to agent-facing tool infrastructure, starting with prompt injection delivered through tool results.

Knowledge Check

3 questions to test your understanding

1 Why should an MCP server register itself and send periodic health heartbeats to the internal registry, rather than an administrator manually adding a static entry once?

2 What should a federation gateway or agent client do if the registry marks a server as unhealthy at connection time?

3 The internal MCP registry service itself goes down. What happens to every MCP server and federation gateway in the organization if there is no fallback plan for this case?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan