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.
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.
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.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.
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)}
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 Type | Version Bump | Example |
|---|---|---|
| Remove a tool | MAJOR (BREAKING) | search_documents removed — all clients calling it break |
| Rename a tool | MAJOR (BREAKING) | search_docs → search_documents — existing clients call wrong name |
| Remove a required param | Could be MINOR | Making a param optional is backwards compatible |
| Add a new required param | MAJOR (BREAKING) | Existing calls missing the param will fail validation |
| Change param type | MAJOR (BREAKING) | limit: string → limit: number — type mismatch |
| Add a new tool | MINOR | Backwards compatible — existing clients unaffected |
| Add optional param | MINOR | Existing calls still work; new param has default |
| Bug fix in tool logic | PATCH | Correct wrong results without changing API contract |
| Performance improvement | PATCH | Same API contract, faster execution |
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.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 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}" } } } }
.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.mcp.json be committed to source control rather than kept in .gitignore?initialize handshake, what information does the server return to the client that enables capability-aware behavior?