Building Custom MCP Servers for Enterprise

9 min read Module 5 of 10 Topic 15 of 30

What you'll learn

  • Explain the MCP protocol architecture (JSON-RPC 2.0, tools/resources/prompts) and when to build a custom MCP server
  • Implement an MCP server with the Python SDK, including input validation and structured error responses
  • Add HTTP header-based authentication and semantic versioning to MCP tool definitions
  • Deploy MCP servers as containerized services and configure agent discovery of available capabilities
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The Model Context Protocol (MCP) is an open standard that defines how agents discover and call tools exposed by external servers. Rather than hardcoding tool logic into each agent, you build MCP servers that expose enterprise capabilities, database queries, internal APIs, document retrieval, workflow triggers, as standardized, discoverable tools. Any agent that speaks MCP can immediately use any MCP server. This lesson builds a production-grade enterprise MCP server: authenticated, versioned, containerized, and observable.

MCP Protocol Architecture

MCP uses JSON-RPC 2.0 as its transport layer. Servers expose three primitive types: tools (callable functions with typed input schemas), resources (readable data sources like documents or database records), and prompts (reusable prompt templates with parameters). Agents connect to MCP servers at session start, call tools/list to discover available capabilities, and call tools/call to invoke them. The protocol supports both stdio (for local subprocess servers) and HTTP/SSE (for networked servers, which is the enterprise pattern).

flowchart TD
    A1["Research Agent"] --> D["Agent Discovery\ntools/list"]
    A2["Writer Agent"] --> D
    A3["Compliance Agent"] --> D
    D --> MCP["Enterprise MCP Server\n(HTTP + SSE)"]
    MCP --> T1["Tool: crm_search/v2"]
    MCP --> T2["Tool: kb_query/v1"]
    MCP --> T3["Tool: workflow_trigger/v1"]
    T1 --> SF["Salesforce API"]
    T2 --> KB["Knowledge Base\nQdrant"]
    T3 --> WF["Workflow Engine\n(internal API)"]

    style A1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style A2 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style A3 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style D fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style MCP fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style T1 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style T2 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style T3 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style SF fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style KB fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style WF fill:#EEF0F7,stroke:#6366F1,color:#0F172A

Building an MCP Server with the Python SDK

The mcp Python SDK provides a FastMCP class that handles protocol negotiation, tool registration, and JSON-RPC serialization. You define tools as decorated async functions with Pydantic-validated inputs.

from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
import asyncpg

# FastMCP handles JSON-RPC 2.0 transport, capability negotiation,
# and error formatting: you write pure async Python functions.
mcp = FastMCP(
    name="enterprise-knowledge-server",
    version="1.2.0",
    description="Internal knowledge base and CRM search capabilities",
)

class KBSearchInput(BaseModel):
    query: str = Field(..., min_length=3, max_length=500, description="Search query text")
    top_k: int = Field(5, ge=1, le=20, description="Number of results to return")
    doc_type: str | None = Field(None, description="Filter by document type: policy, procedure, faq")

class KBSearchResult(BaseModel):
    doc_id: str
    title: str
    excerpt: str
    relevance_score: float
    doc_type: str

@mcp.tool(name="kb_query/v1", description="Search the internal knowledge base by semantic similarity")
async def kb_query(input: KBSearchInput) -> list[KBSearchResult]:
    """
    Semantic search over the internal knowledge base.
    We validate input with Pydantic BEFORE touching the database
    invalid inputs are rejected at the protocol layer with a structured
    error, not a 500 from the database driver.
    """
    pool = mcp.state["db_pool"]  # Injected at startup, shared across requests
    embedding = await generate_embedding(input.query)  # Your embedding function

    type_filter = "AND doc_type = $3" if input.doc_type else ""
    params = [embedding, input.top_k, input.doc_type] if input.doc_type else [embedding, input.top_k]

    rows = await pool.fetch(
        f"""
        SELECT doc_id, title, excerpt, doc_type,
               1 - (embedding <=> $1::vector) AS relevance_score
        FROM knowledge_base
        WHERE 1=1 {type_filter}
        ORDER BY embedding <=> $1::vector
        LIMIT $2
        """,
        *params,
    )

    return [KBSearchResult(**dict(row)) for row in rows]

Authentication via HTTP Headers

Enterprise MCP servers must authenticate callers. For internal agent networks, API key authentication via a custom HTTP header is the simplest pattern that works across all agent frameworks. Validate the key against a secrets store on every request, not on startup, so key rotation takes effect immediately without a server restart.

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
import hashlib, hmac

