Reading the MCP (Model Context Protocol) specification can be intimidating. People see “Protocol” and “Specification” and think you have to be a compiler developer or networking expert to understand it. You have to implement some complex state machine. You have to understand obscure handshakes.
But that’s not true at all. I’m going to prove it to you by building a real MCP server from scratch, a real client that uses it, and showing you exactly how data flows between all three components. I think you’ll be surprised at how simple it actually is.
The Three Players: LLM, Client, MCP Server
Before we build anything, let’s be absolutely clear about what each component does:
The LLM (Large Language Model) = The reasoner
- Receives tool schemas and user requests
- Decides when to tell the client (this is the code that called the LLM) when use a tool and with what parameters
- Interprets results given to it from the client
- Can be Claude, GPT-4, Llama, etc.
- Doesn’t care about implementation details of tools AND DOES NOT RUN CODE OR TOOLS. Purely the decision maker. Not the executer
The Client = The orchestrator/middle-man
- Reads configuration files
- Connects to MCP servers to get tool schemas
- Sends tool schemas to the LLM
- Handles LLM’s tool requests by passing it to the MCP server
- Gets results from calling MCP Server
- Returns results to the LLM
- A Client can be any simple program that can do the above. Nothing special here. Purely the middle-man between the decision maker and the executer
- Examples of programs that have mcp clients built into them: CLI tools (claude-code), IDEs (Cursor), web apps (claude.ai), Python scripts (see below), etc.
The MCP Server = The tool provider
- Gives the client a list of its tools (functions)
- Tells client what each tool (function) does
- Tells the client what data is needed (parameters) to use a tool (function)
- Receives requests from the client to use a tool
- Executes the tool (function) with the parameters supplied by client
- Returns structured results
- Has zero knowledge of the LLM
- Doesn’t care how or why it’s being called
The magic is that these three only need to speak a simple protocol. None of them need to know what the others do internally.
Building a Real MCP Server (It’s Just Functions)
Let’s build a DNS management MPC server. Here’s the complete implementation (conceptually identical to the real Cloudflare MCP Server):
# dns_mcp_server.py
import json
import sys
from typing import Any
# Our fake database of DNS records
dns_database = {
"example-zone-123": [
{"name": "example.com", "type": "A", "content": "198.51.100.42", "ttl": 3600},
{"name": "www.example.com", "type": "A", "content": "198.51.100.43", "ttl": 3600},
{"name": "api.example.com", "type": "A", "content": "198.51.100.44", "ttl": 3600},
]
}
def get_dns_records(zone_id: str, record_type: str) -> dict:
"""Business logic: retrieve DNS records"""
if zone_id not in dns_database:
return {"error": f"Zone {zone_id} not found"}
records = [r for r in dns_database[zone_id] if r["type"] == record_type]
return {"zone_id": zone_id, "records": records}
def list_tools() -> list:
"""Return available tools (MCP protocol requirement)"""
return [
{
"name": "get_dns_records",
"description": "Retrieve DNS records for a zone",
"inputSchema": {
"type": "object",
"properties": {
"zone_id": {
"type": "string",
"description": "The zone ID (e.g., example-zone-123)"
},
"record_type": {
"type": "string",
"enum": ["A", "AAAA", "CNAME", "MX", "TXT"],
"description": "DNS record type"
}
},
"required": ["zone_id", "record_type"]
}
}
]
def handle_request(request: dict) -> dict:
"""Handle incoming MCP protocol requests"""
method = request.get("method")
if method == "tools/list":
return {"jsonrpc": "2.0", "result": {"tools": list_tools()}}
elif method == "tools/call":
tool_name = request["params"]["name"]
arguments = request["params"]["arguments"]
if tool_name == "get_dns_records":
result = get_dns_records(**arguments)
return {"jsonrpc": "2.0", "result": result}
else:
return {"jsonrpc": "2.0", "error": f"Unknown tool: {tool_name}"}
else:
return {"jsonrpc": "2.0", "error": f"Unknown method: {method}"}
# Main loop: read from stdin, write to stdout
if __name__ == "__main__":
while True:
try:
line = sys.stdin.readline()
if not line:
break
request = json.loads(line)
response = handle_request(request)
print(json.dumps(response))
sys.stdout.flush()
except Exception as e:
print(json.dumps({"jsonrpc": "2.0", "error": str(e)}))
sys.stdout.flush()
That’s it. That’s the MCP server. It’s just:
- Define your business logic (the
get_dns_recordsfunction) - Define what tools you provide (the
list_toolsfunction) - Handle incoming MCP requests (the
handle_requestfunction) - Read JSON from stdin, write JSON to stdout
The server doesn’t care about the LLM. It doesn’t care what client calls it. It just handles requests and returns results.
Building a Real Client (It’s Just Configuration + Function Calls)
Now let’s build a client that discovers this server and makes requests. First, the config file:
{
"mcp_servers": {
"dns_tools": {
"type": "local",
"command": "python",
"args": ["dns_mcp_server.py"]
}
}
}
This tells the client: “Run python dns_mcp_server.py as a subprocess when you start up.”
Now here’s the client code for communicating with MCP Servers:
# mcp_client.py
import json
import subprocess
import sys
from typing import Any, Optional
class MCPClient:
def __init__(self, config_file: str):
"""Load config and start MCP servers"""
with open(config_file) as f:
self.config = json.load(f)
self.servers = {}
self.server_tools = {}
# Start all configured servers
for server_name, server_config in self.config["mcp_servers"].items():
if server_config["type"] == "local":
# Spawn a subprocess for local servers
process = subprocess.Popen(
[server_config["command"]] + server_config["args"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
bufsize=1
)
self.servers[server_name] = process
# Discover tools from this server
self.discover_tools(server_name)
def send_request(self, server_name: str, request: dict) -> dict:
"""Send a request to an MCP server and get response"""
process = self.servers[server_name]
# Write request to server's stdin
request_json = json.dumps(request)
process.stdin.write(request_json + "\n")
process.stdin.flush()
# Read response from server's stdout
response_line = process.stdout.readline()
response = json.loads(response_line)
return response
def discover_tools(self, server_name: str) -> None:
"""Ask server what tools it provides"""
request = {"method": "tools/list"}
response = self.send_request(server_name, request)
# Store tools from this server
self.server_tools[server_name] = response["result"]["tools"]
print(f"Discovered {len(response['result']['tools'])} tools from {server_name}")
def get_all_tools(self) -> dict:
"""Return all available tools for the LLM"""
all_tools = {}
for server_name, tools in self.server_tools.items():
for tool in tools:
all_tools[tool["name"]] = {
"server": server_name,
"schema": tool
}
return all_tools
def call_tool(self, tool_name: str, arguments: dict) -> Any:
"""Execute a tool request"""
# Find which server has this tool
for server_name, tools in self.server_tools.items():
for tool in tools:
if tool["name"] == tool_name:
# This server has the tool
request = {
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
}
}
response = self.send_request(server_name, request)
return response["result"]
raise ValueError(f"Tool {tool_name} not found")
# Example usage
if __name__ == "__main__":
client = MCPClient("mcp_servers.json")
# Get available tools
tools = client.get_all_tools()
print(f"\nAvailable tools: {list(tools.keys())}")
# Call a tool
result = client.call_tool("get_dns_records", {
"zone_id": "example-zone-123",
"record_type": "A"
})
print(f"\nTool result: {json.dumps(result, indent=2)}")
That’s the client. It:
- Reads the config file
- Starts the servers (spawns subprocesses)
- Discovers available tools
- Lets you call tools by name with arguments
- Returns results
Where the LLM Enters the Picture
Just as above was the comminication layer between Client and MCP Server, now we need to hook up the Client -> LLM communication. Let’s say you’re calling the LLM via an API (like the Anthropic API). Here’s what that code could look like:
# llm_orchestrator.py (this is the client to llm communication channel)
import json
import anthropic
from mcp_client import MCPClient
def run_with_llm():
# Start the MCP client
client = MCPClient("mcp_servers.json")
# Get available tools for the LLM
available_tools = client.get_all_tools()
# Format tools for the LLM API
tools_for_llm = [
{
"name": tool_name,
"description": tool_info["schema"]["description"],
"input_schema": tool_info["schema"]["inputSchema"]
}
for tool_name, tool_info in available_tools.items()
]
# Initialize the LLM client
llm = anthropic.Anthropic()
# Start conversation with the LLM
messages = [
{
"role": "user",
"content": "What DNS A records do we have for example.com?"
}
]
# Send to LLM with available tools
response = llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=tools_for_llm,
messages=messages
)
print(f"LLM Response: {response.content}")
# Check if LLM wants to use a tool
for content_block in response.content:
if content_block.type == "tool_use":
tool_name = content_block.name
tool_input = content_block.input
print(f"\nLLM decided to use tool: {tool_name}")
print(f"With arguments: {json.dumps(tool_input, indent=2)}")
# Execute the tool using the MCP client
tool_result = client.call_tool(tool_name, tool_input)
print(f"\nTool returned: {json.dumps(tool_result, indent=2)}")
# Send result back to LLM
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": content_block.id,
"content": json.dumps(tool_result)
}
]
})
# Get final response from LLM
final_response = llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=messages
)
print(f"\nFinal LLM Response: {final_response.content[0].text}")
if __name__ == "__main__":
run_with_llm()
Now you can see the complete flow:
- You run
python llm_orchestrator.py "What DNS A records do we have?" - The client (
llm_orchestrator.py) starts and spawns the MCP server (dns_mcp_server.py) - The client discovers tools and sends them to the LLM API
- The LLM (running at Anthropic’s servers) receives the question and available tools
- The LLM decides: “I should use the
get_dns_recordstool” - The client receives the LLM’s decision and calls the MCP server
- The MCP server executes the business logic and returns results
- The client sends results back to the LLM
- The LLM formulates a final response
- You see the answer
Data Flow Step by Step
Let me trace exactly what data flows where and when:
Step 1: Client reads config
File: mcp_servers.json → Client reads it
Step 2: Client starts server
Client spawns: python dns_mcp_server.py
Server listens on stdin/stdout
Step 3: Client discovers tools
Client writes to server's stdin:
{"method": "tools/list"}
Server reads from stdin, processes request
Server writes to stdout:
{"jsonrpc": "2.0", "result": {"tools": [...]}}
Client reads from server's stdout, parses it
Step 4: Client sends tools to LLM
Client → LLM API:
{
"model": "claude-3-5-sonnet-20241022",
"messages": [...],
"tools": [
{
"name": "get_dns_records",
"description": "...",
"input_schema": {...}
}
]
}
Step 5: LLM makes decision
LLM receives question and tools
LLM thinks: "I should call get_dns_records"
LLM returns to Client:
{
"type": "tool_use",
"name": "get_dns_records",
"input": {
"zone_id": "example-zone-123",
"record_type": "A"
}
}
Step 6: Client calls MCP Server with tool data
Client writes to server's stdin:
{
"method": "tools/call",
"params": {
"name": "get_dns_records",
"arguments": {
"zone_id": "example-zone-123",
"record_type": "A"
}
}
}
Step 7: MCP Server reads input, executes get_dns_records(), writes result to stdout:
{
"jsonrpc": "2.0",
"result": {
"zone_id": "example-zone-123",
"records": [
{"name": "example.com", "type": "A", "content": "198.51.100.42", "ttl": 3600}
]
}
}
Client reads from stdout
Step 8: Client sends result back to LLM
Client → LLM API:
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "...",
"content": "{\"zone_id\": \"...\", \"records\": [...]}"
}
]
}
Step 9: LLM formulates response
LLM reads tool result
LLM thinks and writes: "You have one A record for example.com..."
LLM returns final response to Client
Client prints it to you
Responsibility Matrix: Who Does What?
| Task | MCP Server | Client | LLM |
|---|---|---|---|
| Define tool logic | ✅ | ||
| Provide tool schemas | ✅ | ||
| Execute tool code | ✅ | ||
| Read config files | ✅ | ||
| Spawn/connect to servers | ✅ | ||
| Discover available tools | ✅ | ||
| Decide which tool to use | ✅ | ||
| Interpret tool results | ✅ | ||
| Format responses for humans | ✅ | ||
| Handle authentication | ✅ | ||
| Retry on failure | ✅ |
See? Each component has a clear job. There’s no overlap, no mystery.
What MCP Actually Is
Now you can see what MCP really is. It’s not magic. It’s a contract that says:
“If you’re an MCP Server, you must:
- Listen for requests on stdin/stdout (or HTTP)
- Respond to
tools/listwith your available tools - Respond to
tools/callby executing tools - Return JSON-formatted responses
- Follow the jsonrpc 2.0 format”
If you’re a Client, you must:
- Read configuration for where servers are
- Connect to servers (MCP server, LLM Provider Server)
- Ask them what tools they provide
- Send the schemas to the LLM
- Send tool execution request to MCP server when the LLM asks
- Return results from MCP Server to the LLM”
If you’re an LLM, you just:
- Receive tool schemas
- Look at user requests
- Decide if you should use a tool
- Tell the client which tool to use
- Get results back
- Use those results to form a response”
That’s it. It’s a protocol. Boring. Straightforward. No magic.
Why You Don’t Need to Understand Compiler Theory
The MCP spec might be intimidating because it uses formal language. But the core concepts are:
- A client spawns or connects to a server – You already know how to do this
- The client asks the server for available tools – It’s a function call
- The server returns descriptions of tools – It’s just data structures
- The LLM decides to use a tool – It’s the LLM’s decision
- The client sends that decision to the server – It’s just passing parameters
- The server executes the code – It’s just calling a function
- The server returns a result – It’s just returning data
- The client sends that result to the LLM – It’s just passing data around
You don’t need to understand networking internals. You don’t need compiler theory. You just need to understand: “data goes in, data comes out, and we’re all following the same format.”
Final Thoughts
I hope this demystified MCP for you. It’s not complicated. It’s actually elegant.
The protocol defines three simple responsibilities:
- Servers provide tools
- Clients orchestrate connections and manage the LLM
- LLMs do reasoning
Each component is focused. Each component is replaceable. You can swap the LLM, you can swap the client, you can add new servers—as long as they follow the contract.
That’s the real power of MCP. It’s not about being a “protocol.” It’s about having a clear, simple contract that lets any LLM work with any client and any server.
If you build your own MCP server following the patterns I showed you, you’ve already understood 90% of what you need to know.
If you have questions about implementing MCP, building servers, or integrating with clients, I’d love to hear from you. Send me an email or leave a comment.
If you liked this post, please share it! I genuinely appreciate it, and I’d love to know if this made MCP click for you.
If you’re interested in infrastructure, protocols, and building systems that work well with AI, subscribe to my blog at jasonroell.com. I write about this stuff regularly.
Have a great day, and learn on!