Home / Articles

MCP Under the Hood: JSON-RPC Messages, FastMCP Servers and a ReAct Trace

Follow a Model Context Protocol request from tools/list to the final answer, with FastMCP servers, a LangGraph ReAct agent and a tracker that logs every step.

Publisher: REACTAPP.TOP15 min read3513 words

This article is published in English.

Sources: How MCP Works: A Deep Dive with Code

Model Context Protocol, or MCP, is an open standard from Anthropic that lets AI models such as Claude reach external tools, data sources and services. A good mental picture is a universal plug: rather than writing a custom integration for every tool, a standard way exists for a model to talk to outside systems.

The parts

Three pieces make up the structure. The MCP host is the application a person actually uses, whether a chat window or an IDE, and it contains a client that manages the connection. The MCP protocol is the standardized communication layer between host and servers. Each MCP server connects to one kind of resource, for example a database, an external API or a code-execution environment, and acts as a bridge to it.

What happens at runtime

At a high level, a request travels through these stages:

  1. The user sends a query to the host.
  2. The host asks the MCP servers which tools they offer.
  3. The servers return their tool lists.
  4. The host sends the query plus the tool list to the LLM.
  5. The LLM picks a tool and tells the host.
  6. The host calls the right MCP server to run it.
  7. The server executes the tool and returns the result.
  8. The host passes that result back to the LLM.
  9. The LLM writes the final answer and the host delivers it.

A server exposes three kinds of things. Tools are functions the model can invoke, such as search_web, query_database or send_email. Resources are readable data the host can pull in as context, like files, database records, calendar events and documents, which the model can read but not execute. Prompts are pre-written templates the server offers. A GitHub server, for instance, might provide a "summarize this PR" template that the host can surface to the user.

At bottom, MCP is only a standardized way for a host to converse with a server. It fixes the message format and the rules of the conversation, and nothing beyond that. Specifically it defines four things.

  • Message shape. Everything is JSON-RPC 2.0. A request holds an id, a method and params, and a response repeats the id with either a result or an error, so both sides speak the same way.
  • Available methods. The protocol sets a fixed vocabulary of calls a host may make, for example tools/list for discovery, tools/call for running a tool, resources/read for pulling in data, and prompts/get for fetching a template. Servers must implement them.
  • The handshake. On first connection the two sides exchange initialize messages declaring their protocol version and capabilities, so each knows what the other supports before anything else happens.
  • Transport. How the bytes travel matters little. Stdio for a local process, HTTP with SSE and WebSockets all work, with the same message format throughout.

A single tool call breaks into four messages. First the host asks which tools exist:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

The server replies with its tool descriptions:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "search_web",
        "description": "Search the web for a given query",
        "inputSchema": {
          "type": "object",
          "properties": {
            "query": { "type": "string" }
          },
          "required": ["query"]
        }
      }
    ]
  }
}

The host then calls one of them:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "search_web",
    "arguments": {
      "query": "latest news on MCP protocol"
    }
  }
}

And the server returns the outcome:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Anthropic released MCP in Nov 2024 as an open standard..."
      }
    ]
  }
}

Learning by building works best, so the rest of this walks through a small MCP server consumed by an agent.

What FastMCP does

FastMCP is a Python framework for writing MCP servers with minimal boilerplate. Without it, a developer would have to implement the JSON-RPC message loop, serialize and deserialize each request and response, write JSON schemas by hand for every exposed function, and handle protocol messages such as initialize, tools/list, tools/call, resources/list and prompts/list. With it, an ordinary Python function plus a decorator is enough, and the framework handles the rest:

from fastmcp import FastMCP

mcp = FastMCP("My Server")   # creates the server

@mcp.tool()                  # registers the function as an MCP tool
def add(a: float, b: float) -> float:
    """Add two numbers."""    # docstring → tool description sent to the LLM
    return a + b             # type hints → JSON Schema sent to the LLM

mcp.run()                    # starts the stdio message loop

From the type annotations FastMCP builds a JSON schema on its own, and the docstring doubles as the plain-language description that the model reads while it weighs whether a tool is worth calling. Invoking mcp.run() starts a loop that blocks on stdin and stdout while speaking the MCP wire format, and that loop is precisely what MultiServerMCPClient attaches to.

Three primitives in the example servers

Every server in the project is a standalone FastMCP application exposing the three primitive kinds.

