Most MCP tutorials focus on building servers. But if you’re building an AI-powered application — a chatbot, coding assistant, workflow automation tool, or custom agent — you need to build the client side. The client is what connects your application to the MCP ecosystem, letting it discover and use tools, read resources, and execute prompts from any compatible server.
This guide covers how to build MCP clients from scratch using the official SDKs, drawn from the MCP specification (2025-06-18 and 2025-11-25), SDK documentation, and published community patterns. We research and analyze these approaches rather than testing implementations hands-on.
Understanding the Architecture
MCP uses a three-layer architecture: Host, Client, and Server.
- Host — the application your users interact with (like Claude Desktop, Cursor, or your custom app). The host creates and manages clients.
- Client — a component within the host that maintains a 1:1 connection with a single MCP server. Each client handles protocol negotiation, message routing, and capability management for its connection.
- Server — an external process or service that exposes tools, resources, and prompts via MCP.
The key insight: one host, many clients, many servers. Your application creates a separate client instance for each server it connects to. Each client operates independently with its own session state.
┌─────────────────────────────────────┐
│ Your Application │
│ (Host) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client 1 │ │ Client 2 │ ... │
│ └────┬─────┘ └────┬─────┘ │
└───────┼──────────────┼──────────────┘
│ │
┌────▼────┐ ┌────▼────┐
│ Server │ │ Server │
│(DB tools)│ │(Web API)│
└─────────┘ └─────────┘
This mediated access pattern is intentional — the host acts as a security broker, controlling which servers an AI model can access and what operations it can perform.
Setting Up Your First Client
TypeScript SDK
Install the official SDK:
npm install @modelcontextprotocol/sdk
Create a basic client that connects to a server via stdio transport:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["path/to/server.js"],
});
const client = new Client({
name: "my-app",
version: "1.0.0",
});
await client.connect(transport);
// Client is now connected and capabilities are negotiated
console.log("Connected to server:", client.getServerVersion());
Python SDK
Install the official SDK:
pip install mcp
Create a basic client:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
server_params = StdioServerParameters(
command="python",
args=["path/to/server.py"],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Session is now connected and capabilities are negotiated
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
The Connection Lifecycle
Every MCP connection follows a strict lifecycle. Understanding it is essential for building reliable clients.
1. Initialization
The client sends an initialize request containing:
- Protocol version it supports
- Client capabilities it offers (e.g., sampling, roots)
- Client info (name, version)
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"sampling": {},
"roots": { "listChanged": true }
},
"clientInfo": {
"name": "my-app",
"version": "1.0.0"
}
}
}
2. Server Response
The server responds with its own protocol version, capabilities, and metadata:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": { "subscribe": true },
"prompts": { "listChanged": true }
},
"serverInfo": {
"name": "database-server",
"version": "2.1.0"
},
"instructions": "This server provides read-only access to the analytics database."
}
}
The instructions field is important — it’s a human-readable string describing the server’s purpose, which you can feed to your LLM to help it understand what the server does.
3. Confirmation
The client sends an initialized notification to confirm readiness. Only after this point can meaningful operations begin.
4. Normal Operation
The client can now call tools, list resources, use prompts, and receive notifications.
5. Shutdown
Either side can terminate the connection gracefully. The client should handle unexpected disconnections with reconnection logic.
Capability Negotiation
Capabilities determine what features are available during a session. Both sides declare what they support, and each side must respect the other’s declared capabilities.
Client Capabilities You Can Offer
| Capability | What It Means |
|---|---|
sampling |
Your client supports the server requesting LLM completions through you |
roots |
Your client can provide filesystem root URIs to the server |
roots.listChanged |
Your client will notify when available roots change |
elicitation |
Your client supports servers requesting user input/confirmation |
Server Capabilities You’ll See
| Capability | What It Means |
|---|---|
tools |
Server exposes callable tools |
tools.listChanged |
Server will notify when its tool list changes |
resources |
Server exposes readable resources |
resources.subscribe |
Server supports resource change subscriptions |
prompts |
Server exposes prompt templates |
prompts.listChanged |
Server will notify when prompts change |
Rule of thumb: Only declare capabilities you actually implement. If you declare sampling support but don’t handle sampling requests, the server may break when it tries to use it.
Discovering and Calling Tools
Tools are the most common reason to connect to an MCP server. Here’s how to discover and invoke them.
Listing Tools
// TypeScript
const toolsResult = await client.listTools();
for (const tool of toolsResult.tools) {
console.log(`${tool.name}: ${tool.description}`);
// tool.inputSchema contains the JSON Schema for arguments
// tool.outputSchema (if present) describes the return type
}
# Python
tools_result = await session.list_tools()
for tool in tools_result.tools:
print(f"{tool.name}: {tool.description}")
# tool.inputSchema contains the JSON Schema for arguments
Calling a Tool
// TypeScript
const result = await client.callTool({
name: "query_database",
arguments: {
sql: "SELECT count(*) FROM users WHERE active = true",
},
});
// result.content is an array of content blocks
for (const block of result.content) {
if (block.type === "text") {
console.log(block.text);
}
}
// Check if the tool reported an error
if (result.isError) {
console.error("Tool execution failed");
}
# Python
result = await session.call_tool(
"query_database",
arguments={"sql": "SELECT count(*) FROM users WHERE active = true"},
)
for block in result.content:
if block.type == "text":
print(block.text)
Handling Structured Output
If a tool declares an outputSchema (introduced in spec 2025-06-18), the response includes structuredContent — a JSON object matching that schema:
const result = await client.callTool({
name: "get_device_status",
arguments: { device_id: "switch-01" },
});
if (result.structuredContent) {
// Typed, structured data — ideal for programmatic use
const status = result.structuredContent.status; // "online" | "offline" | "degraded"
const uptime = result.structuredContent.uptime_seconds;
}
Structured output is a significant improvement for client builders because it gives you predictable, parseable data instead of free-form text.
Reading Resources
Resources provide read-only access to data — files, database records, API responses, or any URI-addressable content.
Listing Resources
const resources = await client.listResources();
for (const resource of resources.resources) {
console.log(`${resource.uri}: ${resource.name}`);
// resource.mimeType tells you what kind of content to expect
}
Servers can also expose resource templates — URI patterns with placeholders:
const templates = await client.listResourceTemplates();
// e.g., "db://tables/{table_name}/schema"
Reading a Resource
const content = await client.readResource({
uri: "db://tables/users/schema",
});
for (const item of content.contents) {
if (item.text) {
console.log(item.text);
}
// item.blob contains base64-encoded binary data if applicable
}
Subscribing to Changes
If the server supports resources.subscribe, you can watch for changes:
// Subscribe to a resource
await client.subscribeResource({ uri: "config://app/settings" });
// Listen for update notifications
client.setNotificationHandler(
"notifications/resources/updated",
async (notification) => {
const uri = notification.params.uri;
// Re-read the resource to get updated content
const updated = await client.readResource({ uri });
console.log("Resource updated:", updated);
}
);
Using Prompts
Prompts are reusable templates that generate messages for LLM interactions. They’re useful for standardized workflows.
// List available prompts
const prompts = await client.listPrompts();
// Get a specific prompt with arguments
const result = await client.getPrompt({
name: "analyze_data",
arguments: {
dataset: "sales_2026_q1",
focus: "trends",
},
});
// result.messages contains the formatted messages
// ready to send to your LLM
for (const msg of result.messages) {
console.log(`${msg.role}: ${msg.content.text}`);
}
Transport Options
Your choice of transport determines how the client communicates with the server.
Stdio Transport
Best for local servers that run as child processes. The client spawns the server and communicates over stdin/stdout.
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
env: { ...process.env, CUSTOM_VAR: "value" },
});
Pros: Simple, no network setup, process lifecycle tied to client. Cons: Only local, one client per server process.
Streamable HTTP Transport
The modern transport for remote servers, introduced in spec version 2025-03-26. The client sends JSON-RPC messages via HTTP POST and can receive streaming responses via Server-Sent Events (SSE).
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("https://api.example.com/mcp"),
);
const client = new Client({ name: "my-app", version: "1.0.0" });
await client.connect(transport);
Pros: Works remotely, supports streaming, can be load-balanced. Cons: More infrastructure complexity, session management challenges at scale.
Important: The client must include an Accept header listing both application/json and text/event-stream. The server may respond with either format depending on whether it needs to stream.
Legacy SSE Transport
The older HTTP+SSE transport (pre-2025-03-26) used separate endpoints for sending and receiving. It’s being deprecated — new implementations should use Streamable HTTP. However, your client may need backwards compatibility:
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
// Legacy transport for older servers
const transport = new SSEClientTransport(
new URL("https://old-server.example.com/sse"),
);
Multi-Server Client Architecture
Real applications typically connect to multiple servers simultaneously. Here’s how to manage that.
Connection Manager Pattern
Create a manager that maintains multiple client instances and routes requests to the appropriate server:
class MCPConnectionManager {
private clients: Map<string, Client> = new Map();
async addServer(name: string, transport: Transport): Promise<void> {
const client = new Client({
name: "my-app",
version: "1.0.0",
});
await client.connect(transport);
this.clients.set(name, client);
}
async getAllTools(): Promise<Map<string, Tool[]>> {
const allTools = new Map<string, Tool[]>();
for (const [name, client] of this.clients) {
const result = await client.listTools();
allTools.set(name, result.tools);
}
return allTools;
}
async callTool(serverName: string, toolName: string, args: object) {
const client = this.clients.get(serverName);
if (!client) throw new Error(`No server named ${serverName}`);
return client.callTool({ name: toolName, arguments: args });
}
async disconnectAll(): Promise<void> {
for (const client of this.clients.values()) {
await client.close();
}
this.clients.clear();
}
}
Presenting Tools to the LLM
When your host connects to multiple servers, you need to present a unified tool list to the LLM. A common pattern is to namespace tools by server:
async function buildToolManifest(manager: MCPConnectionManager) {
const allTools = await manager.getAllTools();
const manifest = [];
for (const [serverName, tools] of allTools) {
for (const tool of tools) {
manifest.push({
// Prefix tool names to avoid collisions
name: `${serverName}__${tool.name}`,
description: `[${serverName}] ${tool.description}`,
inputSchema: tool.inputSchema,
});
}
}
return manifest;
}
When the LLM chooses a tool, parse the prefix to route to the correct server.
Handling Dynamic Tool Changes
If servers declare tools.listChanged, they can notify your client when tools are added or removed. Your host should handle this:
client.setNotificationHandler(
"notifications/tools/list_changed",
async () => {
// Re-fetch the tool list and update the LLM's available tools
const updated = await client.listTools();
updateToolManifest(serverName, updated.tools);
}
);
Implementing Sampling Support
Sampling is an advanced capability where the server asks your client to perform an LLM completion. This enables agentic workflows where the server can reason about data using the model.
To support sampling, declare the capability during initialization, then handle incoming requests:
const client = new Client(
{ name: "my-app", version: "1.0.0" },
{ capabilities: { sampling: {} } }
);
client.setRequestHandler("sampling/createMessage", async (request) => {
const { messages, modelPreferences, maxTokens } = request.params;
// Call your LLM with the server's requested messages
const response = await callYourLLM({
messages: messages,
maxTokens: maxTokens,
// Optionally respect modelPreferences for model selection
});
return {
role: "assistant",
content: { type: "text", text: response.text },
model: "claude-sonnet-4-6",
};
});
Security note: Always review sampling requests before forwarding them to the LLM. A malicious server could attempt prompt injection through sampling. Consider implementing request filtering or requiring user approval for sampling.
Error Handling and Reconnection
Production clients need robust error handling.
Protocol-Level Errors
MCP uses JSON-RPC error codes. Handle these in your client:
| Code | Meaning |
|---|---|
| -32700 | Parse error — invalid JSON |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
Transport Disconnections
For stdio transports, the server process may crash. For HTTP transports, the network may drop. Implement reconnection:
async function connectWithRetry(
client: Client,
transport: Transport,
maxRetries: number = 3
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await client.connect(transport);
return;
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Tool Execution Errors
Tools report errors through the isError flag on the response, not through protocol-level errors:
const result = await client.callTool({
name: "write_file",
arguments: { path: "/etc/hosts", content: "..." },
});
if (result.isError) {
// The tool ran but reported a failure
// result.content contains error details
const errorMessage = result.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
console.error("Tool error:", errorMessage);
}
Security Considerations
Building a secure MCP client means controlling what servers can do through your application.
Principle of Least Privilege
Only connect to servers you trust. Each server connection is a potential attack surface — a compromised or malicious server could:
- Request sampling to attempt prompt injection
- Return misleading tool results to manipulate the LLM
- Exploit tool annotations to escalate from read-only to destructive operations
Validate Tool Annotations
The 2025-06-18 spec introduced tool annotations like readOnlyHint and destructiveHint. These are hints, not guarantees — a malicious server can lie about them. Use them for UX (showing warnings) but don’t rely on them for security:
for (const tool of tools) {
const annotations = tool.annotations || {};
if (annotations.destructiveHint === true) {
// Show confirmation dialog before calling
}
if (annotations.openWorldHint === true) {
// Tool interacts with external services — may need user consent
}
}
User Consent for Sensitive Operations
The host should implement human-in-the-loop approval for:
- First-time connections to new servers
- Tools marked as destructive
- Sampling requests from servers
- Resource access that may contain sensitive data
OAuth and Authorization
For remote servers using Streamable HTTP, MCP supports OAuth 2.1 for authorization. Your client may need to implement the OAuth flow:
- Discover the server’s authorization metadata
- Register as an OAuth client (dynamic client registration)
- Redirect the user for authorization
- Exchange the authorization code for tokens
- Include the access token in subsequent requests
The TypeScript SDK provides helpers for this flow, but the specifics depend on the server’s authorization requirements.
Testing Your Client
Against Reference Servers
The MCP project maintains reference servers you can test against:
@modelcontextprotocol/server-everything— implements all MCP features@modelcontextprotocol/server-filesystem— file system operations@modelcontextprotocol/server-memory— knowledge graph storage
MCP Inspector
The MCP Inspector is a visual debugging tool that lets you connect to any MCP server, browse its capabilities, and test tool calls interactively. It’s invaluable during development.
Integration Testing
Test your client against servers with known behavior:
// Spawn a test server and verify your client handles the lifecycle
const transport = new StdioClientTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-everything"],
});
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);
// Verify capabilities were negotiated
const tools = await client.listTools();
assert(tools.tools.length > 0, "Should discover tools");
// Verify tool calling works
const result = await client.callTool({
name: "echo",
arguments: { message: "hello" },
});
assert(!result.isError, "Echo should succeed");
await client.close();
Decision Framework: When to Build a Client
| Scenario | Recommendation |
|---|---|
| Adding MCP to an existing AI app | Build a client — integrate with your existing LLM pipeline |
| Building a new AI assistant | Use an existing host (Claude Desktop, etc.) first, build custom only if needed |
| Need to connect to many servers | Build a client with a connection manager |
| Internal tooling for your org | Build a client — gives you full control over security and UX |
| Quick prototype | Use the MCP Inspector or an existing client; build later |
What’s Next
The MCP ecosystem is evolving rapidly. Key developments to watch on the client side:
- Registry integration — clients discovering servers automatically through the MCP Registry API
- Improved session management — better patterns for Streamable HTTP sessions with load balancers
- Elicitation — servers requesting structured user input through the client
- Tool annotations — richer metadata to help clients make better UX decisions
Building an MCP client puts you at the integration layer of the AI stack. As more tools and services adopt MCP, your client becomes the bridge between your application and an expanding ecosystem of capabilities.
This guide is based on our analysis of the MCP specification (2025-06-18 and 2025-11-25), the official TypeScript and Python SDKs, and published community patterns. ChatForest researches and analyzes MCP technologies — we do not claim to test implementations hands-on. Written by Rob Nugen and ChatForest’s AI research team.