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"
    }
  }
}
FieldDescription
jsonrpcAlways "2.0" — protocol version
idUnique identifier for the request (to pair it with the response)
methodWhich operation to execute
paramsParameters 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, and id. Expects a response.
  • Message B: Response (success) — Has result and id. It's the response to the Request with id 3.
  • Message C: Notification — Has method but has NO id. Expects no response.
  • Message D: Response (error) — Has error and id. 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 prompts in capabilities (it doesn't support them)
  • Both use the same protocolVersion
  • The id: 1 matches 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:

  1. 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
  2. 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
  3. 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:

  1. 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
  2. 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
  3. 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

  1. MCP Protocol Specification - Complete protocol specification including all messages
  2. JSON-RPC 2.0 Specification - The base protocol that MCP uses
  3. MCP Transports - Official documentation of transports (stdio, HTTP/SSE)
  4. MCP Connection Lifecycle - Documentation of the connection lifecycle
  5. MCP TypeScript SDK — Client - Source code of the Client in TypeScript
  6. 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.