Tools come first: functions the model is allowed to call. Decorating a Python function with @mcp.tool() is all it takes, and FastMCP turns the annotations and docstring into the JSON schema that goes to the client during tools/list. The model studies that schema to work out when the tool applies and which arguments to supply.

# servers/math_server.py

@mcp.tool()
def divide(a: float, b: float) -> float:
    """Divide a by b. Raises an error if b is zero."""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Resources are read-only data living at a URI. The model doesn't invoke them automatically, so they must be requested explicitly. In this project they only demonstrate the idea, whereas production uses could include config files, snapshots of a database or reference documentation.

# servers/math_server.py

@mcp.resource("math://constants")
def get_math_constants() -> str:
    """Common mathematical constants."""
    return f"π = {math.pi}\n e = {math.e}\n ..."

@mcp.resource("math://formulas/{category}")
def get_formulas(category: str) -> str:
    """Retrieve mathematical formulas by category (geometry | algebra | statistics)."""
    catalog = {
        "geometry": (
            "Geometry Formulas:\n"
            "  Circle area:            A = π × r²\n"
            "  Circle circumference:   C = 2π × r\n"
            "  Rectangle area:         A = length × width\n"
            "  Triangle area:          A = (base × height) / 2\n"
            "  Sphere volume:          V = (4/3) × π × r³\n"
        ),
        "algebra": (
            "Algebra Formulas:\n"
            "  Quadratic formula:      x = (−b ± √(b²−4ac)) / 2a\n"
            "  Difference of squares:  a²−b² = (a+b)(a−b)\n"
            "  Perfect square:         (a+b)² = a²+2ab+b²\n"
            "  Sum of arithmetic seq:  S = n(a₁+aₙ)/2\n"
        ),
        "statistics": (
            "Statistics Formulas:\n"
            "  Mean:       μ = Σx / n\n"
            "  Variance:   σ² = Σ(x−μ)² / n\n"
            "  Std Dev:    σ = √(Σ(x−μ)² / n)\n"
            "  Z-score:    z = (x−μ) / σ\n"
        ),
    }
    return catalog.get(
        category,
        f"Unknown category '{category}'. Available: geometry, algebra, statistics",
    )

The URI math://formulas/{category} is a template. Its {category} portion is a path parameter, which makes math://formulas/geometry and math://formulas/algebra two distinct resources served by the same function.

Prompts are reusable instruction templates. A prompt is a parameterized system message the client may request, a way to package an expert persona or task instructions inside the server so any connected client can use them consistently.

# servers/math_server.py

@mcp.prompt()
def math_tutor(difficulty: str = "intermediate") -> str:
    return (
        f"You are an expert math tutor for {difficulty}-level students. "
        "Break every problem into numbered steps..."
    )

The servers together expose 19 tools, as the next sections show.

The client's point of view

Here is exactly what happens after running python main.py with a query. The end-to-end request path looks like this:

You type a query
      │
      ▼
main.py                    ← entry point, parses args, kicks off async loop
      │
      ▼
client/agent.py            ← spawns 3 MCP servers, builds the agent, invokes it
      │           │
      │           ▼
      │    utils/tracker.py ← fires on every LLM call, tool call, and result
      │
      ▼
LangGraph ReAct loop       ← think → call tool → observe → repeat
      │
      ▼
servers/{math,text,data}_server.py   ← each runs as an isolated subprocess

Step 1: where execution begins

main.py is the first thing Python executes. It loads environment variables, parses command-line arguments, prints a welcome panel and hands over to an async runner.

async def _run(queries: list) -> None:
    from client.agent import run_query   # imported here (late) to keep startup fast
    for i, q in enumerate(queries):
        await run_query(q)

def main() -> None:
    ...
    asyncio.run(_run(queries))

asyncio.run() creates a fresh event loop and blocks until _run() completes. The query used for the demonstration was:

Generate 8 random numbers between 5 and 50 using seed=42,
then calculate their statistics, and tell me if there are any outliers.

Run from the command line like so:

python3 main.py --query "Generate 8 random numbers between 5 and 50 using seed=42, \
then calculate their statistics, and tell me if there are any outliers."

A static info panel is then printed by _print_header():

