MCP: The Protocol That Standardized How Agents Use Tools
Before November 2024, tool integration was chaos. Every AI platform had its own format for how to define, call, and handle tools. Building an agent meant learning OpenAI's tool format, Anthropic's format, custom integrations for your APIs, and adapting everything when you switched platforms.
Then Anthropic released the Model Context Protocol (MCP), and everything changed.
This article explains what MCP is, why it exists, how it works, and how it solves the M×N integration problem that plagued agent development.
The M×N Problem: Why We Needed MCP
Before MCP, every AI model needed its own custom integration with every tool — an explosion of complexity:
flowchart LR
subgraph Models
GPT[GPT-4]
CL[Claude]
GEM[Gemini]
end
subgraph Tools
GH[GitHub]
SL[Slack]
SF[Salesforce]
NO[Notion]
end
GPT ---|custom| GH
GPT ---|custom| SL
GPT ---|custom| SF
CL ---|custom| GH
CL ---|custom| SL
CL ---|custom| NO
GEM ---|custom| GH
GEM ---|custom| SF
GEM ---|custom| NO
style Models fill:#fafaf9,stroke:#e7e5e4
style Tools fill:#fafaf9,stroke:#e7e5e4
Before MCP, the tool integration problem looked like this:
Models: OpenAI GPT-4, Anthropic Claude, Google Gemini, Meta Llama
Tools: GitHub, Slack, Salesforce, Notion, Custom APIs
Integration matrix:
- OpenAI + GitHub = custom integration
- OpenAI + Slack = different custom integration
- Claude + GitHub = different format entirely
- Claude + Slack = different format entirely
- Gemini + GitHub = yet another format
- ... (M × N combinations)
Each model had its own tool definition syntax. Each tool needed a different integration for each model. Want to add a new model? Integrate all your tools again. Want to add a new tool? Integrate it with every model.
The cost: Exponential complexity. With 5 models and 50 tools, you're maintaining 250+ integration points.
What MCP Actually Is
MCP is a standard protocol for how LLM-powered applications (like agents) communicate with tools.
Think of it like HTTP. HTTP standardized how web browsers talk to web servers. Before HTTP, every browser had its own protocol for every server. MCP does the same thing for AI tools.
Key insight: MCP decouples models from tools. Your agent doesn't need to know OpenAI's tool format or Claude's format. It uses MCP. Tools don't need to know about models. They expose MCP servers. The MCP protocol translates between them.
The Architecture
With MCP, a single standard sits between all models and all tools:
flowchart LR
subgraph App["Your Agent App"]
AG[Agent / LLM]
MC[MCP Client]
end
subgraph Servers["MCP Servers"]
MS1[GitHub Server]
MS2[Slack Server]
MS3[Custom API]
end
AG -->|tool request| MC
MC -->|JSON-RPC| MS1
MC -->|JSON-RPC| MS2
MC -->|JSON-RPC| MS3
MS1 -->|result| MC
MS2 -->|result| MC
MS3 -->|result| MC
MC -->|tool result| AG
style App fill:#fafaf9,stroke:#e7e5e4
style Servers fill:#fafaf9,stroke:#e7e5e4
MCP Client: Runs in your application. Manages connections to MCP servers.
MCP Protocol: Standardized messages. Request/response format.
MCP Server: Exposes tools through a standard interface. Could be GitHub API, Slack API, your custom API, or a local utility.
Real Example
Before MCP, using GitHub in an agent with Claude looked like:
# Custom integration, Claude's tool format
tools = [
{
"name": "search_github_issues",
"description": "Search GitHub issues",
"input_schema": {
"type": "object",
"properties": {
"repo": {"type": "string"},
"query": {"type": "string"}
}
}
}
]
# Claude-specific tool handling
response = anthropic.Completions.create(
model="claude-3-sonnet",
tools=tools,
...
)
With MCP, it's:
# MCP client connects to any MCP server
mcp_client = MCPClient()
mcp_client.connect("stdio", command=["python", "github_mcp_server.py"])
# Your agent code is the same—doesn't know it's MCP
response = anthropic.Completions.create(
model="claude-3-sonnet",
tools=mcp_client.get_tools(), # Returns standardized tools
...
)
The key: the MCP server handles translation. Your agent code doesn't care.
How MCP Works: Client, Server, Transport
The MCP Server
An MCP server is a process that exposes tools. Here's a minimal example (Python):
import json
from mcp.server.fastmcp import FastMCP
# Create MCP server
server = FastMCP("github-server")
# Define a tool using MCP
@server.tool()
def search_github_issues(repo: str, query: str) -> dict:
"""Search issues in a GitHub repository"""
# Your implementation
issues = call_github_api(repo, query)
return {"issues": issues}
# Run the server
if __name__ == "__main__":
server.run()
The @server.tool() decorator registers the function with MCP. The server automatically:
- Creates the tool schema
- Handles incoming MCP requests
- Parses arguments
- Calls your function
- Returns results in MCP format
The MCP Client
In your agent application:
from mcp.client.sync import ClientSession
from mcp.client.stdio import StdioTransport
# Create a transport (how to communicate with server)
transport = StdioTransport(
command="python",
args=["github_mcp_server.py"]
)
# Create a client session
session = ClientSession(transport)
# List available tools
tools = session.list_tools()
# Returns:
# [
# {
# "name": "search_github_issues",
# "description": "Search issues in a GitHub repository",
# "inputSchema": {...}
# }
# ]
# Call a tool
result = session.call_tool(
"search_github_issues",
{"repo": "anthropics/anthropic-sdk-python", "query": "agent"}
)
print(result) # {'issues': [...]}
Message Flow
Here's what happens when your agent calls an MCP tool:
Agent: "Search for bug reports in the Python SDK"
↓ (Agent decides to use search_github_issues tool)
MCP Client sends:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_github_issues",
"arguments": {
"repo": "anthropics/anthropic-sdk-python",
"query": "bug"
}
}
}
↓ (Over transport: stdio, HTTP, WebSocket, etc.)
MCP Server receives, parses, calls function:
search_github_issues(
repo="anthropics/anthropic-sdk-python",
query="bug"
)
Function executes, returns result:
{
"issues": [
{"id": 123, "title": "Bug in tool parsing", "state": "open"},
{"id": 456, "title": "Memory leak in context", "state": "open"}
]
}
↓
MCP Server sends response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"issues": [...]
}
}
↓
MCP Client receives, parses, returns to agent:
[
{"id": 123, "title": "Bug in tool parsing", "state": "open"},
{"id": 456, "title": "Memory leak in context", "state": "open"}
]
Agent: "I found 2 open bugs. The first is about tool parsing..."
All of this is standardized. The agent doesn't know it's talking to GitHub. The server doesn't know it's Claude. MCP translates.
Transports: How Servers and Clients Communicate
MCP servers can communicate via different transports:
1. Stdio Transport
The server runs as a subprocess. Communication happens over stdin/stdout.
transport = StdioTransport(
command="python",
args=["mcp_server.py"]
)
Pros: Simple, no network setup, fast Cons: Only for local servers, one connection per process
2. HTTP Transport
The server runs as an HTTP server. Client makes HTTP requests.
transport = HttpTransport(
url="http://localhost:5000/mcp"
)
Pros: Scalable, remote servers, multiple clients Cons: Slightly higher latency, requires server setup
3. WebSocket Transport
Bidirectional WebSocket communication.
transport = WebSocketTransport(
url="ws://localhost:5000/mcp"
)
Pros: Real-time, efficient, supports server-initiated messages Cons: Requires WebSocket infrastructure
Real choice: Most production agents use HTTP or WebSocket. Stdio is for local development.
Real Examples of MCP Servers
Anthropic published MCP server implementations. Here are real examples:
Example 1: File System Server
Read, write, edit files safely:
@server.tool()
def read_file(path: str) -> str:
"""Read a file"""
with open(path, 'r') as f:
return f.read()
@server.tool()
def write_file(path: str, content: str) -> dict:
"""Write a file"""
with open(path, 'w') as f:
f.write(content)
return {"status": "written"}
@server.tool()
def list_directory(path: str) -> list:
"""List files in directory"""
return os.listdir(path)
An agent using this can:
- Read config files
- Write logs
- List available files
- All through a standard protocol
Example 2: Brave Search Server
Web search without building custom API integration:
@server.tool()
def search(query: str, count: int = 10) -> dict:
"""Search the web"""
results = brave_api.search(query, count=count)
return {
"results": [
{
"title": r.title,
"url": r.url,
"snippet": r.description
}
for r in results
]
}
Agent can now search the web. Your code doesn't handle API calls. MCP server does.
Example 3: GitHub Server
Interact with GitHub without custom integrations:
@server.tool()
def create_issue(repo: str, title: str, body: str) -> dict:
"""Create a GitHub issue"""
issue = github_client.create_issue(
repo=repo,
title=title,
body=body
)
return {"id": issue.id, "url": issue.html_url}
@server.tool()
def list_issues(repo: str, state: str = "open") -> list:
"""List GitHub issues"""
issues = github_client.list_issues(repo, state=state)
return [{"id": i.id, "title": i.title} for i in issues]
What MCP Replaces
Before MCP, integrating tools meant:
1. Custom Tool Adapters
You'd write custom code for each model/tool combo:
# Before MCP: Claude + GitHub
def claude_search_github_issues(repo, query):
# Translate to Claude's format
tool_input = {
"repo": repo,
"query": query
}
# Call Claude with specific tool format
response = anthropic.messages.create(
model="claude-3-sonnet",
tools=[{
"name": "search_github_issues",
"input_schema": {...} # Claude-specific
}],
...
)
return parse_claude_response(response)
# Before MCP: OpenAI + GitHub
def openai_search_github_issues(repo, query):
# Translate to OpenAI's format (different!)
response = openai.ChatCompletion.create(
model="gpt-4",
tools=[{
"function": {
"name": "search_github_issues",
"parameters": {...} # OpenAI-specific
}
}],
...
)
return parse_openai_response(response)
With MCP: Write once, use everywhere.
2. Custom API Clients
You'd build custom clients for each service:
# Before MCP: GitHub API client
class GitHubClient:
def __init__(self, token):
self.token = token
def search_issues(self, repo, query):
url = f"https://api.github.com/repos/{repo}/issues"
response = requests.get(url, headers={"Authorization": f"Bearer {self.token}"})
return response.json()
# Before MCP: Slack API client
class SlackClient:
def __init__(self, token):
self.token = token
def send_message(self, channel, text):
# Different API, different format, different error handling
...
# Repeat for Salesforce, Notion, etc.
With MCP: Use community-maintained servers.
3. Error Handling per Tool
Each integration had custom error handling:
# Before MCP: handling GitHub errors
try:
issues = github_client.search_issues(repo, query)
except GithubAuthError:
raise AgentException("GitHub auth failed")
except GithubNotFound:
raise AgentException("Repo not found")
except GithubRateLimit:
# Retry logic
time.sleep(60)
return github_client.search_issues(repo, query)
except Exception as e:
raise AgentException(f"GitHub error: {e}")
With MCP: Protocol handles error format. Server handles retries. Client just uses the response.
Practical: Building an Agent with MCP
Here's a complete example:
from mcp.client.sync import ClientSession
from mcp.client.stdio import StdioTransport
from anthropic import Anthropic
# 1. Set up MCP clients for available tools
transports = {
"github": StdioTransport(
command="python",
args=["github_mcp_server.py"]
),
"web_search": StdioTransport(
command="python",
args=["brave_search_mcp_server.py"]
)
}
sessions = {}
for name, transport in transports.items():
sessions[name] = ClientSession(transport)
# 2. Collect all available tools
all_tools = []
for session in sessions.values():
all_tools.extend(session.list_tools())
# 3. Create agent with Claude
client = Anthropic()
def run_agent(user_input):
messages = [{"role": "user", "content": user_input}]
while True:
# Call Claude with all available MCP tools
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=4096,
tools=all_tools,
messages=messages
)
# Check if Claude wants to use a tool
if response.stop_reason == "tool_use":
# Process tool calls
tool_calls = [
block for block in response.content
if block.type == "tool_use"
]
tool_results = []
for tool_call in tool_calls:
# Find which session has this tool
for name, session in sessions.items():
tools = {t["name"]: t for t in session.list_tools()}
if tool_call.name in tools:
# Call via MCP
result = session.call_tool(
tool_call.name,
tool_call.input
)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_call.id,
"content": json.dumps(result)
})
break
# Add assistant response and tool results to messages
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
else:
# Claude is done, return response
return response.content[0].text
# Use the agent
result = run_agent("Search for AI agent articles and create a GitHub issue with findings")
print(result)
This agent:
- Uses multiple MCP servers (GitHub, web search)
- Doesn't care about their internal implementation
- Handles tool calls standardly
- Scales to add more servers easily
Key Takeaways
MCP solves the M×N integration problem:
- Before MCP: 5 models × 50 tools = 250 integrations to maintain
- With MCP: 5 models + 50 tools = standardized protocol
MCP provides:
- Standard tool definition format
- Standard request/response protocol
- Multiple transport options (stdio, HTTP, WebSocket)
- Community-maintained servers
What this means for agents:
- Build once, deploy anywhere
- Add tools without changing agent code
- Switch models without rewriting integrations
- Focus on reasoning, not plumbing
MCP is still young (released November 2024), but it's already the standard way to build production agents. If you're building agents without MCP, you're reinventing the wheel.
Start here: https://modelcontextprotocol.io/