Programming Notes
nacho4d avatar

Programming Notes

@nacho4d

How an MCP Client, an LLM, and an MCP Server Actually Talk to Each Other

MCP tool calling looks a lot like OpenAI-style function calling, with one twist: the tool lives behind its own JSON-RPC protocol, and as of the current spec, that protocol is stateless — no session, no handshake, every request self-contained.

Example: "What's the weather in Tokyo?"

Below is what the flow looks like under the latest MCP spec version 2026-07-28. The LLM doesn't know current conditions, but knows a get_weather tool exists via MCP.

sequenceDiagram


    actor User

    box rgb(232,244,248) Client Environment
        participant Client as MCP Client
(Agent) end box rgb(240,240,240) LLM Provider Environment participant LLM as LLM API end box rgb(255,243,224) MCP Server Environment participant MCPServer as MCP Server
(weather tool) end autonumber 1.1 0.1 Note over Client,MCPServer: Phase 1: Tool discovery (tools/list) — no handshake required Client->>+MCPServer: tools/list (JSON-RPC request)
{ "jsonrpc": "2.0", "id": 1,
"method": "tools/list",
"params": { "_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "my-agent", "version": "1.0"} } } }
// HTTP headers: MCP-Protocol-Version, Mcp-Method: tools/list MCPServer-->>-Client: tools/list result
{ "jsonrpc": "2.0", "id": 1,
"result": { "resultType": "complete",
"tools": [{ "name": "get_weather",
"description": "Get current weather for a location",
"inputSchema": { "type": "object",
"properties": { "location": {"type": "string"} },
"required": ["location"] } }],
"ttlMs": 300000, "cacheScope": "public" } } Client->>Client: Cache tool list for ttlMs,
convert inputSchema →
LLM function-calling format autonumber 2.01 0.01 Note over User,MCPServer: Phase 2: Runtime — user request User->>+Client: "What's the weather in Tokyo?" Client->>+LLM: POST /v1/chat/completions
{ "model": "gpt-4o",
"messages": [{ "role": "user",
"content": "What's the weather in Tokyo?" }],
"tools": [{ "type": "function",
"function": { "name": "get_weather",
"description": "Get current weather for a location",
"parameters": { "type": "object",
"properties": { "location": {"type": "string"} },
"required": ["location"] } } }] } LLM->>LLM: Decide tool use is needed
(extract location="Tokyo") LLM-->>-Client: Response (finish_reason: tool_calls)
{ "choices": [{ "message": { "role": "assistant",
"content": null,
"tool_calls": [{ "id": "call_abc123",
"type": "function",
"function": { "name": "get_weather",
"arguments": "{\"location\":\"Tokyo\"}" } }] },
"finish_reason": "tool_calls" }] } Client->>Client: Parse tool_calls,
map function name →
MCP tool + server Client->>+MCPServer: tools/call (JSON-RPC request)
{ "jsonrpc": "2.0", "id": 2,
"method": "tools/call",
"params": { "name": "get_weather",
"arguments": { "location": "Tokyo" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "my-agent", "version": "1.0"} } } }
// HTTP headers: Mcp-Method: tools/call, Mcp-Name: get_weather MCPServer->>MCPServer: Call external weather API
(e.g. OpenWeatherMap) MCPServer-->>-Client: tools/call result
{ "jsonrpc": "2.0", "id": 2,
"result": { "resultType": "complete",
"content": [{ "type": "text",
"text": "{\"temp\": 22, \"condition\": \"clear\", \"humidity\": 60}" }],
"isError": false } } Client->>Client: Extract result content,
convert MCP result →
tool-role message Client->>+LLM: POST /v1/chat/completions (continued)
{ "messages": [
{"role": "user", "content": "What's the weather in Tokyo?"},
{"role": "assistant", "tool_calls": [...]},
{ "role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temp\": 22, \"condition\": \"clear\"}" } ] } LLM->>LLM: Integrate tool result,
generate natural language answer LLM-->>-Client: Response (finish_reason: stop)
{ "choices": [{ "message": { "role": "assistant",
"content": "The weather in Tokyo is clear with a temperature of 22°C." },
"finish_reason": "stop" }] } Client->>Client: response.choices[0].message.content Client-->>-User: "The weather in Tokyo is clear
with a temperature of 22°C."

Key points

Phase 1: Tool discovery

  • No handshake. The old initialize/initialized exchange is gone. The client sends tools/list directly; protocol version and client info travel in the _meta field and the MCP-Protocol-Version header.
  • Schema translation is where vendor portability lives. The client converts MCP's inputSchema into the LLM provider's function-calling format (tools: [...] for OpenAI, etc.). One MCP server, any LLM.
  • ttlMs/cacheScope in the response tells the client how long it can reuse the tool list without re-fetching.

Phase 2: Runtime

  • The LLM returns finish_reason: tool_calls instead of a final answer when it needs external data. The client parses the call, maps it to the right MCP tool, and dispatches a tools/call request.
  • Stateless by design. Every tools/call is self-contained — arguments + _meta, routed via Mcp-Method/Mcp-Name headers. No session state means any instance behind a load balancer can handle the request.
  • The MCP result comes back in a content array (resultType: complete). The client reshapes it into a role: "tool" message and resends the conversation to get the final answer.

A production implementation should also handle tools/list pagination, distinguish protocol errors from tool errors (isError: true), and prompt the user before executing sensitive tool calls.

This covers the core MCP call cycle. Authentication is covered in the next blog post.

I hope it helps.

0 comments:

This work is licensed under BSD Zero Clause License | nacho4d ®