╭────────────────────────────── 🔌  MCP Example ───────────────────────────────╮
│ Multi-Server MCP Demo                                                        │
│                                                                              │
│ Three FastMCP servers, each exposing tools + resources + prompts:            │
│   ●  Math Server   — add, subtract, multiply, divide, power, sqrt,           │
│ percentage                                                                   │
│   ●  Text Server   — count_words, word_frequency, reverse, transform,        │
│ extract_emails                                                               │
│   ●  Data Server   — generate_numbers, calculate_statistics, find_outliers,  │
│ normalise                                                                    │
│                                                                              │
│ Stack : FastMCP · LangChain · LangGraph · OpenAI · Rich                      │
│ Track : live Rich panels + JSONL log files under logs/                       │
╰──────────────────────────────────────────────────────────────────────────────╯

Step 2: building the agent

client/agent.py holds run_query(query), the async function that orchestrates everything. It begins by creating a tracker and a log file.

log_file = str(LOGS_DIR / f"run_{int(time.time())}.jsonl")
tracker = MCPTracker(log_file=log_file)

Before any server is connected, an MCPTracker is instantiated and a timestamped JSONL file is prepared under logs/. From then on, every event is written there in real time.

Next the user query gets printed:

╭──── 💬  USER QUERY ────╮
│ Generate 8 random...   │
╰────────────────────────╯

Then the servers to spawn are defined:

def _server_config() -> Dict[str, Any]:
    silent_env = {**os.environ, "FASTMCP_LOG_LEVEL": "ERROR"}
    return {
        "math_server": {
            "command": sys.executable,
            "args": ["servers/math_server.py"],
            "transport": "stdio",
            "env": silent_env,
        },
        "text_server": { ... },
        "data_server":  { ... },
    }

Each entry describes one MCP server running as a subprocess. The command is the interpreter already running (sys.executable), args names the server script, and transport says how client and server talk, where stdio means standard input and output pipes. The env field sets the subprocess environment, and FASTMCP_LOG_LEVEL=ERROR suppresses the FastMCP startup banner.

The stdio transport is the simplest in MCP: the client writes JSON-RPC messages to the server's stdin and reads replies from its stdout, so no network port opens.

After that the client connects to every server and discovers the tools:

client = MultiServerMCPClient(_server_config())
tools = await client.get_tools()

MultiServerMCPClient, from langchain-mcp-adapters, does the following for each server entry at once. It spawns a child process, sends the MCP initialize handshake over stdio, sends a tools/list request, receives the list of tool schemas and wraps each schema in a LangChain Tool object.

When await client.get_tools() completes, the result is a flat list of 19 StructuredTool objects: seven from the math server, seven from text and five from data. That list is the full set of actions open to the model.

Connected to 3 servers (math_server, text_server, data_server) with 19 tools: add, subtract, multiply,
divide, power, square_root, calculate_percentage, count_words, word_frequency, reverse_text,
transform_case, find_and_replace, extract_emails, count_vowels_consonants, generate_numbers,
calculate_statistics, find_outliers, sort_values, normalize_values

Then the language model and agent are created:

model = ChatOpenAI(model=model_name, temperature=0)
agent = create_react_agent(model, tools)

LangGraph's create_react_agent builds a compiled state graph with this structure:

START
  │
  ▼
[call_model]  ─── no tool call ──▶  END
      │
  tool call requested
      │
      ▼
[call_tools]
      │
      ▼
[call_model]  (loop again with tool result in context)

This is the ReAct pattern, reasoning plus acting. The model reasons about the next move, acts by calling a tool, observes the result and repeats until it has enough information to answer.

Finally the agent is invoked:

config = {"callbacks": [tracker]}
result = await agent.ainvoke({"messages": [("human", query)]}, config=config)

The "callbacks": [tracker] entry plugs into LangChain's callback system. Whenever the framework does something significant, such as starting or ending an LLM call or a tool call, it calls the matching method on tracker. That's how the tracker sees everything without sitting inside any of the logic.

Step 3: watching it live

MCPTracker extends LangChain's BaseCallbackHandler. Its methods are never called directly, because LangChain calls them at the right moments.

agent.ainvoke() called
      │
      ├─▶ on_chain_start()       "▶ AGENT STARTED" panel
      │
      ├─▶ on_chat_model_start()  "🤖 LLM CALL #1" panel  (timer starts)
      ├─▶ on_llm_end()           "LLM responded ⏱ 1.23s → will call: add, multiply"
      │
      ├─▶ on_tool_start()        "🔧 TOOL CALL #1" panel  (timer starts)
      ├─▶ on_tool_end()          "✓ TOOL RESULT ⏱ 0.01s" panel
      │
      ├─▶ on_tool_start()        (second tool, if any)
      ├─▶ on_tool_end()
      │
      ├─▶ on_chat_model_start()  "🤖 LLM CALL #2" (LLM synthesises final answer)
      ├─▶ on_llm_end()           no tools this time → loop ends
      │
      └─▶ agent.ainvoke() returns

