Before MCP, every AI tool had its own plugin system. Claude Code had one. Cursor had another. Your database tool worked in Claude Code but not in Cursor, so you wrote it twice. Switching tools meant rewriting all your integrations. This was the state of AI tooling in 2024 —each tool an island, each integration custom.
MCP —the Model Context Protocol —changed this in 2025, and in 2026 it is everywhere. Claude Code uses it. Cursor supports it. LangChain, LiteLLM, and every major AI platform have added MCP support. The official MCP specification defines the protocol —it is an open standard (JSON-RPC based) that standardizes how AI models discover and call tools. Write one MCP server. Use it with any MCP-compatible client.
What Is MCP —and Why It Matters
The protocol, not the marketing. MCP is a JSON-RPC 2.0-based protocol. An MCP server exposes tools, resources, and prompts. An MCP client (embedded in an AI application) connects to the server, discovers what is available, and calls tools on behalf of the LLM. The transport layer is pluggable —stdio for local tools, HTTP with SSE for remote services.
MCP vs. function calling —complementary, not competitive. Function calling: the LLM calls a tool within a single API request. The tool definition is sent in the API call. MCP: the LLM discovers available tools through a standardized protocol, then calls them —potentially across multiple sessions and tools. MCP standardizes the discovery and description of tools. Function calling executes them. You can use MCP to manage your tool catalog and function calling to invoke them —they work together. For a detailed comparison of function calling implementations across OpenAI, Anthropic, Google, and DeepSeek, see our function calling and tool use guide.
Why MCP matters for API developers. Before MCP: you wrote a database query tool. To use it with Claude Code, you wrote a Claude-Code-specific plugin. To use it with Cursor, you wrote a Cursor-specific integration. To use it with your custom app, you wrote custom code. After MCP: write one MCP server. Every MCP-compatible client can use it. This is the “USB-C for AI tools” analogy —it is not perfect, but it is the standard that won.
MCP Architecture: Servers, Clients and Transports
MCP Server. Exposes tools, resources, and prompts. Written in Python, Node.js, or Go —whatever you prefer. Runs locally (stdio transport) or remotely (HTTP+SSE transport). A server is a program. It starts. It listens for connections. It responds to tool-call requests. It stops when the client disconnects.
MCP Client. Connects to MCP servers, discovers available tools, sends tool-call requests from the LLM, and returns results. Embedded in AI applications —Claude Code, Cursor, your custom app. The client is the bridge between the LLM’s decision (“I need to query the database”) and the tool’s execution (SELECT * FROM users).
Transports. stdio: the server runs as a subprocess of the client. Standard input/output for communication. Best for local development tools —zero network configuration, zero latency. The stdio transport adds less than 1ms overhead per message round-trip on a modern machine. HTTP+SSE: the server runs as a remote service. HTTP POST for requests, Server-Sent Events for streaming responses. Best for production services —multiple clients can connect, server can be updated independently without restarting every developer’s IDE.
Architecture diagram:
LLM → MCP Client → Transport (stdio/HTTP) → MCP Server → External Resources (DB, API, Filesystem)
Building Your First MCP Server
A weather + stock price MCP server. 15 minutes. Two tools.
# mcp_server.py
import json
import sys
from typing import Any
class MCPServer:
def __init__(self):
self.tools = {
"get_weather": {
"description": "Get current weather for a city. Returns temperature in Celsius.",
"inputSchema": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Tokyo'"}
},
"required": ["city"]
}
},
"get_stock_price": {
"description": "Get current stock price for a ticker symbol.",
"inputSchema": {
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "Stock ticker, e.g. AAPL"}
},
"required": ["symbol"]
}
}
}
def handle_request(self, request: dict) -> dict:
method = request.get("method")
if method == "tools/list":
return {"tools": list(self.tools.values())}
elif method == "tools/call":
tool_name = request["params"]["name"]
tool_args = request["params"]["arguments"]
result = self._execute_tool(tool_name, tool_args)
return {"content": [{"type": "text", "text": str(result)}]}
else:
return {"error": f"Unknown method: {method}"}
def _execute_tool(self, name: str, args: dict) -> Any:
if name == "get_weather":
return f"Weather in {args['city']}: 22°C, partly cloudy"
elif name == "get_stock_price":
return f"{args['symbol']}: $185.50"
else:
return f"Unknown tool: {name}"
# stdio transport —reads JSON-RPC from stdin, writes to stdout
if __name__ == "__main__":
server = MCPServer()
for line in sys.stdin:
request = json.loads(line)
response = server.handle_request(request)
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
Connect to Claude Desktop. Anthropic’s MCP quickstart walks through the full setup. Add this to your claude_desktop_config.json:
{
"mcpServers": {
"my-tools": {
"command": "python",
"args": ["mcp_server.py"]
}
}
}
Restart Claude Desktop. Your two tools —get_weather and get_stock_price —are now available. Claude discovers them automatically. Try: “What is the weather in Tokyo and what is Apple’s stock price?” Claude will call both tools, in parallel, and synthesize a response.
For a complete walkthrough of the Claude API —authentication, streaming, tool use, and error handling —see our Claude API developer guide.
Building a Real MCP Server: Database Query Tool
The weather server proves MCP works in 50 lines of Python. Production tools need more: parameterized queries, connection pooling, and error messages the LLM can self-correct from. Here is a complete MCP server that queries a SQLite database, built with the official MCP Python SDK (pip install mcp). Three tools —query_users, get_user_by_id, create_user. Copy, adapt, deploy.
# db_mcp_server.py —production-grade MCP database server
import sqlite3
import asyncio
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationCapabilities
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("database-tools")
# Database connection —created once, reused across all tool calls
_connection: sqlite3.Connection | None = None
def get_db():
global _connection
if _connection is None:
_connection = sqlite3.connect("app.db")
_connection.row_factory = sqlite3.Row
# WAL mode: concurrent reads without locking. Without this,
# two simultaneous query_users calls serialize —the second
# blocks until the first releases the read lock.
_connection.execute("PRAGMA journal_mode=WAL")
return _connection
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="query_users",
description="Run a SELECT query against the users table. "
"Use for searching, filtering, or counting users. "
"Returns results as JSON array. "
"Column names: id, name, email, role, status, created_at.",
inputSchema={
"type": "object",
"properties": {
"where_clause": {
"type": "string",
"description": "SQL WHERE clause without 'WHERE' keyword. "
"Example: \"age > 25 AND status = 'active'\". "
"Leave empty for all users."
},
"limit": {
"type": "integer",
"description": "Max rows to return. Default 50.",
"default": 50
}
}
}
),
Tool(
name="get_user_by_id",
description="Fetch a single user by primary key. Returns user object or null.",
inputSchema={
"type": "object",
"properties": {
"user_id": {
"type": "integer",
"description": "The user's ID in the database."
}
},
"required": ["user_id"]
}
),
Tool(
name="create_user",
description="Insert a new user into the database. "
"Returns the created user with their assigned ID.",
inputSchema={
"type": "object",
"properties": {
"name": {"type": "string", "description": "User's full name."},
"email": {"type": "string", "description": "Valid email address."},
"role": {
"type": "string",
"enum": ["admin", "editor", "viewer"],
"description": "User role. Default: viewer."
}
},
"required": ["name", "email"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
db = get_db()
if name == "query_users":
where = arguments.get("where_clause", "")
limit = arguments.get("limit", 50)
query = "SELECT * FROM users"
params = []
if where.strip():
query += " WHERE " + where
query += " LIMIT ?"
params.append(limit)
try:
rows = db.execute(query, params).fetchall()
except sqlite3.OperationalError as e:
# Return column names so the LLM can self-correct bad WHERE clauses
return [TextContent(
type="text",
text=f"Query failed: {e}. Valid columns: id, name, email, role, status, created_at."
)]
return [TextContent(
type="text",
text=str([dict(r) for r in rows])
)]
elif name == "get_user_by_id":
user_id = arguments["user_id"]
row = db.execute(
"SELECT * FROM users WHERE id = ?", [user_id]
).fetchone()
if row is None:
return [TextContent(type="text", text=f"No user found with id={user_id}")]
return [TextContent(type="text", text=str(dict(row)))]
elif name == "create_user":
name_val = arguments["name"]
email = arguments["email"]
role = arguments.get("role", "viewer")
try:
cursor = db.execute(
"INSERT INTO users (name, email, role) VALUES (?, ?, ?)",
[name_val, email, role]
)
db.commit()
new_id = cursor.lastrowid
except sqlite3.IntegrityError as e:
return [TextContent(
type="text",
text=f"Insert failed: {e}. Email may already exist."
)]
new_user = db.execute(
"SELECT * FROM users WHERE id = ?", [new_id]
).fetchone()
return [TextContent(type="text", text=str(dict(new_user)))]
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationCapabilities(
sampling={},
experimental={},
roots={}
),
notification_options=NotificationOptions()
)
if __name__ == "__main__":
asyncio.run(main())
Four decisions in this code that prevent 3 AM on-call pages.
Tool descriptions with examples. The LLM reads your tool description to decide which tool to call. A bare "description": "Query the database" gives the model no way to distinguish between tools —for a SELECT COUNT(*) query it will frequently try get_user_by_id instead. Without explicit instructions, models frequently pick the wrong tool when multiple tools have similar descriptions. The description above includes what the tool does, what it returns, and the exact column names. That specificity eliminates the ambiguity.
Parameterized queries —no f-strings. db.execute("SELECT * FROM users WHERE id = ?", [user_id]) never becomes db.execute(f"SELECT * FROM users WHERE id = {user_id}"). One f-string in a database MCP server, and an LLM that generates user_id = "1 OR 1=1" leaks every row in your users table. An LLM will generate that input. It has happened in production. Parameterized queries are a hard requirement, not a nice-to-have.
Error messages with recovery hints. When query_users fails because the LLM guessed a column name that does not exist, the error includes the valid column list: "Valid columns: id, name, email, role, status, created_at." The LLM reads this, corrects its query, and retries the tool call —zero human intervention. A bare "OperationalError: no such column" produces a shrug from Claude and a blocked user.
Connection reuse with WAL journal mode. PRAGMA journal_mode=WAL enables concurrent reads without locking. Without it, two simultaneous query_users calls serialize —the second waits 50ms for the first to release the read lock. In a tool chain where Claude calls query_users, reads the results, then calls get_user_by_id for each row, that lock wait compounds across 5 sequential calls to 250ms of user-visible latency. WAL mode eliminates the bottleneck.
MCP Across LLM Providers
MCP support varies sharply by provider. Anthropic created the protocol and has the deepest integration. Everyone else is playing catch-up at different speeds. Here is where each major provider stands in mid-2026.
| Provider | MCP Support Level | Transport Support | Production Readiness | SDK Quality |
|---|---|---|---|---|
| Anthropic | Native —the originator | stdio + HTTP/SSE | Production for both transports | Official Python, TypeScript SDKs with full spec coverage |
| OpenAI | Via Agents SDK + community adapters | stdio (Agents SDK), HTTP via adapters | Agents SDK: production. Adapters: beta-quality | Community-maintained; no first-party MCP SDK |
| Community adapters only | stdio, limited HTTP/SSE | Development-only | Early-stage community packages; sparse documentation | |
| DeepSeek | OpenAI-compatible path | Via OpenAI adapter toolchain | Works, but unsupported | No dedicated MCP SDK; inherits OpenAI adapter quirks |
| Aggregation Platforms (infrastructure layer —routes to all providers below) | MCP gateway —connects once, routes everywhere | HTTP/SSE with unified auth | Production with managed auth, rate limits, logging | Platform SDKs for Python, JavaScript, LangChain |
The MCP gateway pattern. An aggregation platform acts as an MCP client, connects to your MCP servers, and exposes tools to any LLM through a single API endpoint. You write one MCP server. You use it with GPT-5.5, Claude Opus 4, Gemini 3, and DeepSeek V3 —all through POST https://api.tokspan.com/v1/chat/completions with the same API key. The platform handles protocol translation so your MCP tools work regardless of which model is active. One server definition feeds 6+ models. See the TokSpan quickstart for the 5-minute setup.
MCP vs. Function Calling: When to Use Which
The decision comes down to one question: do your tools need to work across multiple LLM clients?
Use function calling alone when you call one LLM API directly —OpenAI, Anthropic, or Google —and your tools are specific to a single application. You pass tool definitions directly in the tools array of the chat completions request. The infrastructure is zero: no separate server process, no transport layer, no protocol negotiation. A customer support bot that looks up order status via one internal REST endpoint at GET /orders/:id needs function calling, not MCP. Do not over-engineer this —MCP adds a server process, a transport, and a protocol handshake for zero benefit when you have one client.
Use MCP + function calling together when you have multiple AI clients —Claude Code on your laptop, Cursor on your teammate’s, and a custom dashboard on your staging server —that all need the same tools. Your database query tool, deployment tool, and log viewer get defined once in an MCP server. Each client discovers them through tools/list and calls them through function calling at runtime.
Your tool catalog is centralized. Updates to tool descriptions or schemas propagate instantly to every client without redeployment. An internal platform team at a 200-person engineering org using this pattern cut tool integration maintenance from 8 hours per new tool to 30 minutes.
Use MCP as a gateway when you route tool calls to multiple LLM providers through an aggregation platform. One MCP server definition feeds tools to GPT-5.5, Claude Opus 4, and Gemini 3 simultaneously. The platform translates between MCP’s tool discovery protocol and each provider’s function calling API. You never write provider-specific tool definitions. You never debug why a tool works with Claude but silently fails with GPT-5.5. One server definition feeds 6+ models through a single endpoint —no per-provider tool configuration required.
The wrong choice —from experience. Consider a typical scenario: a developer at a small startup spends two weeks setting up MCP servers with HTTP/SSE transport, auth middleware, and connection pooling for three internal tools used exclusively with Claude Code. Function calling would have taken two hours. Two weeks of engineering time traded for infrastructure that served exactly one client. Start with function calling. Migrate to MCP when you hit your second client —not before.
MCP in Production: Authentication, Rate Limiting and Error Handling
stdio is for development. HTTP+SSE is for production. The stdio transport runs your MCP server as a subprocess —no authentication, no network security, one client per server process. Fine for claude_desktop_config.json on your laptop. Wrong for a service serving 50 developers across your org. HTTP+SSE transport lets you deploy the server as a standalone service behind a load balancer, with proper auth, monitoring, and independent deploy cycles.
Authentication middleware. The MCP HTTP transport specification does not yet define a standard auth mechanism as of mid-2026. You add your own. The common pattern: validate an API key in the Authorization: Bearer <key> header before any MCP message is processed. For a TokSpan-based deployment, the platform handles auth at the API gateway layer —your MCP server receives pre-authenticated requests at https://api.tokspan.com/v1/. If you self-host, bolt on auth middleware before the MCP handler.
An expired token at depth 3 of a tool chain —where tool_a calls tool_b which calls tool_c —produces a cryptic JSON-RPC error that takes 45 minutes to trace back to the auth layer. Get auth right before anything else. For a complete security checklist, see our production security checklist.
# Auth middleware for MCP HTTP server (FastAPI pattern)
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
VALID_TOKENS = {"mcp-secret-abc123", "mcp-prod-xyz789"} # Replace with DB lookup
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if request.url.path.startswith("/mcp/"):
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
if token not in VALID_TOKENS:
raise HTTPException(status_code=401, detail="Invalid MCP API key")
return await call_next(request)
Rate limiting prevents LLM-amplified incidents. One loosely described tool —a run_sql tool with no LIMIT enforcement —and an LLM generates a query that scans 10 million rows at 2 AM because a user asked “show me everything.” A token bucket rate limiter on the server side caps query_users at 60 calls per minute and create_user at 10 calls per minute. Use Redis for distributed rate limiting across multiple server instances —INCR + EXPIRE per tool name per API key.
Without rate limiting, a single enthusiastic LLM prompt becomes the root cause of a production database outage. You will get paged at 3 AM. You will trace it back to a tool definition you wrote in 15 minutes and forgot about six sprints ago.
Error handling that helps the LLM recover. Do not return "Error: something went wrong." The LLM reads your error message and tries again. Return three things: the error type, the specific parameter that failed, and valid alternatives. This error is dead weight: "Invalid input." This one lets Claude self-correct in one retry: "create_user failed: role must be one of [admin, editor, viewer]. Received: 'superadmin'." The difference between those two error messages is the difference between a self-healing tool chain and a stuck user staring at a spinner until they give up and open a ticket.
Common MCP Pitfalls: What Breaks in Practice
Here are four failure modes that MCP developers hit within their first month. Every one is avoidable if you know about it before you deploy.
Vague tool descriptions. This is the number one cause of wrong-tool-selection bugs. "description": "Fetch data" tells the LLM nothing. Claude Opus 4 will guess which tool to use and will guess wrong frequently when multiple tools have overlapping descriptions. Write tool descriptions like you are explaining the tool to a developer who has never seen your codebase. Include what the tool does, what it returns, an example of valid input, and when NOT to use it.
The query_users description in the database server above is not verbose —it is exactly precise enough to eliminate ambiguity.
stdio buffer limits. The stdio transport uses OS pipes. Linux default pipe buffer: 64KB. Your query_large_dataset tool returns 2MB of JSON. The write() call blocks. The client hangs. You stare at a spinner for 30 seconds, then kill -9 the process.
For any tool that might return more than 64KB, implement response chunking —split large results into pages with page and page_size parameters. Or switch to HTTP transport, where response size limits are configurable at the server framework level. Set a hard 512KB response cap on every tool. No tool in an MCP server should ever return more data than an LLM can usefully process in a single context window.
Tool name collisions across servers. You connect two MCP servers: one from the database team (get_status —replication lag), one from the ops team (get_status —deployment health). Both expose a tool called get_status. Claude Code prepends a server-name prefix: database-tools_get_status and ops-tools_get_status. But some MCP clients, including recent versions of Cursor, silently pick whichever server responds to tools/list first. The fix is embarrassingly simple and universally ignored: namespace every tool name from day one. db_query_users. ops_deploy_service. logs_search_errors. A two-character prefix prevents an entire category of silent failures.
Schema validation gaps. An LLM will send "user_id": "42" (string) when your schema says "type": "integer". It will send "limit": -5 when your schema says "minimum": 1. It will send "role": "superadmin" when your schema says "enum": ["admin", "editor", "viewer"]. Your call_tool handler must validate every argument, coerce types where safe (int("42") —42), and return specific validation errors for everything else. Never assume the LLM respects your JSON Schema. It does not. Your server is the last line of defense between a hallucinated tool argument and your production database.
FAQ
Do I need MCP if I already use function calling?
They are complementary, not either-or. Function calling executes tools within an API call. MCP standardizes tool discovery and description across applications. Use MCP to define your tool catalog —the single source of truth for what tools exist and how to call them. Use function calling at runtime to actually invoke those tools. MCP is the interface layer. Function calling is the execution layer. If you have exactly one client application and three tools, skip MCP and use function calling directly. You will know when you need MCP: the moment you find yourself copy-pasting tool definitions between Claude Code config files and your custom app’s tools array.
Can I use MCP with OpenAI models?
Yes —via adapters or the OpenAI Agents SDK. Native support is less mature than Anthropic’s but functional for common tool patterns. If you use an aggregation platform with MCP gateway support, the platform handles the translation: your MCP server works with GPT-5.5 and GPT-5.5-mini without any OpenAI-specific MCP code on your side. The trade-off: community adapters lag behind Anthropic’s official SDK by roughly 3 to 6 months on new MCP spec features.
Is MCP production-ready in 2026?
For local tools via stdio transport: yes, and Claude Code’s stdio client is battle-tested across millions of developer hours. For remote services via HTTP: yes, with the caveat that you must add your own authentication middleware —the HTTP transport’s auth standard is still evolving. The protocol core (JSON-RPC message format, tool discovery, tool execution) is stable and, at time of writing, has not had a breaking change since the 2025-03-26 specification update. If you self-host an HTTP MCP server, budget 2 to 3 days for auth, rate limiting, and logging setup.
How does MCP relate to API aggregation platforms?
Aggregation platforms can serve as MCP gateways: you connect your MCP servers to the platform once. The platform routes tool calls to any LLM —GPT-5.5, Claude Opus 4, Gemini 3, DeepSeek V3. You get multi-model tool use without per-provider MCP configuration. One MCP server. All models. For details on how aggregation platforms implement MCP gateway routing in practice, see the TokSpan MCP integration docs.
Can I connect multiple MCP servers to one client?
Yes. Claude Code, Cursor, and most MCP clients support connecting to multiple servers simultaneously —add each server to your config file with a unique name, and all tools from all servers appear in the LLM’s available tool list. The sharp edge: tool name collisions across servers. Name your tools with a namespace prefix (db_query, ops_deploy) from day one. Do not rely on the client to disambiguate —not every client does, and those that do handle it inconsistently.
What happens when two MCP servers expose tools with the same name?
It depends on the client, and that is the problem. Claude Code appends the server name to create unique identifiers: database-tools_get_status and ops-tools_get_status. Cursor silently deduplicates —the first server to respond to tools/list wins. Do not rely on client-side disambiguation at all. Prefix every tool name with a server-specific namespace. db_get_status and ops_get_status remove the ambiguity at the source.
How do I debug MCP tool calls when something goes wrong?
Check three places, in order. First, client logs —Claude Code writes MCP messages to its log directory. Second, your server’s stderr —the stdio transport sends all stderr output to the client’s console (stdin and stdout are reserved for MCP protocol messages, but stderr is free for logging). Third, add structured JSON logging to your server: {"event": "tool_call", "tool": "query_users", "args": {...}, "duration_ms": 45, "error": null}. Structured logs catch the 80% of MCP bugs that surface only during on-call rotations when you are 15 minutes into an incident and have no idea which tool call failed or why.
Does MCP support streaming responses from tools?
Not in the current specification. MCP tool calls return a single content array —no partial responses, no progress updates, no Server-Sent Events from tool execution. For long-running operations like database migrations or report generation, return a job ID immediately and expose a separate get_job_status tool. The LLM polls it. This job-ID-plus-polling pattern is the de facto standard used by every major production MCP server as of mid-2026. Direct streaming support is on the MCP specification roadmap but has no committed ship date.
MCP unified AI tool integration in 2025–2026 —one server, any client, every model. But standardization invites competition. Google’s Agent-to-Agent Protocol (A2A) is gaining traction among enterprise teams that need agent-to-agent communication beyond simple tool calling. OpenAI is building its own agent SDK ecosystem with deeper platform integration. The question worth watching through 2027 is not whether MCP survives —the protocol itself is solid and the specification is stable —but whether “one protocol to rule them all” lasts longer than the industry’s appetite for platform-specific integrations that offer tighter coupling and better performance at the cost of lock-in.
The MCP server you built in the first section of this article works with one client —Claude Desktop. An MCP gateway connects that same server to every LLM in your stack, so the tool definitions you write once feed every model you use. TokSpan’s MCP integration docs cover gateway setup, auth configuration, and the production checklist from the section above —turning the 50-line stdio server into a multi-model tool layer that survives provider switches without touching a line of tool code.