Module 2: Host-Client-Server Architecture
The Client: MCP's Connector
The Client: MCP's Connector
Capsule description
You already know the Host — Claude Code as the orchestrator that decides which servers to use and coordinates everything. But between the Host that decides and the Server that executes there's an invisible component: the MCP Client. It's the restaurant's waiter — it doesn't cook or decide the menu, but without it orders don't reach the kitchen and dishes don't reach the table.
In this capsule you're going to understand what the MCP Client is, how it handles a connection's lifecycle (from the initial handshake to disconnection), what protocol it uses to communicate, and how it negotiates capabilities with the Server. Although you normally don't build Clients (they come integrated in the Host), understanding how they work lets you diagnose connection problems and write Servers that behave correctly.
The Client is the layer most taken for granted — everything works fine until it fails. Understanding it prepares you for those moments where you need to know exactly what happens between the Host and your Server.
What is an MCP Client?
Definition
An MCP Client is the component inside the Host that establishes and maintains a connection with an MCP Server using the MCP protocol (based on JSON-RPC 2.0).
In concrete terms:
Claude Code (Host)
├── MCP Client 1 ←→ Filesystem Server
├── MCP Client 2 ←→ GitHub Server
└── MCP Client 3 ←→ PostgreSQL Server
Each Client is an independent connection.
Each Client maintains its own state.
Each Client negotiates capabilities with ITS Server.
1:1 relationship
An important architectural detail: the Client-Server relationship is 1:1. There isn't a Client that talks with multiple Servers. Each Server connection has its own dedicated Client:
✅ Correct:
Host
├── Client A → Server A
├── Client B → Server B
└── Client C → Server C
❌ Incorrect (this is NOT how it works):
Host
└── Single Client → Server A
→ Server B
→ Server C
This guarantees isolation: if the connection with Server B fails, Client A and Client C keep working normally.
The protocol: JSON-RPC 2.0
Why JSON-RPC
MCP uses JSON-RPC 2.0 as its message format. If you've worked with REST APIs, JSON-RPC will seem familiar but with a different structure:
REST:
GET /api/files/readme.md
→ { "content": "# My README..." }
JSON-RPC:
→ { "jsonrpc": "2.0", "method": "tools/call", "params": {...}, "id": 1 }
← { "jsonrpc": "2.0", "result": {...}, "id": 1 }
Anatomy of a JSON-RPC message
Every message the Client sends to the Server has this structure:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "/Users/dev/projects/README.md"
}
}
}
| Field | Description |
|---|---|
jsonrpc | Always "2.0" — protocol version |
id | Unique identifier for the request (to pair it with the response) |
method | Which operation to execute |
params | Parameters of the operation |
And the Server's response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "# My README\n\nThis is my project..."
}
]
}
}
The id: 1 in the response matches the id: 1 of the request — that's how the Client knows which response corresponds to which request.
Message types
The MCP protocol handles 3 types of messages:
MCP message types:
│
├── Request (Client → Server)
│ Has: method, params, id
│ Expects: Response
│ Example: "Execute the read_file tool"
│
├── Response (Server → Client)
│ Has: result (success) or error (failure), id
│ Responds to: a specific Request (same id)
│ Example: "Here's the file's content"
│
└── Notification (either direction, no id)
Has: method, params (has NO id)
Expects no response
Example: "The resources list changed" (Server → Client)
The difference between Request and Notification is key: a Request expects a response (has id), a Notification is fire-and-forget (no id).
// Request (expects a response)
{
"jsonrpc": "2.0",
"id": 42,
"method": "tools/call",
"params": { "name": "read_file", "arguments": { "path": "README.md" } }
}
// Notification (expects no response)
{
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": { "uri": "file:///Users/dev/data.json" }
}
Lifecycle of an MCP connection
Overview
The lifecycle of an MCP connection has 4 phases:
Connection lifecycle:
│
├── 1. INITIALIZE
│ Client and Server negotiate version and capabilities
│
├── 2. INITIALIZED (notification)
│ Client confirms that initialization was successful
│
├── 3. OPERATION (main phase)
│ Client sends requests, Server responds
│ Can last minutes, hours, or the whole session
│
└── 4. SHUTDOWN
Client closes the connection
Each phase has specific messages. Let's see them in detail.
Phase 1: Initialize
When Claude Code starts an MCP server, the first thing the Client does is send an initialize message:
// Client → Server: "Hi, I want to connect"
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": {
"listChanged": true
}
},
"clientInfo": {
"name": "claude-code",
"version": "1.0.0"
}
}
}
This message says:
- protocolVersion: Which version of the MCP protocol the Client speaks
- capabilities: Which features the Client supports
- clientInfo: Who the Client is (name and version)
The Server responds with its own information:
// Server → Client: "Hi, I accept the connection"
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {
"listChanged": true
},
"resources": {
"subscribe": true,
"listChanged": true
}
},
"serverInfo": {
"name": "filesystem-server",
"version": "0.5.0"
}
}
}
This response says:
- protocolVersion: Version of the protocol the Server accepts
- capabilities: Which features the Server exposes (tools, resources, etc.)
- serverInfo: Who the Server is
Capability negotiation
The initialize phase is a negotiation. Not all Servers expose the same capabilities:
Server A (filesystem):
capabilities:
tools: ✅ (has tools like read_file, write_file)
resources: ❌ (doesn't expose resources)
prompts: ❌ (doesn't expose prompts)
Server B (database):
capabilities:
tools: ✅ (has tools like query, list_tables)
resources: ✅ (exposes resources like db://schema)
prompts: ✅ (has prompt templates like analyze_table)
Server C (weather):
capabilities:
tools: ✅ (has tools like get_forecast)
resources: ❌
prompts: ❌
The Client records its Server's capabilities and tells the Host what it can do. That's why the Host knows which tools are available — because each Client reported its Server's capabilities.
Phase 2: Initialized (Notification)
After receiving the initialize response, the Client sends a notification confirming that everything is ready:
// Client → Server: "All set, let's begin"
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
This is a notification (no id), it expects no response. From this moment on, the connection is active and the Client can send operational requests.
Phase 3: Operation
This is the phase where the Client sends real requests to the Server. The most common types:
Available operations according to capabilities:
│
├── Tools
│ ├── tools/list → List all available tools
│ └── tools/call → Execute a specific tool
│
├── Resources
│ ├── resources/list → List available resources
│ ├── resources/read → Read a specific resource
│ └── resources/subscribe → Subscribe to changes
│
└── Prompts
├── prompts/list → List available prompts
└── prompts/get → Get a specific prompt
Example: List available tools
// Client → Server
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}
// Server → Client
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "read_file",
"description": "Reads the complete contents of a file",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file"
}
},
"required": ["path"]
}
},
{
"name": "write_file",
"description": "Writes contents to a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
},
"required": ["path", "content"]
}
}
]
}
}
Notice how each tool has an inputSchema — a JSON Schema that defines exactly which parameters it accepts. This lets the Host know how to invoke each tool correctly.
Example: Execute a tool
// Client → Server: "Execute read_file"
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "/Users/dev/projects/README.md"
}
}
}
// Server → Client: "Here's the result"
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "# My Project\n\nThis project is..."
}
],
"isError": false
}
}
Example: Error in a tool
// Server → Client: "There was an error"
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [
{
"type": "text",
"text": "Error: File not found: /Users/dev/nonexistent.md"
}
],
"isError": true
}
}
The Server returns errors inside result with isError: true, not as a JSON-RPC error. This lets the Host present the error in a friendly way to the user.
Phase 4: Shutdown
When Claude Code closes a session or removes an MCP server, the Client closes the connection in an orderly way:
Client shutdown:
├── Client stops sending requests
├── Client closes the communication channel
└── The Server's process ends
In practice with Claude Code, the shutdown happens when:
- You close the Claude Code session (
Ctrl+C) - You remove a server (
claude mcp remove) - Claude Code detects that the server's process crashed
Transports: how messages travel
What is a transport?
The MCP protocol defines which messages are sent. The transport defines how those messages travel. Think of the protocol as the language and the transport as the means of communication (phone, email, in person):
MCP transports:
│
├── stdio (Standard I/O)
│ ├── Messages travel through the process's stdin/stdout
│ ├── The server is a local process
│ ├── Used by: Claude Code, Claude Desktop
│ └── The most common for local development
│
├── HTTP with SSE (Server-Sent Events)
│ ├── Messages travel over HTTP
│ ├── The server can be on a remote machine
│ ├── Client → Server: HTTP POST
│ ├── Server → Client: SSE stream
│ └── Used for: remote servers, cloud deployments
│
└── Streamable HTTP
├── Evolution of the HTTP transport
├── Supports bidirectional streaming
└── Used for: advanced communication
stdio in detail
When you configure an MCP server in Claude Code with npx, you use stdio:
claude mcp add filesystem -s user -- npx -y @modelcontextprotocol/server-filesystem /dir
What happens internally:
Claude Code
│
├── Launches process: npx server-filesystem /dir
│ └── This process has stdin and stdout
│
├── Client writes to the process's stdin
│ → {"jsonrpc":"2.0","method":"initialize",...}\n
│
├── Server reads from stdin, processes, writes to stdout
│ → {"jsonrpc":"2.0","result":{...}}\n
│
└── Client reads from the process's stdout
→ Parses the JSON and delivers it to the Host
Data flow with stdio:
Client ──stdin──→ Server Process
Client ←─stdout── Server Process
Client ←─stderr── Server Process (logs/errors, not MCP)
Each JSON message goes on one line, terminated with \n. The Client and the Server parse JSON line by line.
When to use each transport?
stdio:
├── ✅ Local server (your machine)
├── ✅ Development and testing
├── ✅ Claude Code, Claude Desktop
└── ❌ Server in the cloud
HTTP/SSE:
├── ✅ Remote server (on a server)
├── ✅ Multiple Hosts connected to the same Server
├── ✅ Production deployments
└── ❌ More complex to configure
For this guide, you'll almost always work with stdio. HTTP/SSE will be explored when you get to production deployments.
Client capabilities
What can a Client do?
The Client also declares capabilities during initialization. These are less well known but important:
{
"capabilities": {
"roots": {
"listChanged": true
},
"sampling": {}
}
}
Client capabilities:
│
├── roots
│ ├── Tells the Server which base directories/URIs it has access to
│ └── listChanged: the Client can notify if the roots change
│
└── sampling
└── The Client can ask the Server to generate text
(used in advanced flows where the Server needs
the model to generate something)
Roots: the base directories
When the Client sends its roots to the Server, it tells it "these are the directories I have access to":
// Client informs the Server of its roots
{
"roots": [
{
"uri": "file:///Users/dev/projects/my-app",
"name": "My App"
}
]
}
This lets the Server contextualize its operations. A Filesystem server can know that it should only operate within those directories.
Error handling in the protocol
JSON-RPC errors
When something fails at the protocol level (not at the operation level), the Server returns a JSON-RPC error:
// Protocol error: method not found
{
"jsonrpc": "2.0",
"id": 5,
"error": {
"code": -32601,
"message": "Method not found: tools/nonexistent"
}
}
Standard JSON-RPC error codes:
Error codes:
├── -32700 Parse error (invalid JSON)
├── -32600 Invalid request (incorrect structure)
├── -32601 Method not found (method doesn't exist)
├── -32602 Invalid params (incorrect parameters)
└── -32603 Internal error (server internal error)
Errors vs results with isError
It's important to distinguish between protocol errors and operation errors:
Protocol error (JSON-RPC error):
→ The Client couldn't communicate with the Server
→ Example: method doesn't exist, invalid JSON
→ Returned as "error" in JSON-RPC
Operation error (result with isError):
→ The Client communicated fine, but the operation failed
→ Example: file not found, invalid SQL query
→ Returned as "result" with isError: true
Troubleshooting
"The server connects but doesn't list tools"
Probable cause: The Server doesn't declare tools in its capabilities during initialize.
Solution: Verify that the Server returns correct capabilities:
{
"capabilities": {
"tools": {}
}
}
If "tools": {} is missing, the Client won't request the list of tools.
"The messages don't reach the server"
Probable cause: A transport problem (stdio). The Server isn't reading from stdin correctly.
Solution:
# Try sending a message manually to the server
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | npx -y @modelcontextprotocol/server-filesystem /tmp
If you don't get a response, the Server isn't processing stdin correctly.
"Error: Protocol version mismatch"
Cause: The Client and the Server speak different versions of the MCP protocol.
Solution: Update the Server (or the Client) to a compatible version:
# Update the server
npm update -g @modelcontextprotocol/server-filesystem
# Or reinstall the latest version
npm install -g @modelcontextprotocol/server-filesystem@latest
"Connection timeout during initialize"
Cause: The Server takes too long to respond to the initialize.
Solution:
# Verify that the server starts correctly
npx -y @modelcontextprotocol/server-filesystem /your/dir
# If it uses npx, install globally for a faster startup
npm install -g @modelcontextprotocol/server-filesystem
"The server disconnects after a while"
Probable cause: The Server's process crashes. It could be from an unhandled error, a memory leak, or a timeout.
Solution: Check the Server's logs (stderr):
# The server's errors go to stderr
# Claude Code can show these errors in its output
# Look for error messages in the Claude Code session
Exercises
Exercise 1: Identify MCP messages (Easy)
Classify each message as a Request, Response, or Notification:
// Message A
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "read_file" } }
// Message B
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "hello" }] } }
// Message C
{ "jsonrpc": "2.0", "method": "notifications/resources/updated" }
// Message D
{ "jsonrpc": "2.0", "id": 7, "error": { "code": -32601, "message": "Method not found" } }
See solution
- Message A: Request — Has
method,params, andid. Expects a response. - Message B: Response (success) — Has
resultandid. It's the response to the Request with id 3. - Message C: Notification — Has
methodbut has NOid. Expects no response. - Message D: Response (error) — Has
errorandid. It's an error response to the Request with id 7.
Quick rule: If it has id + method → Request. If it has id + result/error → Response. If it has method but no id → Notification.
Exercise 2: Write an initialize message (Medium)
Write the initialize message that a Client would send, and the response that a database Server would return. The Server exposes tools and resources, but not prompts.
See solution
Client → Server (initialize request):
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {
"roots": {
"listChanged": true
}
},
"clientInfo": {
"name": "claude-code",
"version": "1.0.0"
}
}
}
Server → Client (initialize response):
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {
"listChanged": true
},
"resources": {
"subscribe": true,
"listChanged": true
}
},
"serverInfo": {
"name": "database-server",
"version": "1.0.0"
}
}
}
Key points:
- The Server does NOT include
promptsin capabilities (it doesn't support them) - Both use the same
protocolVersion - The
id: 1matches in request and response
Exercise 3: Trace the complete lifecycle (Medium)
Draw the complete sequence of messages when Claude Code connects with a Filesystem MCP server and the user asks "read the package.json file." Include everything from initialize to the final response.
See solution
Phase 1: Initialize
───────────────────
Client → Server: initialize (id:1)
{ protocolVersion, capabilities, clientInfo }
Server → Client: response (id:1)
{ protocolVersion, capabilities: {tools: {}}, serverInfo }
Client → Server: notifications/initialized
(no id, notification)
Phase 2: Discover
───────────────────
Client → Server: tools/list (id:2)
Server → Client: response (id:2)
{ tools: [read_file, write_file, ...] }
→ Host records the available tools
Phase 3: Operation (user asks "read package.json")
───────────────────
Host decides: needs read_file → Filesystem server
Client → Server: tools/call (id:3)
{ name: "read_file", arguments: { path: "package.json" } }
Server → Client: response (id:3)
{ content: [{ type: "text", text: "{ \"name\": \"my-app\"... }" }] }
→ Host presents the content to the user
Total: 6 messages (3 from the Client, 3 from the Server)
Exercise 4: Diagnose errors from the message (Medium)
Given this message exchange, identify what went wrong and in which phase of the lifecycle:
// Client → Server
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "claude-code", "version": "1.0.0" } } }
// Server → Client
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "serverInfo": { "name": "old-server", "version": "0.1.0" } } }
See solution
Problem: A possible protocol version mismatch.
The Client sends protocolVersion: "2025-03-26" but the Server responds with protocolVersion: "2024-11-05".
Analysis:
- The Client wants to use the most recent version of the protocol
- The Server only supports an earlier version
- Depending on the implementation, this can:
- Work if the versions are backward-compatible
- Fail if there are breaking changes between versions
- Cause unexpected behavior if the Client uses features the Server doesn't support
Phase: Initialize (Phase 1 of the lifecycle)
Solution: Update the Server to a version that supports the more recent protocol:
npm update -g @modelcontextprotocol/server-old-server
Exercise 5: Compare stdio vs HTTP/SSE (Hard)
Describe 3 scenarios where you'd use stdio and 3 where you'd use HTTP/SSE for an MCP server's transport. Justify each choice.
See solution
Scenarios for stdio:
-
Local filesystem server
- Accesses files on your machine
- Doesn't need the network
- stdio is direct and fast
- Justification: the operation is local, there's no benefit in HTTP
-
Development/testing server
- You're developing a new MCP server
- You need a fast development cycle
- stdio doesn't require a port or network configuration
- Justification: simplicity for development
-
Server with sensitive data
- Accesses local secrets or private files
- You don't want to expose an HTTP endpoint
- stdio keeps everything inside the machine
- Justification: security — no network attack surface
Scenarios for HTTP/SSE:
-
Cloud database server
- The database is on AWS/GCP/Azure
- The server needs to run near the database (less latency)
- Multiple developers want to use the same server
- Justification: the server can't run locally
-
Server shared by the team
- A Jira MCP server that the whole team uses
- A centralized server prevents each developer from configuring their own
- Justification: sharing a server across multiple Hosts
-
Server with CI/CD integration
- The server is part of a deployment pipeline
- It's deployed as a web service
- Other systems (not just Claude Code) need to access it
- Justification: integration with existing infrastructure
Exercise 6: Write a sequence of tools/call (Hard)
Write the complete JSON-RPC messages for this sequence: the user wants to "search for .py files and read the largest one." You need 2 calls: first search_files, then read_file with the result.
See solution
// Call 1: Search for .py files
// Client → Server
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "search_files",
"arguments": {
"path": "/Users/dev/project",
"pattern": "*.py"
}
}
}
// Server → Client
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"content": [
{
"type": "text",
"text": "Found 3 files:\n- main.py (2.4 KB)\n- utils.py (5.1 KB)\n- test_main.py (1.8 KB)"
}
],
"isError": false
}
}
// Host processes: utils.py is the largest (5.1 KB)
// Call 2: Read the largest file
// Client → Server
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "read_file",
"arguments": {
"path": "/Users/dev/project/utils.py"
}
}
}
// Server → Client
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"content": [
{
"type": "text",
"text": "import os\nimport json\n\ndef parse_config(path):\n ..."
}
],
"isError": false
}
}
Key points:
- The
ids are sequential (10, 11) to distinguish each request - The Host (not the Client) decides that utils.py is the largest
- The second call uses information from the first's result
- Both calls use the same Client → same Server
Summary
In this capsule you learned:
- The MCP Client is the component inside the Host that handles the communication with a specific Server
- The Client-Server relationship is 1:1 — each Server has its dedicated Client
- MCP uses JSON-RPC 2.0 as its message format, with 3 types: Request, Response, Notification
- The lifecycle has 4 phases: Initialize → Initialized → Operation → Shutdown
- During initialize, Client and Server negotiate protocol version and capabilities
- The transports define how messages travel: stdio (local) or HTTP/SSE (remote)
- Errors can be protocol errors (JSON-RPC error) or operation errors (result with isError)
- Understanding the Client lets you diagnose problems in the connection between Host and Server
Next capsule: The Server: the provider — the layer you're going to build. How a Server exposes capabilities, responds to requests, and handles state.
Additional resources
- MCP Protocol Specification - Complete protocol specification including all messages
- JSON-RPC 2.0 Specification - The base protocol that MCP uses
- MCP Transports - Official documentation of transports (stdio, HTTP/SSE)
- MCP Connection Lifecycle - Documentation of the connection lifecycle
- MCP TypeScript SDK — Client - Source code of the Client in TypeScript
- MCP Inspector - Tool to visualize MCP messages in real time
Next capsule: The Server: the provider — how an MCP Server exposes capabilities, responds to requests, and handles its internal state. This is the layer you'll build in modules 4-6.