Whenever on_tool_start fires, the tracker works out which server owns the tool and assigns a matching color. The only purpose is readability.

TOOL_SERVER_MAP = {
    "add":        "math_server",
    "count_words": "text_server",
    "generate_numbers": "data_server",
    ...
}
SERVER_COLORS = {
    "math_server": "cyan",
    "text_server":  "green",
    "data_server":  "yellow",
}

Each event becomes one line of JSON in logs/run_<timestamp>.jsonl. The file is opened in append mode and written immediately, so running tail -f logs/run_*.jsonl in another terminal shows raw events scrolling by as the agent works. After agent.ainvoke() returns, client/agent.py calls print_summary() by hand, which reports the total LLM calls, tool calls and wall-clock time for the whole run.

The first panel to appear is LLM call number 1, with a single message:

── 🤖  LLM CALL  #1  model=gpt-5-mini   messages=1 ──╮
│  Role    Content preview                             │
│  Human   Generate 8 random numbers between 5 and 50… │
╰──────────────────────────────────────────────────────╯

The ReAct graph has just started. The model receives the system prompt, injected by LangGraph with the tool list and their schemas, and the one human message, which is why messages=1.

  LLM responded  ⏱ 5.35s  → will call: generate_numbers

The tracker inspected the raw model response. Rather than prose, the model answered with a structured request to call a tool:

{
  "name": "generate_numbers",
  "arguments": {"count": 8, "min_val": 5, "max_val": 50, "seed": 42}
}

Inside response.generations[0][0].message.tool_calls the tracker spotted the request and printed its name. The 5.35 seconds is simply the round trip to the OpenAI API. LangGraph reads that decision and moves to the call_tools node, which yields tool call number 1:

╭── 🔧  TOOL CALL  #1 ──────────────────╮
│ Tool  :  generate_numbers             │
│ Server:  data_server                  │
│ Args  :                               │
│ {'count': 8, 'min_val': 5,            │
│  'max_val': 50, 'seed': 42}           │
╰───────────────────────────────────────╯

From the tool name generate_numbers the tracker consulted TOOL_SERVER_MAP, which resolves to data_server, and painted the panel yellow because SERVER_COLORS["data_server"] is "yellow". At this exact point LangChain pushes a JSON-RPC tools/call message down the stdio pipe to the data_server child process:

{"method": "tools/call", "params": {"name": "generate_numbers",
  "arguments": {"count": 8, "min_val": 5, "max_val": 50, "seed": 42}}}

The process receives it, runs the Python function and writes the result back to its stdout.

╭── ✓  TOOL RESULT  ⏱ 0.63s ────────────────────────────╮
│ [33.77, 6.13, 17.38, 15.04, 38.14, 35.45, 45.15, 8.91] │
╰──────────────────────────────────────────────────────────╯

It executed this exact code:

random.seed(42)
return [round(random.uniform(5, 50), 2) for _ in range(8)]
# → [33.77, 6.13, 17.38, 15.04, 38.14, 35.45, 45.15, 8.91]

The 0.63 seconds covers the time from sending the request to getting the response over the pipe, including Python process scheduling overhead. LangGraph appends the result to the agent's message list as a ToolMessage, so the list now holds three items: human, AI (the tool-call decision) and tool (the result).

LLM call number 2 follows, with three messages:

╭── 🤖  LLM CALL  #2  model=gpt-5-mini   messages=3 ──╮
│  Role    Content preview                             │
│  Human   Generate 8 random numbers…                 │
│  AI      (the tool-call decision)                    │
│  Tool    [33.77, 6.13, 17.38, ...]                   │
╰──────────────────────────────────────────────────────╯

The model is called again with the full history so far. It sees the original question, its own earlier decision to call generate_numbers, and the numbers that came back. Its reasoning runs roughly like this: the numbers are in hand, the question also asked for statistics, so calculate_statistics should run with them. That produces tool call number 2 and its result:

Tool  : calculate_statistics
Server: data_server
Args  : {'numbers': [33.77, 6.13, 17.38, 15.04, 38.14, 35.45, 45.15, 8.91]}
Result: {"count":8, "min":6.13, "max":45.15, "mean":24.9962,
         "median":25.575, "std_dev":14.8181, "variance":219.5755,
         "range":39.02, "q1":15.04, "q3":38.14}

