Agent Architecture
MCP: Host, Client, and Server Roles
Reading level
Three roles, one protocol — know which one you build
You read "build an MCP server" in a tutorial. But the MCP docs keep mentioning hosts, clients, and servers. Are they three different things you need to build? Is the server the same as the client? Where does the AI model fit?
MCP (Model Context Protocol) defines exactly three roles. You almost certainly only build one of them: the Server. The Host is the application the user runs (Claude Desktop, Cursor, an IDE). The Client is the protocol adapter built into the Host. The Server is the part you write — it exposes your tools and data to the model.
The separation of roles is deliberate. It means: you can write an MCP Server once and have it work with any MCP Host — Claude Desktop today, Cursor tomorrow, a custom agent next week. You don't need to know which model is being used or how the Host works. The protocol is the interface; your Server just needs to implement it.
MCP is built on JSON-RPC 2.0 with two transport options: stdio (for local servers, launched as a subprocess by the Host) and HTTP+SSE (for remote servers, accessed over the network). The transport is pluggable because the protocol layer is defined above it. This is the correct architecture for a protocol designed to work across model providers, IDE integrations, and deployment environments.
The three roles and what they do
Host — the application the user runs. Manages the model, the conversation, and which MCP Servers to connect. Examples: Claude Desktop, Cursor, VS Code with Copilot. You don't write this unless you're building an AI application from scratch.
Client — lives inside the Host. Handles the JSON-RPC protocol translation between the Host's model calls and the Server's tool responses. You almost never write this — the MCP SDK provides it.
Server — the part you write. Declares tools, resources, and prompts that the model can use. Responds to JSON-RPC requests from the Client. Has no knowledge of the model or the conversation history.
The tool call flow — how a model uses a tool you built:
1. User: "Search the docs for AbortController"
2. Host sends message to model
3. Model emits: { type: "tool_use", name: "search_docs", input: { query: "AbortController" } }
4. Client receives tool_use, routes to your MCP Server
5. Your Server: calls search_docs("AbortController"), returns results
6. Client wraps result in tool_result format, sends back to Host
7. Host sends tool_result to model
8. Model continues generation with search results in context
Your Server only participates in steps 5 — the JSON-RPC call. Everything else is the Host and Client.
Building an MCP Server with the TypeScript SDK:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "docs-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: "search_docs",
description: "Search internal documentation",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
}],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === "search_docs") {
const results = await searchDocs(req.params.arguments.query);
return { content: [{ type: "text", text: JSON.stringify(results) }] };
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
Security model: servers are process-isolated from the Host. A server cannot read the model's context window, only receive explicit tool call inputs. The Host controls which servers are connected and what filesystem/network permissions they have.
The tool that generated JSON but never ran
A developer was building a local AI coding assistant and wanted to add file-write capabilities. They added a tool description to the system prompt: "You have a write_file tool. Call it with path and content parameters." The model dutifully generated JSON like {"tool": "write_file", "path": "src/index.ts", "content": "..."}. The developer pasted the output into their terminal and ran the write manually.
When they tried to automate this — have the agent actually write files as part of a loop — nothing happened. The model generated the right JSON, the system prompt said the tool existed, but there was no executor on the other side. The write_file "tool" was a fiction written in English. No server was listening for the JSON the model emitted.
The confusion was understandable. The model can describe tools, and it can generate tool call JSON when instructed. But generating JSON is not executing a tool. Without an MCP Server — a running process that receives the JSON-RPC call and performs the actual filesystem write — the tool call is a description of intent, not an action. The missing layer was the Server: the process that turns model-generated JSON into real system effects.
Separating the description from the execution
The fix was to properly separate the three MCP roles. The Host (in this case, the developer's custom agent runner) manages the conversation and sends messages to the model. The Client (provided by the MCP SDK, embedded in the Host) translates between the model's tool_use format and JSON-RPC calls the Server expects. The Server — a new process they wrote — listens on stdio, receives the write_file call, validates the path, and performs the actual write.
Once all three layers existed, the agent loop worked as expected: model emits tool_use, Client routes it to Server, Server writes the file and returns a result, Client passes the tool_result back to the model, the model continues. The key insight was that the model is only responsible for deciding which tool to call and with what arguments — the Server is responsible for execution.
The correct mental model: the system prompt describes what tools are available (their names, schemas, and descriptions). The MCP Server is what makes those descriptions true. Without the Server, the system prompt is making promises the system cannot keep. An MCP tool is not real until there is a running Server process that implements it — and the Client is what connects the model's intent to the Server's execution.
Pattern at a glance
// ❌ Tool described in prompt — model generates JSON but nothing runs
const systemPrompt = `
You have access to a write_file tool.
Call it with: {"tool": "write_file", "path": "...", "content": "..."}
`;
// No MCP Server running. Model emits JSON.
// Developer manually pastes it into terminal.
// No automation possible.
// ✅ MCP Server process — implements the tool with real execution
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { writeFile } from "node:fs/promises";
const server = new Server(
{ name: "file-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name === "write_file") {
const { path, content } = req.params.arguments;
await writeFile(path, content, "utf-8");
return { content: [{ type: "text", text: `Written: ${path}` }] };
}
});
// Host launches this as subprocess; Client handles JSON-RPC routing
await server.connect(new StdioServerTransport());
Watch it: a tool call through all three roles
The animation shows a single tool call flowing through all three roles: the Host sends the user's message to the model → the model emits a tool_use block → the Client routes it to your Server → your Server returns results → the Client passes them back → the model continues.
Watch the Client layer in the animation — it's translating between the model's tool_use format and the Server's JSON-RPC interface. This is the protocol adapter work the SDK handles for you. Your Server just gets a clean function call.
The initialize handshake (not shown) happens before any tool calls: Host → Client → Server sends an initialize request; Server responds with its capabilities (tools, resources, prompts); Client stores the capability negotiation result. This is where version mismatches between SDK versions surface.
Showing: Clear — three roles
Implementation depth
MCP uses JSON-RPC 2.0 as its wire format. Every tool call from the Client to the Server is a JSON-RPC request with method: "tools/call" and params containing the tool name and arguments. The Server responds with a JSON-RPC result containing the tool output. The schema for both is defined in the MCP spec — mismatches between the tool definition schema and the actual arguments the model sends are the most common source of silent failures.
// Tool definition — what the model sees (via ListTools)
{
name: "write_file",
description: "Write content to a file at the specified path",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "File path relative to workspace" },
content: { type: "string", description: "UTF-8 content to write" }
},
required: ["path", "content"]
}
}
// What the Client sends to the Server (JSON-RPC)
{
jsonrpc: "2.0",
method: "tools/call",
params: { name: "write_file", arguments: { path: "src/a.ts", content: "..." } },
id: 1
}
Common pitfalls when building MCP Servers:
- Mixing stdio and HTTP transports — stdio servers are launched as subprocesses by the Host; HTTP+SSE servers run independently and the Host connects to them by URL. Do not attempt to use both transports in the same server process.
- Forgetting the initialize handshake — the Host sends an
initializerequest before any tool calls. If your server doesn't implement the initialize handler, the Client will hang waiting for capability negotiation to complete. - Tool definition vs tool execution drift — if you add a new tool to the ListTools response but forget the corresponding handler in CallTool, the model will call it and your server returns an error. Keep definitions and handlers co-located.
- Error propagation — return JSON-RPC error objects (not thrown exceptions) for tool-level errors. Thrown exceptions in the handler may crash the server process rather than returning a graceful error result to the Client.
References
Remember
Key takeaways
-
MCP has three roles: Host (the AI app), Client (the protocol adapter inside the Host), and Server (your tools and data). You almost always only build the Server.The Server is stateless and declarative — it lists tools and responds to JSON-RPC calls. It has no knowledge of the model, the conversation, or the Host's context. The SDK handles the protocol layer.Transport is pluggable: stdio for local subprocess servers, HTTP+SSE for remote servers. The protocol is the same over both transports. Build the logic once, deploy as local or remote based on your security and latency requirements.
-
An MCP Server you build today works with any MCP Host — Claude Desktop, Cursor, any compliant application. The protocol is the interface; the model and Host are swappable.The initialize handshake is where capability negotiation happens. If your SDK version doesn't match the Host's expectations, this is where version mismatch errors surface — check SDK version compatibility first when debugging connection issues.Security: servers are process-isolated. They can't read the model's context window — only receive explicit tool call inputs. The Host controls server connections and permissions. Design servers to be minimal-privilege: only request the filesystem/network access the tool actually needs.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed