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:
- Write model-specific function-calling JSON schemas.
- Wire up a custom dispatch loop per model SDK.
- 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 MCP | With MCP |
|---|---|
| N tool definitions × M models = N×M integrations | N tool servers + M model clients = N+M integrations |
| Per-model schema dialects | One JSON schema format |
| No standard for resources/prompts | Resources and prompts are first-class |
| Switching models rewrites tools | Switching models changes one line |
MCP Architecture in 60 Seconds
MCP is a client-server protocol. Three roles:
- MCP Server — exposes capabilities. Could be a wrapper around your database, a SaaS API, the filesystem, or anything else.
- MCP Client — runs inside the AI application (Claude Desktop, an agent framework, your custom app). Connects to servers and surfaces their capabilities to the model.
- Transport — stdio (local subprocess), SSE/HTTP (remote), or WebSocket. The protocol is transport-agnostic.
A server exposes three kinds of capabilities:
- Tools — functions the model can call (query the DB, send an email, run code).
- Resources — data the model can read (files, DB rows, API responses).
- 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:
| Category | Examples |
|---|---|
| Databases | Postgres, MySQL, MongoDB, Snowflake, BigQuery |
| SaaS APIs | GitHub, Slack, Notion, Linear, Jira, Salesforce |
| Cloud | AWS, GCP, Azure, Cloudflare |
| Dev tools | Filesystem, shell, git, Docker, Kubernetes |
| Search | Brave, Google, Tavily, internal RAG |
| Browser | Playwright, 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:
- You want to swap or route between models without rewriting tools.
- You have multiple tools across multiple services.
- You want tools, resources, and prompts in one consistent interface.
- You're sharing tool servers across teams or projects.
- You want the model to discover capabilities rather than hardcode them.
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
- Over-broad tools. A "run any SQL" tool invites disaster. Scope tools narrowly (
get_user_by_emailbeatsquery_sql). - No timeouts. A hanging DB call hangs the whole agent. Always wrap.
- Stuffing tool output into context. Return summaries, not 10k-row dumps. The model can't use it and it bloats cost.
- No evals. Tool-using agents hallucinate more, not less, when tool outputs are noisy. See our hallucination guide.
- Trusting model-chosen args. Validate and sanitize. The model will pass malformed input eventually.
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:
- 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.
- 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:
- Confidence-tagged drafts. The LLM produces a draft marked "AI-generated, pending review." A human reviewer approves or edits before it reaches the user.
- Escalation queues. Low-confidence or verification-failed answers route to a human queue with the retrieved context attached, so the reviewer doesn't start from scratch.
- Feedback loops. Capture reviewer edits as training data for your eval set and (if volume warrants) fine-tuning. Every human correction makes the next version better.
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:
- Least privilege. Each server gets only the credentials it needs. Read-only by default.
- Input validation. Never trust model-generated arguments. Validate against the schema; reject anything that doesn't match.
- 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 →