MCP Protocol Guide: Building AI Agents with Model Context Protocol

Published 2026-07-26 · 17 min read

The Model Context Protocol (MCP) is the open standard that's quietly become the default way to connect LLMs to external tools, data sources, and services. Released by Anthropic in late 2024 and adopted by OpenAI, Google, and the open-source ecosystem through 2025-2026, MCP solves a problem every agent builder hit: every integration was a one-off, every model had its own tool-calling format, and switching models meant rewriting your tool layer.

MCP fixes this with a simple idea: expose capabilities (tools, resources, prompts) through a standard server, and let any MCP-aware client consume them. Write your Postgres MCP server once; Claude 4, GPT-5, Gemini, and every MCP-compatible agent can use it. This guide walks through building production MCP servers and agents, with the patterns we use at DrAI.

See Agent Model Pricing →

Why MCP Exists

Before MCP, connecting an LLM to your database looked like this for each model:

  1. Write model-specific function-calling JSON schemas.
  2. Wire up a custom dispatch loop per model SDK.
  3. Re-do all of it when you add a second model or a second tool.

The result was vendor lock-in at the integration layer — even when the models themselves were substitutable. MCP decouples the two: tool servers expose a standard protocol; model clients consume it. Adding a new model is a config change, not a rewrite.

Without MCPWith MCP
N tool definitions × M models = N×M integrationsN tool servers + M model clients = N+M integrations
Per-model schema dialectsOne JSON schema format
No standard for resources/promptsResources and prompts are first-class
Switching models rewrites toolsSwitching models changes one line

MCP Architecture in 60 Seconds

MCP is a client-server protocol. Three roles:

A server exposes three kinds of capabilities:

  1. Tools — functions the model can call (query the DB, send an email, run code).
  2. Resources — data the model can read (files, DB rows, API responses).
  3. Prompts — reusable prompt templates (parameterized workflows).

Building Your First MCP Server

The official Python SDK makes this straightforward. Here's a complete MCP server that exposes a Postgres query tool and a "list tables" resource:

# server.py — a minimal MCP server
from mcp.server import Server
from mcp.types import Tool, TextContent
import psycopg, json, os

server = Server("postgres-tools")
pool = psycopg.connect(os.environ["DATABASE_URL"])

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="query_postgres",
            description="Run a read-only SQL query against the docs database. "
                        "Use for retrieving facts. Always LIMIT results.",
            inputSchema={
                "type": "object",
                "properties": {
                    "sql": {"type": "string", "description": "SELECT query"},
                    "limit": {"type": "integer", "default": 20},
                },
                "required": ["sql"],
            },
        ),
        Tool(
            name="list_tables",
            description="List all tables in the public schema.",
            inputSchema={"type": "object", "properties": {}},
        ),
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    if name == "query_postgres":
        sql = arguments["sql"]
        # Safety: only allow SELECT
        if not sql.strip().upper().startswith("SELECT"):
            return [TextContent(type="text", text="Error: only SELECT allowed")]
        with pool.cursor() as cur:
            cur.execute(sql)
            cols = [d[0] for d in cur.description]
            rows = cur.fetchmany(arguments.get("limit", 20))
            return [TextContent(type="text",
                text=json.dumps({"columns": cols, "rows": rows}, default=str))]
    elif name == "list_tables":
        with pool.cursor() as cur:
            cur.execute("SELECT tablename FROM pg_tables WHERE schemaname='public'")
            return [TextContent(type="text",
                text=json.dumps([r[0] for r in cur.fetchall]))]

if __name__ == "__main__":
    import asyncio
    from mcp.server.stdio import stdio_server
    async def main():
        async with stdio_server() as (r, w):
            await server.run(r, w, server.create_initialization_options())
    asyncio.run(main())

Run it with python server.py and connect from any MCP client. The server advertises its tools; the client (and the model behind it) discovers them automatically.

Exposing Resources and Prompts

Tools are for actions; resources are for data the model reads, and prompts are reusable templates. Here's how to add them:

@server.list_resources()
async def list_resources() -> list[Resource]:
    return [
        Resource(
            uri="docs://schema",
            name="Database Schema",
            description="Current table schemas for grounding SQL generation",
            mimeType="text/plain",
        ),
    ]

