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.