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/