@server.read_resource()
async def read_resource(uri: str) -> str:
    if uri == "docs://schema":
        with pool.cursor() as cur:
            cur.execute("""
                SELECT table_name, column_name, data_type
                FROM information_schema.columns
                WHERE table_schema = 'public'
                ORDER BY table_name, ordinal_position
            """)
            return "\n".join(f"{r[0]}.{r[1]} ({r[2]})" for r in cur.fetchall())

@server.list_prompts()
async def list_prompts() -> list[Prompt]:
    return [
        Prompt(
            name="summarize_table",
            description="Summarize the contents of a table for a non-technical user.",
            arguments=[PromptArgument(name="table", required=True)],
        ),
    ]

@server.get_prompt()
async def get_prompt(name: str, arguments: dict) -> GetPromptResult:
    if name == "summarize_table":
        table = arguments["table"]
        return GetPromptResult(
            messages=[{
                "role": "user",
                "content": {"type": "text", "text":
                    f"Query the {table} table, then write a 3-sentence summary "
                    "of what it contains for a non-technical stakeholder."},
            }]
        )

Resources let the model pull context on demand (instead of you stuffing it into the prompt). Prompts package up reusable workflows. Both are versioned and discoverable — a big upgrade over hardcoded system prompts.

Consuming MCP from an Agent

Here's the client side — an agent that connects to your MCP server, exposes its tools to Claude 4, and runs an agentic loop:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
import anthropic

async def run_agent(user_query: str):
    # 1. Connect to the MCP server
    server_params = StdioServerParameters(
        command="python", args=["server.py"],
        env={"DATABASE_URL": "postgresql://..."},
    )
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # 2. Discover tools
            tools_result = await session.list_tools()
            # Convert to Anthropic tool format
            claude_tools = [{
                "name": t.name,
                "description": t.description,
                "input_schema": t.inputSchema,
            } for t in tools_result.tools]

            # 3. Agentic loop
            client = anthropic.Anthropic()
            messages = [{"role": "user", "content": user_query}]
            for _ in range(10):  # max 10 tool rounds
                resp = client.messages.create(
                    model="claude-sonnet-4-20250514",
                    messages=messages, tools=claude_tools, max_tokens=4096,
                )
                if resp.stop_reason != "tool_use":
                    return resp.content  # model is done
                messages.append({"role": "assistant", "content": resp.content})
                for block in resp.content:
                    if block.type == "tool_use":
                        # 4. Call the MCP tool
                        result = await session.call_tool(
                            block.name, block.input
                        )
                        messages.append({
                            "role": "user",
                            "content": [{"type": "tool_result",
                                "tool_use_id": block.id,
                                "content": result.content[0].text}],
                        })

asyncio.run(run_agent("How many users signed up last week?"))

The key insight: the agent code is model-agnostic. Swap claude-sonnet-4 for gpt-5 and the rest works unchanged — the MCP server exposes the same tools to either. See our Claude 4 vs GPT-5 guide for model selection.

Using DrAI's OpenAI-Compatible Endpoint

If you're using DrAI as your model gateway, the same MCP tools work — just point the OpenAI SDK at our endpoint and swap models by changing one string:

import openai, os
client = openai.OpenAI(
    base_url="https://ai.dr-ai.top/v1",
    api_key=os.environ["DRAI_KEY"],
)

resp = client.chat.completions.create(
    model="gpt-5",   # or claude-opus-4, claude-sonnet-4, gemini-2.5-pro
    messages=messages,
    tools=claude_tools,  # same MCP-derived tool schemas
)

Sign in to grab an API key, and read the model routing guide for routing logic across these models.

Production Patterns

1. Auth and multi-tenancy

For multi-tenant apps, pass tenant context through the MCP server's environment or via resource URIs:

@server.call_tool()
async def call_tool(name, arguments):
    tenant = server.session_context["tenant_id"]  # set on connect
    sql = arguments["sql"].replace("{tenant}", str(tenant))
    ...

2. Rate limiting and timeouts

Every tool call should have a timeout and a cost ceiling. Wrap calls:

import asyncio

async def with_timeout(coro, seconds=10):
    return await asyncio.wait_for(coro, timeout=seconds)