Nothing was remembered by the model here. It lifted the values word for word out of the earlier ToolMessage in its context window and dropped them into the next tool's arguments. This is what chaining looks like in a ReAct loop: what one tool outputs becomes context that the following model call can read and reuse.

In LLM call number 3 there are five messages, and the model calls find_outliers:

Tool  : find_outliers
Server: data_server
Args  : {'numbers': [...], 'threshold': 2}
Result: {"outliers": [], "outlier_count": 0,
         "total_checked": 8, "mean": 24.9962, "std_dev": 14.8181}

The find_outliers function computes the z-score of each value (z = (x − mean) / std_dev) and checks whether any exceeds the threshold of 2. None does, so outliers is an empty list.

LLM call number 4 sees seven messages:

╭── 🤖  LLM CALL  #4  messages=7 ──╮
│  Human / AI / Tool                │
│  AI    / Tool                     │
│  AI    / Tool                     │
╰───────────────────────────────────╯
  LLM responded  ⏱ 30.25s

The missing "will call" suffix signals that the model produced ordinary text and not a tool request, which ends the ReAct loop. The call took 30 seconds, since the context was large (seven messages, raw JSON results included) and the model composed a long, thorough synthesis, working out a z-score for each value manually. LangGraph finds no tool calls in the response and routes to END, which prints the final answer:

╭── ✅  FINAL ANSWER ───────────────────────────────╮
│ Random numbers: [33.77, 6.13, 17.38, ...]        │
│ Mean: 24.9962 / Median: 25.575 / Std Dev: 14.8181│
│ Outliers: none (all |z| < 2)                     │
╰───────────────────────────────────────────────────╯

Then the run summary appears:

╭─────────────────────────────────────────────────────╮
│ LLM Calls   4                                       │
│ Tool Calls  3                                       │
│ Total Time  42.25s                                  │
│ Log File    logs/run_1776864980.jsonl               │
╰─────────────────────────────────────────────────────╯

The complete message sequence

This is every item in the agent's message list when agent.ainvoke() returned:

1  HumanMessage   "Generate 8 random numbers…"
2  AIMessage      [tool_call: generate_numbers({count:8, min:5, max:50, seed:42})]
3  ToolMessage    [33.77, 6.13, 17.38, 15.04, 38.14, 35.45, 45.15, 8.91]
4  AIMessage      [tool_call: calculate_statistics({numbers:[...]})]
5  ToolMessage    {count:8, mean:24.9962, std_dev:14.8181, ...}
6  AIMessage      [tool_call: find_outliers({numbers:[...], threshold:2})]
7  ToolMessage    {outliers:[], outlier_count:0, ...}
8  AIMessage      "Here are the results. Random numbers: …"   ← final answer

Each LLM call gets everything accumulated so far, so the messages figure in the tracker panels climbs 1, 3, 5, 7.

The JSONL log

All the events above were also logged to logs/run_x.jsonl as they occurred, with one JSON object per line:

{"timestamp": "...", "event": "agent_start",  "data": {"runnable": "LangGraph", "run_id": "..."}}
{"timestamp": "...", "event": "llm_call",     "data": {"call_number": 1, "model": "gpt-5-mini", "messages": 1}}
{"timestamp": "...", "event": "llm_end",      "data": {"elapsed": "5.35s", "tool_calls_requested": ["generate_numbers"]}}
{"timestamp": "...", "event": "tool_start",   "data": {"call_number": 1, "tool": "generate_numbers", "server": "data_server", "args": "..."}}
{"timestamp": "...", "event": "tool_end",     "data": {"elapsed": "0.63s", "output_preview": "[33.77, 6.13, ...]"}}
{"timestamp": "...", "event": "llm_call",     "data": {"call_number": 2, "model": "gpt-5-mini", "messages": 3}}
...
{"timestamp": "...", "event": "run_summary",  "data": {"llm_calls": 4, "tool_calls": 3, "total_elapsed": "42.25s"}}

Why this matters

The real value of MCP is distribution. In the days before a standard, any team that wanted to hand an LLM outside capabilities had to build its own integration layer, and none of that work carried over to other models or frameworks. Now a server is written once and serves any compliant client. A database team could publish a read-only server for its schema, a DevOps team could expose deployment tools, and a data platform could offer query and analytics functions. Any agent can connect to all of them, and the teams never need to agree on implementation details.