$title =

Demystifying MCP: How the LLM, Client, and Server Actually Work Together (It’s so simple)

;

$content = [

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:

  1. Define your business logic (the get_dns_records function)
  2. Define what tools you provide (the list_tools function)
  3. Handle incoming MCP requests (the handle_request function)
  4. 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:

  1. Reads the config file
  2. Starts the servers (spawns subprocesses)
  3. Discovers available tools
  4. Lets you call tools by name with arguments
  5. 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:

  1. You run python llm_orchestrator.py "What DNS A records do we have?"
  2. The client (llm_orchestrator.py) starts and spawns the MCP server (dns_mcp_server.py)
  3. The client discovers tools and sends them to the LLM API
  4. The LLM (running at Anthropic’s servers) receives the question and available tools
  5. The LLM decides: “I should use the get_dns_records tool”
  6. The client receives the LLM’s decision and calls the MCP server
  7. The MCP server executes the business logic and returns results
  8. The client sends results back to the LLM
  9. The LLM formulates a final response
  10. 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/list with your available tools
  • Respond to tools/call by 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:

  1. A client spawns or connects to a server – You already know how to do this
  2. The client asks the server for available tools – It’s a function call
  3. The server returns descriptions of tools – It’s just data structures
  4. The LLM decides to use a tool – It’s the LLM’s decision
  5. The client sends that decision to the server – It’s just passing parameters
  6. The server executes the code – It’s just calling a function
  7. The server returns a result – It’s just returning data
  8. 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!

];

$date =

;

$category =

;

$author =

;

Discover more from The Curious Programmer

Subscribe now to keep reading and get access to the full archive.

Continue reading