result = await with_timeout(session.call_tool(name, args))

3. Observability

Log every tool call with name, args, duration, and result size. This is how you find the queries that bloat context or the tools the model misuses. Pair with the hallucination-prevention evals — bad tool outputs are a leading cause of agent hallucination.

4. Permission scoping

Not every model invocation should have every tool. Expose scoped subsets per agent role:

TOOL_SETS = {
    "reader": ["query_postgres", "list_tables"],
    "writer": ["query_postgres", "insert_row", "update_row"],
    "admin":  "*",  # all tools
}

@server.list_tools()
async def list_tools():
    role = server.session_context["role"]
    allowed = TOOL_SETS.get(role, [])
    return [t for t in ALL_TOOLS if t.name in allowed or allowed == "*"]

The MCP Ecosystem in 2026

The protocol's value compounds with the number of available servers. As of mid-2026, there are production-grade MCP servers for:

CategoryExamples
DatabasesPostgres, MySQL, MongoDB, Snowflake, BigQuery
SaaS APIsGitHub, Slack, Notion, Linear, Jira, Salesforce
CloudAWS, GCP, Azure, Cloudflare
Dev toolsFilesystem, shell, git, Docker, Kubernetes
SearchBrave, Google, Tavily, internal RAG
BrowserPlaywright, Puppeteer, computer-use

Anthropic maintains a reference servers repo; the open-source community has filled in the long tail. For a RAG system, combine the Postgres MCP server (for structured data) with a vector-search MCP server (for semantic retrieval) — see our RAG guide and vector DB comparison.

MCP vs. Plain Function Calling

If you're only using one model and one tool, raw function calling is simpler. MCP earns its keep when any of these apply:

For a single-model, single-tool script, skip MCP — it's overhead. For anything production-grade or multi-model, it's the right default in 2026.

Common Pitfalls

Streaming and Partial-Answer Reliability

Most production LLM features stream tokens to the user for perceived latency. Streaming introduces a reliability wrinkle: the user sees partial answers before the verification gate runs. Two patterns handle this safely:

  1. Stream-then-verify. Stream the candidate answer in real time, then run the verifier on completion. If the verdict is UNSUPPORTED, append a correction: "⚠️ Correction: the previous statement wasn't supported by sources." Users prefer a delayed correction over a confident lie left standing.
  2. Gated streaming. Buffer the first sentence, verify it, and only then start streaming. This adds ~1s to first-token latency but prevents the worst hallucinations from ever appearing. Use for high-stakes domains.

Either way, log the streamed answer alongside the final verdict so you can measure the gap between what users saw and what passed verification. That gap is your true hallucination exposure.

Human-in-the-Loop Fallback

For the highest-stakes outputs — legal advice, medical triage, financial recommendations — no combination of techniques is sufficient for full automation. Design for graceful escalation:

This isn't a failure of the AI — it's the correct architecture for domains where errors are irreversible. The LLM handles the 80% of queries that are routine; humans handle the 20% that matter most. The result is faster throughput and lower risk than either pure-human or pure-AI approaches.

Security Considerations

MCP servers are powerful — they execute code, hit databases, call APIs. Three rules:

  1. Least privilege. Each server gets only the credentials it needs. Read-only by default.
  2. Input validation. Never trust model-generated arguments. Validate against the schema; reject anything that doesn't match.
  3. Audit logging. Every tool call goes to an immutable log. You'll need it for debugging and compliance.

For agents that touch production data, add a human-approval step for destructive actions. The MCP spec supports this via the elicitation flow — the server can ask the user to confirm before executing.

The Bottom Line

MCP is the rare standard that actually stuck. It collapsed the N×M integration problem into N+M, made tool servers reusable across models, and gave agent builders a clean separation between capabilities and intelligence. If you're building AI agents in 2026, MCP is the default — and every model worth using supports it.

DrAI exposes Claude 4, GPT-5, and the rest through one MCP-friendly endpoint. Sign in, connect your MCP servers, and ship agents that aren't locked to one vendor.

See Model Pricing →

📚 Related Reading

GPT-5 Function Calling Guide: Build AI Agents That Use ToolsMaster GPT-5 function calling with this complete guide. Learn structured outputs...
🌐 English