Connecting one MCP server to a client is easy. Connecting twenty is where the real engineering starts.
At first, the code feels straightforward: create a client, connect to a server, list its tools, call them. Then you add a second server. Then a third. Soon you have three filesystem servers, two database servers, a browser automation server, an internal knowledge-base server, a code search server, and a few experimental tools somebody added on Friday afternoon.
Now you have new questions:
- Why do two servers both expose a tool named
search? - Which server should handle
get_customer? - What happens when one MCP server crashes during startup?
- How do you prevent a junior-experiment server from seeing production credentials?
- How do you stop the model from seeing 400 tools at once?
- How do you audit which tool was called, by which user, through which server?
The answer is not “just connect to more servers.”
You need a tool registry: a client-side control plane that manages server lifecycles, namespaces tools, caches metadata, enforces policy, routes calls, and degrades gracefully when individual servers fail.
This article is about building that registry without turning your MCP client into a fragile pile of connection hacks.
TL;DR
If you want one MCP client to work reliably with many MCP servers:
- Treat the registry as a control plane, not a list of connections.
- Give every MCP server a stable identity and isolated lifecycle.
- Namespace every tool before exposing it to the model.
- Discover tools lazily and cache metadata deliberately.
- Do not show the model every tool all the time.
- Route calls through an explicit registry lookup.
- Add health checks, retries, backoff, and circuit breakers.
- Enforce authorization before execution, not after.
- Observe tool metadata changes like production API changes.
A scalable MCP client is not just an MCP client. It is a small integration gateway.
📋 Table of Contents
- The Real Problem
- 1. Treat the Registry as a Control Plane Not a Connection List
- 2. Give Every Server a Stable Identity and Lifecycle
- 3. Namespace Tools Before They Namespace You
- 4. Discover Lazily Cache Metadata and Refresh Deliberately
- 5. Do Not Show the Model 400 Tools at Once
- 6. Route Calls With Explicit Boundaries
- 7. Add Health Checks Backoff and Circuit Breakers
- 8. Enforce Authorization Like an API Gateway
- 9. Observe Tool Metadata Like Production API Contracts
- Choosing the Right Registry Architecture
- Production Checklist
The Real Problem
MCP servers are wonderfully composable. Each one can expose tools, resources, and prompts through a standard protocol. But that flexibility creates a scaling problem on the client side.
A single MCP server usually has a coherent tool surface. Maybe it exposes:
search_documents
get_document
summarize_document
Another exposes:
search_issues
get_issue
comment_issue
Another exposes:
search_code
get_file
run_linter
Individually, these are fine. Together, the client now has to answer questions that no single server knows about:
- Which tools are available right now?
- Which tools are allowed for this user?
- Which tools are relevant to this task?
- Which server owns a given tool?
- What should happen if two tools have the same name?
- What should happen if a server disconnects?
- How do we avoid leaking one server’s credentials to another?
- How do we know whether a tool schema changed?
This is why naive multi-server MCP clients break down.
The first version usually looks like this:
const servers = [
createFilesystemClient(),
createDatabaseClient(),
createBrowserClient(),
createInternalWikiClient()
];
const allTools = [];
for (const server of servers) {
await server.connect();
const tools = await server.listTools();
allTools.push(...tools);
}
That works for a demo.
It does not work for production.
A production MCP client with many servers needs a registry. The registry is responsible for turning a chaotic set of MCP connections into a governed, observable, routable tool surface.
1. Treat the Registry as a Control Plane Not a Connection List
Scenario
You add a new internal MCP server by copying an existing client snippet. It works locally. Then you deploy. The server needs an environment variable that only exists on one machine. It times out on startup. Now your entire agent fails to boot because one optional server is unavailable.
Why the obvious solution is insufficient
Most multi-server examples treat the client as a simple connection manager:
connect to server
list tools
merge tools
done
That model collapses once servers have different failure modes, permissions, refresh intervals, transports, and ownership boundaries.
You need a higher-level abstraction.
Solution
Model the registry as a control plane.
The registry should own:
- server configuration
- connection lifecycle
- tool discovery
- tool indexing
- routing rules
- authorization policy
- health state
- metadata cache
- audit hooks
A useful starting point is a config-driven server inventory:
export type McpServerConfig = {
id: string;
displayName: string;
owner: string;
environment: "local" | "staging" | "production";
enabled: boolean;
tags: string[];
timeoutMs?: number;
refreshToolsAfterMs?: number;
transport:
| {
type: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
}
| {
type: "http";
url: string;
headers?: Record<string, string>;
};
};
Then the registry consumes that config:
class ToolRegistry {
private servers = new Map<string, ManagedServer>();
constructor(
private configs: McpServerConfig[],
private sessionFactory: SessionFactory
) {}
async start(): Promise<void> {
for (const config of this.configs) {
this.servers.set(config.id, {
config,
state: config.enabled ? "stopped" : "disabled",
tools: []
});
}
}
}
The registry does not immediately assume every server is reachable. It knows each server exists as a managed object. Connection, discovery, and health handling are separate concerns.
Why this works
A control-plane mindset gives you a place to put cross-cutting behavior.
Instead of scattering logic across random client code, you get one component responsible for:
- which servers exist
- which servers should be active
- how tools are discovered
- how tools are named
- how calls are routed
- how failures are handled
- which user can call what
This becomes especially important when your MCP servers are not all equal. Some may be local stdio processes. Some may be remote HTTP servers. Some may be read-only. Some may be dangerous. Some may be maintained by another team.
🔍 Why this matters: A registry is not a convenience layer. It is the place where your MCP client becomes an operational system.
2. Give Every Server a Stable Identity and Lifecycle
Scenario
Two MCP servers expose tools for customer data. One is production. One is a staging sandbox. Both define a tool called get_customer. A developer hardcodes a call to get_customer, assuming it is the production one. Later, the order changes. The agent starts reading staging data. Nobody notices until something looks wrong.
Why identity matters
If your registry only knows servers by connection order or implicit client object references, you will eventually route calls to the wrong place.
Every MCP server needs:
- a stable ID
- a display name
- an owner
- an environment label
- a lifecycle state
- a transport config
- a tool list
- health metadata
Solution
Represent each server as a managed object:
type ServerState =
| "disabled"
| "stopped"
| "starting"
| "ready"
| "failed"
| "degraded";
type ManagedServer = {
config: McpServerConfig;
state: ServerState;
session?: McpSession;
tools: ToolRef[];
lastRefreshAt?: number;
lastError?: string;
};
Then define lifecycle transitions explicitly:
async function connectServer(
server: ManagedServer,
sessionFactory: SessionFactory
): Promise<void> {
if (server.state === "starting" || server.state === "ready") {
return;
}
server.state = "starting";
try {
server.session = await sessionFactory.create(server.config);
server.state = "ready";
server.lastError = undefined;
} catch (err) {
server.state = "failed";
server.lastError = err instanceof Error ? err.message : String(err);
}
}
And close connections deliberately:
async function disconnectServer(server: ManagedServer): Promise<void> {
try {
await server.session?.close();
} finally {
server.session = undefined;
server.state = "stopped";
server.tools = [];
}
}
Why this works
Explicit lifecycle state prevents a whole class of bugs.
You can now answer:
- Is this server starting?
- Is it failed?
- Is it disabled by config?
- Is it degraded but still serving cached tools?
- Should the client retry?
- Should the registry route calls to it?
This also helps with local stdio MCP servers. Each stdio server is usually a child process. Twenty servers can mean twenty processes. If one crashes, you want the registry to mark that server failed without taking down the entire client.
Practical note
For stdio servers, isolate environment variables per server. Do not pass your whole process environment to every MCP server. If a database MCP server does not need your cloud credentials, it should not receive them.
3. Namespace Tools Before They Namespace You
Scenario
You connect a documentation MCP server and a customer-support MCP server. Both expose:
search
The model calls search. Which server receives the call?
If you merge tool lists without namespacing, you have silently created a nondeterministic or collision-prone tool surface.
Why this matters
Tool name collisions are not rare. They are inevitable.
Common tool names repeat across servers:
searchgetlistcreatedeleterunqueryfetchstatus
If your MCP client exposes raw tool names from multiple servers, you will eventually have conflicts. Worse, the conflict may be invisible until the wrong tool is called.
Solution
Give every tool a qualified identity.
A simple pattern is:
type ToolRef = {
qualifiedName: string;
serverId: string;
originalName: string;
description?: string;
inputSchema?: Record<string, unknown>;
metadataHash: string;
};
function qualifyToolName(serverId: string, toolName: string): string {
return `${serverId}.${toolName}`;
}
When discovering tools:
function indexTools(server: ManagedServer): Map<string, ToolRef> {
const index = new Map<string, ToolRef>();
for (const tool of server.tools) {
const qualifiedName = qualifyToolName(server.config.id, tool.originalName);
index.set(qualifiedName, {
...tool,
qualifiedName
});
}
return index;
}
When the client receives a tool call, the registry resolves the qualified name:
function resolveTool(
registryIndex: Map<string, ToolRef>,
qualifiedName: string
): ToolRef | undefined {
return registryIndex.get(qualifiedName);
}
Then the registry calls the server using the original tool name:
await server.session?.callTool({
name: tool.originalName,
arguments: args
});
Why this works
Namespacing separates the model-facing tool identity from the server-local tool identity.
The model sees:
docs.search
support.search
code.search
The MCP servers still see:
search
That keeps individual servers simple while giving the client a globally routable tool surface.
Gotcha
Some MCP client surfaces restrict the characters allowed in tool names. If your client only allows alphanumeric characters, underscores, and hyphens, choose a safe separator.
For example:
docs_search
support_search
But that can become ambiguous if server IDs or tool names also contain underscores.
A safer pattern is to maintain an opaque alias table:
type ToolAlias = {
alias: string;
serverId: string;
originalName: string;
};
Then the model sees aliases like:
tool_8f3d2a
or generated slugs:
docs_search_v1
while the registry stores the true mapping internally.
💡 Practical note: Do not assume tool names are globally unique. Assume they will collide, and design your registry around that assumption.
4. Discover Lazily Cache Metadata and Refresh Deliberately
Scenario
Your client connects to twenty MCP servers at startup. Three are slow. One is a remote server behind a flaky VPN. Another stdio server fails because a binary is missing. Now the entire application takes forty seconds to boot, and the user has not even asked a question yet.
Why eager discovery hurts
Listing tools from every server immediately is tempting because it gives you a complete tool inventory. But complete is not the same as useful.
At scale, eager discovery causes:
- slow startup
- unnecessary process spawning
- network calls to unused servers
- higher failure surface
- stale tool metadata
- noisy logs
- unnecessary token usage
Solution
Use lazy discovery with deliberate caching.
The registry should distinguish between:
- server configuration
- server connection
- tool discovery
- tool exposure
- tool invocation
A server can be configured without being connected. It can be connected without having its tools refreshed. Its tools can be cached without being exposed to the model.
Example:
function isToolCacheStale(server: ManagedServer): boolean {
const ttl = server.config.refreshToolsAfterMs ?? 5 * 60_000;
if (!server.lastRefreshAt) {
return true;
}
return Date.now() - server.lastRefreshAt > ttl;
}
Then refresh only when needed:
async function ensureServerTools(
server: ManagedServer,
options: { force?: boolean } = {}
): Promise<void> {
if (!server.session) {
await connectServer(server, sessionFactory);
}
if (!server.session) {
return;
}
if (!options.force && !isToolCacheStale(server)) {
return;
}
try {
const result = await server.session.listTools();
server.tools = result.tools.map((tool) => ({
serverId: server.config.id,
originalName: tool.name,
qualifiedName: qualifyToolName(server.config.id, tool.name),
description: tool.description,
inputSchema: tool.inputSchema,
metadataHash: hashToolMetadata(tool)
}));
server.lastRefreshAt = Date.now();
server.state = "ready";
server.lastError = undefined;
} catch (err) {
server.state = server.tools.length > 0 ? "degraded" : "failed";
server.lastError = err instanceof Error ? err.message : String(err);
}
}
Stale-while-error
A useful production behavior is to keep serving cached tool metadata if a refresh fails but the server previously worked.
For example:
try {
await refreshTools(server);
} catch {
if (server.tools.length > 0) {
server.state = "degraded";
} else {
server.state = "failed";
}
}
This prevents one flaky refresh from erasing a known-good tool surface.
Tool list change notifications
Some MCP ecosystems support server-initiated notifications when the tool list changes. If your SDK exposes those notifications, use them. They are better than blind polling.
But still keep a TTL-based refresh as a fallback. Notifications can be missed. Processes can restart. Networks can drop messages.
Why this works
Lazy discovery keeps the client responsive.
If the user is only asking documentation questions, there is no reason to connect to the database server, browser automation server, and billing MCP server immediately.
The registry can connect when a profile, task, or explicit request requires those tools.
5. Do Not Show the Model 400 Tools at Once
Scenario
You successfully aggregate twenty MCP servers. Each has ten to thirty tools. Your client now exposes 300 to 600 tools to the model.
The model starts choosing the wrong tools. It tries database tools for documentation questions. It calls internal admin tools when the user only wanted a summary. Tool selection latency increases. Token usage explodes.
Why tool sprawl hurts
Models are good at using tools, but they are not infinitely good at choosing among hundreds of them.
Too many tools cause:
- poorer tool selection
- higher prompt token usage
- more accidental calls
- more policy risk
- harder debugging
- slower client rendering
- confusing user interfaces
A registry that simply merges every tool into one flat list does not scale.
Solution
Use tool sets, profiles, or scopes.
Define which tools should be visible for a given context:
type ToolProfile = {
id: string;
serverTags?: string[];
serverIds?: string[];
allowTools?: string[];
denyTools?: string[];
maxTools?: number;
};
Example profiles:
const supportProfile: ToolProfile = {
id: "support",
serverTags: ["docs", "support", "crm-read"],
denyTools: ["*.delete_*", "*.write_*"],
maxTools: 40
};
const engineeringProfile: ToolProfile = {
id: "engineering",
serverTags: ["code", "issues", "ci"],
allowTools: [
"code.search",
"code.get_file",
"issues.get_issue",
"issues.list_issues"
]
};
Then filter the registry:
function listToolsForProfile(
registry: ToolRegistry,
profile: ToolProfile
): ToolRef[] {
const tools: ToolRef[] = [];
for (const server of registry.servers.values()) {
if (!server.config.enabled) continue;
if (server.state === "disabled" || server.state === "failed") continue;
if (profile.serverIds && !profile.serverIds.includes(server.config.id)) {
continue;
}
if (
profile.serverTags &&
!profile.serverTags.some((tag) => server.config.tags.includes(tag))
) {
continue;
}
for (const tool of server.tools) {
if (matchesToolFilters(tool, profile)) {
tools.push(tool);
}
}
}
return tools.slice(0, profile.maxTools ?? Infinity);
}
Why this works
Profiles reduce the model’s decision space.
Instead of asking:
Which of 400 tools might be relevant?
the client asks:
Which of these 12 support-related tools should be used?
That improves accuracy and reduces accidental access to unrelated systems.
Advanced option: retrieval-based tool selection
For very large registries, static profiles may not be enough. You can build a tool-search layer:
- embed tool names and descriptions
- retrieve the top N tools for the current task
- include only those tools in the model context
- keep a small set of always-available fallback tools
This is useful when you have hundreds of tools. But do not start there. Start with tags, profiles, and explicit allow-lists. Retrieval adds complexity and can hide policy mistakes.
⚠️ Gotcha: Tool descriptions are not neutral documentation. They influence model behavior. Treat untrusted server descriptions as data, not instructions.
6. Route Calls With Explicit Boundaries
Scenario
The model calls:
support.get_customer
Your client needs to know:
- which server owns the tool
- whether the server is available
- whether the caller is authorized
- which original tool name to invoke
- what arguments to pass
- how to handle failure
If routing is implicit, you will eventually call the wrong server or fail in confusing ways.
Why routing matters
A multi-server MCP client is effectively a router.
Every tool call should pass through a deterministic path:
qualified tool name
→ registry lookup
→ policy check
→ schema validation
→ server session
→ original MCP tool call
→ normalized result
Solution
Implement a central call dispatcher:
type CallContext = {
userId: string;
sessionId: string;
roles: string[];
profileId: string;
};
class ToolRegistry {
async callTool(
qualifiedName: string,
args: Record<string, unknown>,
ctx: CallContext
): Promise<unknown> {
const tool = this.toolIndex.get(qualifiedName);
if (!tool) {
throw new Error(`Unknown tool: ${qualifiedName}`);
}
const server = this.servers.get(tool.serverId);
if (!server) {
throw new Error(`Unknown server for tool: ${qualifiedName}`);
}
if (!server.session || server.state === "failed") {
throw new Error(`Server unavailable: ${server.config.id}`);
}
const policyDecision = this.policy.evaluate(ctx, tool, server.config);
if (!policyDecision.allowed) {
throw new Error(
`Tool call denied: ${policyDecision.reason ?? "policy violation"}`
);
}
return server.session.callTool({
name: tool.originalName,
arguments: args
});
}
}
Validate before forwarding
The MCP server is ultimately responsible for validating its own inputs. But the registry can still do useful preflight checks:
- reject unknown fields
- validate enum values
- enforce max lengths
- check rough schema shape
- block obviously dangerous values
- apply rate limits
This gives better errors and reduces unnecessary server load.
A simple preflight check:
function hasUnknownArguments(
args: Record<string, unknown>,
schema?: Record<string, unknown>
): boolean {
if (!schema || typeof schema !== "object") {
return false;
}
const properties = schema.properties as Record<string, unknown> | undefined;
if (!properties) {
return false;
}
const allowed = new Set(Object.keys(properties));
return Object.keys(args).some((key) => !allowed.has(key));
}
For serious validation, use a JSON Schema validator. But even a lightweight check can catch client bugs early.
Why this works
Explicit routing makes the registry a choke point.
That is exactly what you want.
If every tool call goes through the same route, you can add:
- authorization
- auditing
- retries
- metrics
- tracing
- timeouts
- result normalization
- human approval
without modifying every MCP server.
Practical note
Do not blindly retry mutating tools. Retrying a read-only search tool is usually safe. Retrying create_ticket, delete_file, or send_email can cause duplicate side effects. If you retry, make the safety model explicit.
7. Add Health Checks Backoff and Circuit Breakers
Scenario
One MCP server becomes slow. It does not crash. It just takes twenty seconds to respond to tool discovery. Every time your client starts, it waits for that server. Later, the server starts timing out during tool calls. Now the model experience feels broken, even though nineteen other servers are fine.
Why health handling matters
With one server, failure is obvious. With twenty, failure becomes partial and ambiguous.
You need to distinguish between:
- server not configured
- server disabled
- server starting
- server temporarily unreachable
- server repeatedly failing
- server ready but degraded
- server ready but slow
- server tool list stale
- server call failed once
- server call failed permanently
Solution
Add connection backoff and circuit breakers.
A minimal circuit breaker:
type CircuitState = {
failures: number;
openUntil: number;
};
class CircuitBreaker {
private circuits = new Map<string, CircuitState>();
canAttempt(key: string): boolean {
const circuit = this.circuits.get(key);
if (!circuit) {
return true;
}
return Date.now() >= circuit.openUntil;
}
recordFailure(key: string): void {
const circuit = this.circuits.get(key) ?? {
failures: 0,
openUntil: 0
};
circuit.failures += 1;
const backoffMs = Math.min(30_000, 1000 * 2 ** circuit.failures);
circuit.openUntil = Date.now() + backoffMs;
this.circuits.set(key, circuit);
}
recordSuccess(key: string): void {
this.circuits.delete(key);
}
}
Use it in the registry:
async function callServerTool(
registry: ToolRegistry,
breaker: CircuitBreaker,
server: ManagedServer,
tool: ToolRef,
args: Record<string, unknown>
): Promise<unknown> {
if (!breaker.canAttempt(server.config.id)) {
throw new Error(`Server circuit open: ${server.config.id}`);
}
try {
const result = await server.session!.callTool({
name: tool.originalName,
arguments: args
});
breaker.recordSuccess(server.config.id);
return result;
} catch (err) {
breaker.recordFailure(server.config.id);
throw err;
}
}
Health probes
MCP servers may not always expose a dedicated health tool. But you can still derive health signals from:
- successful connection
- successful tool listing
- recent successful tool calls
- recent timeout rate
- error rate
- latency
- transport-level disconnection events
For local stdio servers, process exit is also a health signal.
For remote HTTP servers, use sensible timeouts:
const timeoutMs = server.config.timeoutMs ?? 10_000;
Do not let one slow server hang the entire registry.
Degraded mode
A useful pattern is to keep a server visible but mark it degraded:
if (server.tools.length > 0 && recentFailures > threshold) {
server.state = "degraded";
}
Then the registry can choose to:
- show the tools but warn the user
- hide the tools temporarily
- route to an alternative server
- require explicit retry
- return cached results where safe
Why this works
Health handling turns a fragile client into a resilient one.
The goal is not to make every server perfectly available. The goal is to prevent one bad server from ruining the entire MCP client experience.
🚨 Production warning: If your registry connects to remote MCP servers, treat network failures as normal. Timeouts, token expiration, DNS failures, and transient HTTP errors will happen.
8. Enforce Authorization Like an API Gateway
Scenario
Your MCP client connects to an internal admin server. The server exposes a tool called reset_environment. The model does not know it is dangerous. It only knows the tool exists and the description says it can help fix broken environments.
A user says:
“This staging environment is broken. Can you fix it?”
The model calls:
admin.reset_environment
Now you have a very bad day.
Why authorization matters
MCP servers often enforce their own permissions, but the client registry is in a unique position.
It knows:
- which user is interacting
- which session is active
- which profile is selected
- which server owns the tool
- which tags apply
- which tool is being called
- whether the tool is read-only or mutating
- whether the tool is experimental or production
That makes the registry the right place for policy.
Solution
Add a policy engine.
Start simple:
type PolicyDecision = {
allowed: boolean;
reason?: string;
requiresApproval?: boolean;
};
type PolicyRule = {
id: string;
evaluate: (
ctx: CallContext,
tool: ToolRef,
server: McpServerConfig
) => PolicyDecision;
};
class ToolPolicyEngine {
constructor(private rules: PolicyRule[]) {}
evaluate(
ctx: CallContext,
tool: ToolRef,
server: McpServerConfig
): PolicyDecision {
for (const rule of this.rules) {
const decision = rule.evaluate(ctx, tool, server);
if (!decision.allowed) {
return decision;
}
if (decision.requiresApproval) {
return decision;
}
}
return { allowed: true };
}
}
Example rules:
const denyDisabledServers: PolicyRule = {
id: "deny-disabled-servers",
evaluate: (_ctx, _tool, server) => {
if (!server.enabled) {
return {
allowed: false,
reason: "Server is disabled"
};
}
return { allowed: true };
}
};
const denyProductionAdminForSupportUsers: PolicyRule = {
id: "deny-prod-admin-for-support",
evaluate: (ctx, tool, server) => {
const isAdmin = ctx.roles.includes("admin");
const isProductionAdminServer =
server.environment === "production" &&
server.tags.includes("admin");
if (isProductionAdminServer && !isAdmin) {
return {
allowed: false,
reason: "Production admin tools require admin role"
};
}
return { allowed: true };
}
};
Use risk levels
Do not rely only on names. Add explicit risk metadata to server config or tool metadata.
For example:
type ToolRiskLevel = "read" | "write" | "destructive" | "external";
Then policy can require approval for destructive operations:
const requireApprovalForDestructiveTools: PolicyRule = {
id: "require-approval-destructive",
evaluate: (_ctx, tool) => {
const name = tool.originalName.toLowerCase();
if (
name.includes("delete") ||
name.includes("drop") ||
name.includes("reset") ||
name.includes("terminate")
) {
return {
allowed: true,
requiresApproval: true
};
}
return { allowed: true };
}
};
Name-based inference is imperfect, but it is a useful safety net until you have curated metadata.
Why this works
The policy engine makes your registry behave more like an API gateway.
It gives you a single place to answer:
- Can this user call this tool?
- Can this session call this tool?
- Is this tool allowed in this profile?
- Is this server allowed in this environment?
- Does this tool require human approval?
- Should this tool be logged at a higher audit level?
This is especially important when MCP servers come from different trust levels.
A local filesystem server, an internal database server, and a third-party web search server should not be treated identically.
Practical note
Do not rely on the model to enforce policy. The model can be helpful, but it is not an authorization boundary. The registry is.
9. Observe Tool Metadata Like Production API Contracts
Scenario
A team updates an MCP server. The tool search_issues still exists, but one argument changed from project to project_id. The description also changed subtly.
Your client still sends:
{
"project": "website"
}
The tool fails. The model retries. The user thinks the agent is broken.
Why metadata observability matters
MCP tools are contracts.
Their names, descriptions, and input schemas influence:
- model behavior
- client validation
- policy rules
- UI rendering
- audit interpretation
- regression tests
If you do not observe changes in those contracts, you will debug mysterious agent behavior at the wrong layer.
Solution
Hash tool metadata and log changes.
import { createHash } from "node:crypto";
function hashToolMetadata(tool: {
name: string;
description?: string;
inputSchema?: unknown;
}): string {
const payload = JSON.stringify({
name: tool.name,
description: tool.description ?? "",
inputSchema: tool.inputSchema ?? null
});
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
When refreshing tools, compare old and new hashes:
function detectToolChanges(
previous: Map<string, ToolRef>,
next: ToolRef[]
): {
added: ToolRef[];
removed: ToolRef[];
changed: ToolRef[];
} {
const nextByName = new Map(next.map((tool) => [tool.qualifiedName, tool]));
const added: ToolRef[] = [];
const changed: ToolRef[] = [];
const removed: ToolRef[] = [];
for (const tool of next) {
const old = previous.get(tool.qualifiedName);
if (!old) {
added.push(tool);
} else if (old.metadataHash !== tool.metadataHash) {
changed.push(tool);
}
}
for (const [name, oldTool] of previous) {
if (!nextByName.has(name)) {
removed.push(oldTool);
}
}
return { added, removed, changed };
}
Then emit structured events:
function logToolMetadataChanges(
serverId: string,
changes: ReturnType<typeof detectToolChanges>
): void {
if (changes.added.length > 0) {
console.error(JSON.stringify({
event: "tools_added",
serverId,
tools: changes.added.map((tool) => tool.qualifiedName)
}));
}
if (changes.removed.length > 0) {
console.error(JSON.stringify({
event: "tools_removed",
serverId,
tools: changes.removed.map((tool) => tool.qualifiedName)
}));
}
if (changes.changed.length > 0) {
console.error(JSON.stringify({
event: "tools_changed",
serverId,
tools: changes.changed.map((tool) => ({
name: tool.qualifiedName,
hash: tool.metadataHash
}))
}));
}
}
Metrics to track
Useful registry metrics include:
- number of configured servers
- number of active servers
- number of failed servers
- number of tools exposed per profile
- tool call count by server and tool
- tool error rate
- tool latency
- circuit breaker state
- refresh success rate
- metadata change events
- authorization denials
- approval requests
- cache staleness
Audit calls separately from debugging logs
For production auditing, record:
- request ID
- user ID or session ID
- profile ID
- server ID
- qualified tool name
- original tool name
- policy decision
- approval status
- start and end time
- error class
- result size
- whether output was truncated
Be careful not to log sensitive arguments. Log metadata first, and add argument logging only where safe and necessary.
Why this works
Tool metadata observability turns MCP integration into a manageable engineering discipline.
Instead of asking:
Why did the agent do something weird?
you can ask:
Did the tool surface change? Did policy change? Did the server fail? Did the model choose a new tool? Did an argument schema drift?
That distinction saves hours.
Choosing the Right Registry Architecture
There is more than one way to build this. The right choice depends on how many clients you have, how sensitive the tools are, and how much operational complexity you can absorb.
Architecture comparison
| Architecture | Description | Best for | Main trade-off |
|---|---|---|---|
| Embedded registry inside the client | Registry lives in the same process as the MCP client | Local tools, desktop apps, simple internal tools | Harder to share policy across multiple clients |
| Standalone registry service | Separate service manages server metadata, policy, and routing | Teams with multiple MCP clients or centralized governance | Adds network hop and operational burden |
| Sidecar registry per client | Registry runs next to the client as a local service | Multi-language clients on managed devices | More deployment complexity |
| MCP proxy/router | A dedicated MCP server aggregates and re-exposes selected tools | Creating curated tool surfaces for downstream clients | Can become a complex meta-server |
| Hybrid config plus gateway | Static server config locally, central policy service remotely | Enterprises needing both local speed and central control | Requires careful consistency rules |
Embedded registry
This is the simplest place to start.
Your MCP client process owns the registry directly. It connects to local and remote MCP servers, indexes tools, and routes calls.
Use this when:
- you have one client application
- servers are mostly local or trusted
- policy is simple
- you want low operational overhead
The downside is that every client needs its own policy implementation. If you have a desktop app, web app, and CLI app, you may end up duplicating logic.
Standalone registry service
A standalone registry service can centralize:
- server catalog
- authorization policy
- audit logs
- tool metadata cache
- health status
- approval workflows
Clients ask the registry which tools are available and how to call them.
Use this when:
- multiple clients need the same tool surface
- compliance requires centralized auditing
- server configuration changes often
- you need admin dashboards
- policy is complex
The downside is that you now have a distributed system. You need authentication, authorization, versioning, caching, and failure handling between the client and registry.
MCP proxy/router
Another option is to build an MCP server that acts as an aggregator. It connects to multiple downstream MCP servers and exposes a curated set of tools upstream.
This can be powerful, but it comes with a warning:
A proxy that re-exposes tools is also a security boundary.
It must handle namespacing, authorization, schema translation, rate limiting, and failure isolation. If you build it poorly, you have simply moved the complexity into a more privileged component.
Use this pattern when you want to provide a stable MCP surface to clients that cannot implement their own registry.
Production Checklist
Before running one MCP client against twenty MCP servers, I would want the following in place.
Server management
- Every server has a stable ID.
- Every server has an owner.
- Every server has an environment label.
- Every server has tags.
- Disabled servers are explicitly disabled.
- Local stdio servers use isolated environment variables.
- Remote servers use scoped credentials.
- Server startup is lazy or prioritized.
- Server crashes do not crash the client.
Tool discovery
- Tool listing is cached.
- Cache TTL is explicit.
- Tool list refresh can be forced.
- Tool list change notifications are used where available.
- Stale tools can be served during transient server failures.
- Metadata hashes are computed.
- Added, removed, and changed tools are logged.
Tool exposure
- Tools are namespaced or aliased.
- Name collisions are detected.
- The model sees a curated profile, not every tool.
- Tool sets are scoped by user, task, or session.
- Tool descriptions are reviewed before production exposure.
- Dangerous tools are hidden by default.
Call routing
- Qualified tool names map to one server and one original tool.
- Unknown tools are rejected.
- Unavailable servers fail cleanly.
- Calls use the original server-local tool name.
- Arguments are validated before forwarding where practical.
- Mutating tools are not blindly retried.
- Timeouts are enforced.
Reliability
- Connection backoff exists.
- Circuit breakers prevent retry storms.
- Slow servers do not block the whole registry.
- Degraded state is distinguishable from failed state.
- Health signals include latency and error rate.
- Process exits are detected for stdio servers.
- Remote auth expiration is handled.
Security and policy
- Registry enforces authorization before calls.
- Policy is separate from model prompting.
- Server trust levels differ by source and environment.
- Destructive tools require approval or explicit role.
- Third-party MCP servers are isolated from internal credentials.
- Tool output is treated as untrusted data.
- Audit events are emitted for sensitive calls.
Observability
- Tool call logs include server ID and tool ID.
- Logs include policy decisions.
- Errors are classified by server, transport, and tool.
- Metrics track latency, failures, and cache staleness.
- Metadata drift triggers alerts.
- Sensitive arguments are redacted.
What I Would Choose
If I were building a multi-server MCP client today, I would start with an embedded registry inside the client process, but I would design it as if it could later become a standalone service.
That means:
- config-driven server definitions
- explicit server IDs
- namespaced tool references
- lazy discovery
- profile-based tool exposure
- central call routing
- policy hooks from day one
- structured audit events from day one
For a single desktop app or internal prototype, the registry can live in-process. That keeps things simple.
For a team with multiple agents, multiple environments, and sensitive internal systems, I would move the policy and audit layer into a central service as soon as the second client appears. Duplicating authorization logic across clients is a bad habit.
The most important architectural decision is not whether you use stdio, HTTP, a proxy, or a gateway.
It is this:
Your MCP client should not merely connect to servers. It should govern them.
Twenty MCP servers can be powerful. Twenty unmanaged MCP servers are just twenty ways for your agent to do the wrong thing.
Build the registry first. The servers become much easier to live with.