Why Governance Becomes Necessary
Three MCP Servers are manageable from memory. Twenty require a system.
As the number of MCP Servers in an enterprise grows, predictable problems emerge:
- A new engineer builds a Jira tool that already exists because there's no directory
- An Agent calls the deprecated
search_jiratool (v1.x) instead of the currentsearch_issues(v2.x) - A tool call fails with no log — unclear whether the Server crashed or the arguments were wrong
- Token costs spike with no visibility into which Server's which tool caused it
Registry solves discovery. Routing solves dispatch. Observability solves diagnosis.
MCP Registry
A Registry is the enterprise directory of MCP Servers: their locations, versions, capabilities, and owners.
# mcp-registry.yaml
servers:
- id: jira-tools
name: Jira Tools
description: "Search, create, and update Jira tickets"
version: "2.1.0"
domain: engineering
owner: "@team-platform"
status: active
transport: stdio
command: python
args: ["/opt/mcp/jira/server.py"]
capabilities:
tools: [search_issues, create_issue, update_issue]
resources: [jira://projects, jira://sprint/current]
metrics:
monthly_calls: 4521
avg_latency_ms: 180
error_rate: 0.2%
- id: github-tools
name: GitHub Tools
version: "1.5.0"
domain: engineering
owner: "@team-platform"
status: active
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
capabilities:
tools: [create_pull_request, search_repositories, get_file_contents]
- id: jira-tools-legacy
name: Jira Tools (Legacy)
version: "1.2.0"
domain: engineering
status: deprecated
deprecation:
reason: "Superseded by jira-tools v2.x; search_jira renamed to search_issues"
migration_guide: "Replace search_jira with search_issues; argument structure unchanged"
removal_date: "2026-10-01"
capabilities:
tools: [search_jira, create_jira_ticket]
The Registry solves three things:
- Discovery: new Agents check the Registry to learn what's available — no relying on hallway conversations
-
Deprecation signaling:
deprecatedstatus plus amigration_guidegives Agent code a concrete migration path - Ownership: every Server has an owner — when something breaks, you know who to contact
Tool Routing Strategies
When an Agent needs to "search Jira tickets," how does it find the right Server? Four strategies:
Strategy 1: Static Configuration (simplest)
Declare all Servers directly in Agent settings:
{
"mcpServers": {
"jira": {
"command": "python",
"args": ["/opt/mcp/jira/server.py"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"]
}
}
}
Simple and predictable. Adding a new Server requires updating every Agent config file manually.
Strategy 2: Domain-Based Loading
Load only the Servers relevant to the current task:
DOMAIN_SERVERS = {
"engineering": ["jira-tools", "github-tools", "gitlab-tools"],
"data": ["postgres-readonly", "bigquery-tools"],
"communication": ["slack-tools", "email-tools"],
}
def load_servers_for_task(task_type: str) -> list[dict]:
domain = classify_task_domain(task_type)
server_ids = DOMAIN_SERVERS.get(domain, [])
registry = load_registry()
return [
s for s in registry["servers"]
if s["id"] in server_ids and s["status"] == "active"
]
Reduces unnecessary Server startup overhead. Each Agent loads only the tools it needs.
Strategy 3: Embedding Routing (semantic matching)
The same approach as Skill Series Article 06 — embed Server descriptions, embed the user request, find the nearest neighbors:
def route_to_server(user_input: str, registry: list[dict]) -> list[str]:
query_embedding = embedder.embed(user_input)
scored = []
for server in registry:
desc_embedding = get_cached_embedding(server["id"], server["description"])
score = cosine_similarity(query_embedding, desc_embedding)
scored.append((server["id"], score))
scored.sort(key=lambda x: x[1], reverse=True)
return [s[0] for s in scored[:3] if s[1] > 0.6]
Good for 20+ Servers where new ones get added frequently. The same limitation applies as in Skill routing: Servers in the same domain cluster in embedding space. Use negative examples in descriptions to distinguish them.
Strategy 4: Hierarchical Routing (recommended)
Coarse-filter by domain first, then run embedding matching within the domain:
def hierarchical_route(user_input: str, registry: list[dict]) -> list[str]:
# Layer 1: LLM classifies domain quickly
domain = llm_classify_domain(user_input) # "engineering" / "data" / ...
# Layer 2: embedding match within domain
domain_servers = [s for s in registry if s.get("domain") == domain]
return embedding_route(user_input, domain_servers)
Observability: Langfuse Integration
Without Trace, MCP tool calls are a black box. When something fails, you don't know why. When latency spikes, you don't know where. When token costs grow, you don't know which tool caused it.
Three-Layer Trace Structure
from langfuse import Langfuse
from langfuse.decorators import observe, langfuse_context
langfuse = Langfuse()
@observe(name="mcp_tool_call")
async def traced_tool_call(
server_id: str,
tool_name: str,
arguments: dict,
call_fn
) -> dict:
langfuse_context.update_current_observation(
input={"tool": tool_name, "arguments": arguments},
metadata={
"server_id": server_id,
"server_version": get_server_version(server_id),
}
)
t0 = time.perf_counter()
try:
result = await call_fn(tool_name, arguments)
latency_ms = (time.perf_counter() - t0) * 1000
langfuse_context.update_current_observation(
output=result,
metadata={"latency_ms": round(latency_ms, 2), "success": True}
)
return result
except Exception as exc:
latency_ms = (time.perf_counter() - t0) * 1000
langfuse_context.update_current_observation(
output={"error": str(exc)},
metadata={"latency_ms": round(latency_ms, 2), "success": False},
level="ERROR"
)
raise
Session-Level Trace
@observe(name="agent_session")
async def run_agent_session(user_input: str, session_id: str):
langfuse_context.update_current_trace(
session_id=session_id,
user_id="user:alice",
metadata={"task_type": "jira_query"}
)
result = await agent.run(user_input)
langfuse_context.update_current_observation(output={"result": result})
return result
Every tool call inside this session automatically attaches to the session trace.
What Trace Answers
Which tool call is slowest?
→ sort Spans by latency_ms
Which Server has the highest error rate?
→ group by server_id, count success=False fraction
Where is token spend concentrated?
→ LLM Span usage field, grouped by tool_name
What caused a specific Agent session failure?
→ search by session_id in Langfuse, expand the trace tree
Alert Rules
Wire key metrics into your alerting system (Grafana / Prometheus):
# Prometheus alerting rules
groups:
- name: mcp_server_alerts
rules:
- alert: MCPServerHighErrorRate
expr: mcp_tool_call_error_rate_5m > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "MCP Server {{ $labels.server_id }} error rate {{ $value | humanizePercentage }}"
- alert: MCPToolHighLatency
expr: mcp_tool_call_p90_latency_ms > 5000
for: 3m
labels:
severity: warning
annotations:
summary: "Tool {{ $labels.tool_name }} P90 latency {{ $value }}ms"
- alert: DeprecatedToolInUse
expr: mcp_deprecated_tool_calls_total > 0
for: 0m
labels:
severity: info
annotations:
summary: "Deprecated tool {{ $labels.tool_name }} called {{ $value }} times today"
Three-Level Governance Roadmap
Level 1 — Individual/small team (do now):
- Create
mcp-registry.yamlwith id / version / owner / status for every Server - Each Server's README lists its tools and usage examples
Level 2 — Team sharing (1-2 weeks):
- Registry in Git, changes go through PR review
- Langfuse Trace on every tool call: log tool_name / latency / success at minimum
- Error rate alerts configured
Level 3 — Enterprise governance (ongoing):
- Hierarchical routing (domain classification + embedding selection)
- Monthly Server health report (call volume, error rate, top consumers)
- Formal deprecation process (announce → alert → 90-day grace period → remove)
Summary
- Registry is the foundation of discovery: without it, 20 Servers rely on word-of-mouth, duplicate development and version confusion are inevitable
- Match routing strategy to scale: static config for 3 Servers; hierarchical routing (domain filter + embedding selection) for 20+, avoiding the same-domain embedding confusion problem
- Observability turns black boxes into records: Langfuse's session-level + tool-call-level Trace means failure root causes go from 'unknown' to 'found in 5 seconds on the Dashboard'
References
- Langfuse MCP Integration
- MCP Series planning document: MCP Knowledge Series Outline
Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.
Find more useful knowledge and interesting products on my Homepage
Top comments (0)