DEV Community

Cover image for MCP Series (08): Enterprise Governance — Registry, Routing, and Observability
WonderLab
WonderLab

Posted on

MCP Series (08): Enterprise Governance — Registry, Routing, and Observability

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_jira tool (v1.x) instead of the current search_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]
Enter fullscreen mode Exit fullscreen mode

The Registry solves three things:

  1. Discovery: new Agents check the Registry to learn what's available — no relying on hallway conversations
  2. Deprecation signaling: deprecated status plus a migration_guide gives Agent code a concrete migration path
  3. 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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    ]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Three-Level Governance Roadmap

Level 1 — Individual/small team (do now):

  • Create mcp-registry.yaml with 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

  1. Registry is the foundation of discovery: without it, 20 Servers rely on word-of-mouth, duplicate development and version confusion are inevitable
  2. 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
  3. 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


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)