VALID_KEY_HASHES: set[str] = set()  # Pre-loaded from secrets manager at startup

class APIKeyMiddleware(BaseHTTPMiddleware):
    """
    Validate X-Agent-API-Key header on every MCP request.
    We compare hashes, not raw keys, this prevents timing attacks
    and means we never need to store the plaintext key in memory.
    """
    async def dispatch(self, request: Request, call_next):
        if request.url.path in ("/health", "/ready"):
            # Health probes do not require authentication
            # Kubernetes/load balancers need these to work pre-auth
            return await call_next(request)

        api_key = request.headers.get("X-Agent-API-Key", "")
        key_hash = hashlib.sha256(api_key.encode()).hexdigest()

        if key_hash not in VALID_KEY_HASHES:
            return JSONResponse(
                status_code=401,
                content={"error": "Invalid or missing API key"},
            )

        return await call_next(request)

Versioning MCP Tools with Semantic Versioning

Tool versioning in MCP is done by including the version in the tool name: kb_query/v1, kb_query/v2. This is the simplest approach that works with all MCP clients, no custom capability negotiation required. Register both versions simultaneously during migration windows.

sequenceDiagram
    participant OLD as Old Agent (v1 client)
    participant NEW as New Agent (v2 client)
    participant MCP as MCP Server

    OLD->>MCP: tools/list
    MCP-->>OLD: [kb_query/v1, kb_query/v2, crm_search/v1]
    OLD->>MCP: tools/call kb_query/v1 (works as before)
    MCP-->>OLD: Results

    NEW->>MCP: tools/list
    MCP-->>NEW: [kb_query/v1, kb_query/v2, crm_search/v1]
    NEW->>MCP: tools/call kb_query/v2 (uses new schema)
    MCP-->>NEW: Enhanced results with new fields

    Note over MCP: v1 deprecated header in response
    Note over MCP: Remove v1 once all agents migrate

    style OLD fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style NEW fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style MCP fill:#fff7ed,stroke:#f59e0b,color:#0F172A

Deploying as a Docker Container

MCP servers should be containerized and deployed behind an internal load balancer. They are stateless (all state lives in the databases they connect to), so horizontal scaling is trivial. The Dockerfile is minimal: a Python base image, dependency installation, and an entrypoint that starts the server with uvicorn.

# Startup: inject shared resources into MCP server state
import asyncpg, os
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
    """
    Create shared connection pool at startup, not per-request.
    Connection pools are expensive to create, one pool per process,
    shared across all concurrent tool calls via asyncpg's pool.acquire().
    """
    pool = await asyncpg.create_pool(
        dsn=os.environ["DATABASE_URL"],
        min_size=5,
        max_size=20,
        command_timeout=30,
    )
    mcp.state["db_pool"] = pool
    yield
    await pool.close()

# Start the server: uvicorn enterprise_mcp:app --host 0.0.0.0 --port 8080
app = mcp.streamable_http_app(lifespan=lifespan)

Agent Discovery and MCP Server Health Monitoring

In a production network, agents discover MCP servers via a central registry: a simple key-value store (Redis or Consul) where each MCP server registers its address, name, version, and health status at startup. Agents query the registry on initialization to build their tool catalog. Each MCP server exposes a /health endpoint that the registry polls every 30 seconds; unhealthy servers are removed from the registry automatically, and agents that cache the tool list re-query after a cache miss or error.

Monitor MCP servers like any microservice: track tool call latency (p50/p95/p99), error rate by tool name and version, and authentication failure rate (a spike signals a credential rotation issue or a misconfigured agent). Export these metrics to your existing observability stack via OpenTelemetry, the MCP server is just an HTTP service from the infrastructure’s perspective.

This lesson completes the module on enterprise integrations. You now have the full stack: distributed state that is safe under concurrent agent writes, long-term memory with semantic retrieval, typed clients for Salesforce and SAP, database agents with cost controls, and custom MCP servers that expose all of these capabilities as standardized, discoverable tools for your entire agent network.

Knowledge Check

3 questions to test your understanding

1 You have three agent types in your network: a research agent, a writer agent, and a compliance agent, all of which need to query your internal knowledge base. What is the correct architectural choice?

2 A new version of your CRM tool in an MCP server requires a new required parameter that old agents do not supply. How do you deploy this change without breaking existing agents?

3 An agent sends a tool call to your MCP server. The tool executes a database query that takes 45 seconds, far exceeding the agent's 10-second tool timeout. The agent marks the tool call as failed and retries. How do you fix this?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan