Mastering MCP · Day 27 of 30

MCP Registry, Discovery & Versioning

Solve the service discovery problem for MCP ecosystems — build a registry API, define server manifests, implement semantic versioning with BREAKING change detection, and understand the mcp.json project-level standard.

📅 Day 27
⏳ ~28 min read
🎯 Level ASCEND
🚀 Phase Discovery
Table of Contents

The Problem: How Do Agents Find MCP Servers?

As your MCP ecosystem grows from one server to twenty, a critical operational question emerges: how does an agent know which servers exist, what tools they expose, and which version is running? Without a registry, this information is hardcoded — brittle, unscalable, and invisible to operators.

📌
Hardcoded Config
Most teams start here. Server URLs and tool lists are in static config files. Works for 1–3 servers, breaks at scale. No dynamic discovery, no version awareness, no capability negotiation.
Anti-pattern at scale
📄
mcp.json File
Project-level standard (used by Claude Code). A mcp.json file in the repo root lists all MCP servers for a project. Simple, version-controlled, shareable with the team. The right choice for most projects.
Best for teams
📊
Registry API
A central HTTP API that serves server manifests, handles registration and deregistration, and supports capability queries. Required for large enterprises with dozens of MCP servers owned by different teams.
Enterprise scale

MCP Server Manifest: name, version, capabilities, schema

A server manifest is a machine-readable JSON document that fully describes an MCP server — its identity, version, capabilities, transport options, and the full schema of every tool and resource it exposes. Think of it as the OpenAPI spec equivalent for MCP servers.

JSON — MCP server manifest format{
  "name": "documents-server",
  "displayName": "Enterprise Documents MCP Server",
  "version": "2.1.0",
  "description": "Full-text search and retrieval for the corporate document repository",
  "author": "Platform Engineering Team",
  "license": "MIT",
  "mcpProtocolVersion": "2024-11-05",

  "transports": [
    { "type": "streamable-http", "url": "https://mcp-docs.internal.company.com/mcp" },
    { "type": "stdio", "command": "python -m documents_server" }
  ],

  "capabilities": {
    "tools": true,
    "resources": true,
    "prompts": false,
    "sampling": false,
    "logging": true
  },

  "tools": [
    {
      "name": "search_documents",
      "description": "Full-text search across the document repository",
      "inputSchema": {
        "type": "object",
        "properties": {
          "query": { "type": "string", "description": "Search query" },
          "limit": { "type": "integer", "default": 10, "maximum": 50 }
        },
        "required": ["query"]
      }
    }
  ],

  "authentication": {
    "type": "bearer",
    "tokenEndpoint": "https://auth.company.com/oauth/token",
    "scopes": ["mcp:documents:read"]
  },

  "tags": ["documents", "search", "knowledge-base"],
  "category": "knowledge-management"
}

The manifest should be served at a well-known path on the MCP server itself: GET /.well-known/mcp-manifest.json. This allows automatic discovery — a registry can crawl known server base URLs and ingest manifests without manual registration steps.

Building a Lightweight MCP Registry API (FastAPI + DynamoDB)

A registry is a simple CRUD API for server manifests. FastAPI + DynamoDB gives you a serverless, automatically scaling registry with single-digit millisecond reads — deployed as a Lambda function behind API Gateway.

Python — MCP registry API with FastAPI + DynamoDBfrom fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional
import boto3, time, hashlib

app = FastAPI(title="MCP Registry", version="1.0.0")
table = boto3.resource("dynamodb").Table("mcp-registry")

class ServerRegistration(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    version: str = Field(..., pattern=r"^\d+\.\d+\.\d+$")
    manifest_url: str
    tags: list[str] = []
    category: str = "general"

# ── Register a server ─────────────────────────────────────────────
@app.put("/v1/servers/{name}")
async def register_server(name: str, reg: ServerRegistration):
    server_id = hashlib.sha256(name.encode()).hexdigest()[:12]
    table.put_item(Item={
        "PK": f"SERVER#{server_id}",
        "SK": f"VERSION#{reg.version}",
        "name": name, "version": reg.version,
        "manifest_url": reg.manifest_url,
        "tags": reg.tags, "category": reg.category,
        "registered_at": int(time.time()),
        "ttl": int(time.time()) + 86400 * 30,  # 30-day TTL
    })
    return {"server_id": server_id, "status": "registered"}

# ── Search servers by tag/category ────────────────────────────────
@app.get("/v1/servers")
async def list_servers(
    tag: Optional[str] = Query(None),
    category: Optional[str] = Query(None),
):
    resp = table.scan(FilterExpression="begins_with(PK, :prefix)",
                       ExpressionAttributeValues={":prefix": "SERVER#"})
    items = resp["Items"]
    if tag:
        items = [i for i in items if tag in i.get("tags", [])]
    if category:
        items = [i for i in items if i.get("category") == category]
    return {"servers": items, "count": len(items)}

Semantic Versioning for MCP Servers (BREAKING Changes)

MCP servers are APIs. They follow semantic versioning: MAJOR.MINOR.PATCH. Understanding which changes are BREAKING is critical — a breaking change in an MCP server can silently break every agent workflow that depends on it.

Change TypeVersion BumpExample
Remove a toolMAJOR (BREAKING)search_documents removed — all clients calling it break
Rename a toolMAJOR (BREAKING)search_docssearch_documents — existing clients call wrong name
Remove a required paramCould be MINORMaking a param optional is backwards compatible
Add a new required paramMAJOR (BREAKING)Existing calls missing the param will fail validation
Change param typeMAJOR (BREAKING)limit: stringlimit: number — type mismatch
Add a new toolMINORBackwards compatible — existing clients unaffected
Add optional paramMINORExisting calls still work; new param has default
Bug fix in tool logicPATCHCorrect wrong results without changing API contract
Performance improvementPATCHSame API contract, faster execution
⚠️
Never remove tools without a deprecation period: Mark a tool as deprecated in its description for at least one MINOR version before removing it in a MAJOR release. Add a deprecated: true field to the tool's manifest entry and include a replacedBy pointer to the successor tool. Agents and developers need time to migrate.

Client-Side Capability Negotiation

When an MCP client connects to a server, the first exchange is an initialize handshake where both sides declare their capabilities. A smart client uses the server's declared capabilities to decide which features to use.

Python — capability-aware MCP client initializationfrom mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def connect_with_negotiation(server_url: str):
    async with streamablehttp_client(server_url) as (read, write, _):
        async with ClientSession(read, write) as session:
            # Initialize — server returns its capabilities
            init_result = await session.initialize()
            caps = init_result.capabilities

            # Capability-gated feature usage
            if caps.tools:
                tools = await session.list_tools()
                print(f"Available tools: {[t.name for t in tools.tools]}")

            if caps.resources:
                resources = await session.list_resources()
                print(f"Available resources: {[r.uri for r in resources.resources]}")

            if caps.prompts:
                prompts = await session.list_prompts()
                print(f"Available prompts: {[p.name for p in prompts.prompts]}")

            # Check server version from initialization result
            server_info = init_result.serverInfo
            print(f"Connected to {server_info.name} v{server_info.version}")
            return session, caps

mcp.json — Project-Level Server Registry File

mcp.json is the Claude Code standard for project-level MCP configuration. Place it at the project root alongside .claude/ and it tells Claude Code exactly which MCP servers to connect to when working in this repository.

JSON — .claude/mcp.json (Claude Code project config){
  "mcpServers": {
    "documents": {
      "command": "python",
      "args": ["-m", "servers.documents"],
      "env": {
        "S3_BUCKET": "my-docs-bucket",
        "LOG_LEVEL": "INFO"
      }
    },
    "database": {
      "command": "python",
      "args": ["-m", "servers.database"],
      "env": {
        "DB_URL": "postgresql://localhost:5432/mydb"
      }
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
      }
    },
    "remote-prod": {
      "url": "https://mcp.company.com/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_API_KEY}"
      }
    }
  }
}
💡
Commit mcp.json to source control: Unlike secrets (.env), mcp.json should be committed to your repository. It's project documentation — it tells every developer and every AI agent exactly which MCP servers are available for this project. Use environment variable references (${VAR_NAME}) for secrets rather than hardcoding sensitive values.
Knowledge Check
4 questions · instant feedback · Registry & Discovery checkpoint
1. Which change to an MCP server's tool schema is a BREAKING change requiring a MAJOR version bump?
2. What well-known path should an MCP server expose to enable automatic manifest discovery by a registry crawler?
3. Why should mcp.json be committed to source control rather than kept in .gitignore?
4. During the MCP initialize handshake, what information does the server return to the client that enables capability-aware behavior?
out of 4 correct —