Flip the perspective — stop writing servers and learn to build clients. Connect to any MCP server, discover its capabilities, call tools, read resources, and orchestrate multi-server workflows.
📅 Day 9 of 90⏱ 🔌 Client SDK🤝 Multi-Server
Section 1
Server vs. Client — Two Sides of MCP
Every day until now you've been on the server side — registering tools, exposing resources, crafting prompts. Today you switch seats. The Client SDK is what any MCP host (Claude Desktop, your own app, a CI pipeline) uses to talk to those servers. Understanding clients makes you a complete MCP engineer.
🏗️
MCP Server
Exposes capabilities to the world
Registers tools, resources, prompts
Handles incoming requests
Calls external APIs on behalf of clients
Optionally requests sampling from host
What you built Days 3–7
🎮
MCP Client
Consumes and orchestrates capabilities
Connects to one or more servers
Discovers available tools / resources / prompts
Calls tools and reads resources on demand
Routes AI decisions to the right server
What you build today
💡
When do you write a client? Any time you're building the host layer — a custom AI assistant, a CI bot, a VS Code extension, a multi-agent orchestrator, or integration tests for your own server. Claude Desktop is an MCP client. Today you build your own.
Section 2
Client Architecture & Lifecycle
An MCP client goes through a strict four-phase lifecycle. Every phase maps to a specific set of JSON-RPC messages under the hood — but the Client SDK handles all the wire details so you only deal with clean TypeScript methods.
sequenceDiagram
participant A as Your App
participant C as McpClient
participant S as McpServer
A->>C: new Client(info, options)
A->>C: client.connect(transport)
C->>S: initialize {clientInfo, capabilities}
S-->>C: {serverInfo, capabilities, protocolVersion}
C->>S: notifications/initialized
Note over C,S: ✅ Connection established
A->>C: client.listTools()
C->>S: tools/list
S-->>C: {tools: [...]}
A->>C: client.callTool("search_repos", {query:"mcp"})
C->>S: tools/call {name, arguments}
S-->>C: {content: [...]}
A->>C: client.readResource("devdash://status")
C->>S: resources/read {uri}
S-->>C: {contents: [...]}
A->>C: client.close()
C->>S: close transport
1
Construct — create the client object
Pass clientInfo (name + version) and an options object declaring your client's capabilities. This is symmetric to how you construct McpServer.
2
Connect — attach a transport and handshake
client.connect(transport) starts the transport, fires the initialize request, awaits the server's capabilities response, and sends notifications/initialized. After this resolves, the client is ready.
3
Use — call tools, read resources, get prompts
The full set of typed async methods is available. All calls are automatically serialised, sent over the transport, and the response is deserialised and returned.
4
Close — gracefully shut down
client.close() closes the transport and cleans up any pending request callbacks. Always close in a finally block so connections don't leak.
Section 3
Your First MCP Client
The Client SDK lives in the same @modelcontextprotocol/sdk package as the Server SDK — no extra install needed. Here's the minimal client that connects to any stdio server, lists its tools, and calls one.
// Install (same package as the server SDK)
// npm install @modelcontextprotocol/sdksetup
// src/client-basic.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
// ── 1. Create client ──────────────────────────────────────────────────────
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
capabilities: {
// Declare what THIS CLIENT supports receiving
// (for now we're read-only — no sampling, no roots)
},
}
);
// ── 2. Connect via stdio (spawn the server as a child process) ────────────
const transport = new StdioClientTransport({
command: 'node',
args: ['./dist/index.js'], // path to your MCP server
env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? '' },
});
await client.connect(transport);
console.log('Connected to:', client.getServerVersion());
// ── 3. Discover tools ─────────────────────────────────────────────────────
const { tools } = await client.listTools();
console.log(`\nAvailable tools (${tools.length}):`);
tools.forEach(t => console.log(` • ${t.name} — ${t.description}`));
// ── 4. Call a tool ────────────────────────────────────────────────────────
const result = await client.callTool({
name: 'search_repos',
arguments: { query: 'modelcontextprotocol', cursor: 1 },
});
console.log('\nTool result:');
result.content.forEach(c => {
if (c.type === 'text') console.log(c.text);
});
// ── 5. Always close ───────────────────────────────────────────────────────
await client.close();src/client-basic.ts
🚀
StdioClientTransport spawns the server for you. You pass the command + args and the SDK handles process spawning, piping stdio, and process cleanup on close. You don't manage the child process at all.
Section 4
The Complete Client API
The Client class exposes a typed method for every JSON-RPC operation defined in the MCP spec. Here's the full surface area — every method you'll ever need.
listTools()
Returns all tools the server currently exposes, including names, descriptions, and JSON Schema input definitions.
→ { tools: Tool[] }
callTool({ name, arguments })
Invoke a named tool with an arguments object. Returns multi-content response and the isError flag.
→ { content: Content[], isError? }
listResources()
Returns all static resources the server exposes. URI template resources are listed separately via listResourceTemplates().
→ { resources: Resource[] }
listResourceTemplates()
Returns all URI template resources (e.g. github://repo/{owner}/{repo}) with their template strings and metadata.
→ { resourceTemplates: [] }
readResource({ uri })
Fetch the content of any resource by URI. Works for both static URIs and expanded URI template instances.
→ { contents: ResourceContent[] }
subscribeResource({ uri })
Subscribe to change notifications for a resource URI. Server pushes notifications/resources/updated when the resource changes.
→ void (then events)
listPrompts()
Returns all prompt templates the server exposes, with argument schemas for each.
→ { prompts: Prompt[] }
getPrompt({ name, arguments })
Render a prompt template by name with the provided arguments. Returns the resolved message array ready to send to an LLM.
→ { messages: PromptMessage[] }
setLoggingLevel({ level })
Set the server's minimum log level. Server sends notifications/message log events at or above this level.
→ void
ping()
Send a keep-alive ping to the server and await a pong. Use to verify the connection is still alive before making important calls.
→ void
getServerCapabilities()
Returns the capabilities object the server declared during the initialize handshake — tells you what features the server supports.
→ ServerCapabilities
getServerVersion()
Returns the server's { name, version } info from the handshake. Useful for logging and compatibility checks.
→ { name, version }
Section 5
Reading Resources & Rendering Prompts
Tools get all the attention, but resources and prompts are equally powerful from the client side. Here's how to read live data and render prompt templates in a client application.
During initialize, both sides advertise what they support. A well-written client always checks the server's capabilities before calling optional features — otherwise you'll get a -32601 Method not found error on servers that don't implement subscriptions or sampling.
tools.listChanged
Server will push notifications/tools/list_changed when its tool list changes at runtime.
resources.subscribe
Server supports resources/subscribe — clients can register for live update notifications.
resources.listChanged
Server will notify when its resource list changes (new files, new DB rows, etc.).
prompts.listChanged
Server will notify when its prompt template library is updated.
sampling
Server can call sampling/createMessage — the client must handle these requests and route them to an LLM.
logging
Server will emit notifications/message log events that the client can display or persist.
// src/client-capabilities.ts — safe capability-aware client
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client = new Client(
{ name: 'smart-client', version: '1.0.0' },
{
capabilities: {
// Declare sampling support so the server knows it can call us back
sampling: {},
},
}
);
await client.connect(new StdioClientTransport({ command: 'node', args: ['./dist/index.js'] }));
const caps = client.getServerCapabilities();
console.log('Server capabilities:', JSON.stringify(caps, null, 2));
// ── Guard before using optional features ─────────────────────────────────
// Only subscribe to resources if the server supports it
if (caps?.resources?.subscribe) {
await client.subscribeResource({ uri: 'devdash://limits' });
console.log('Subscribed to rate limits resource');
} else {
console.log('Server does not support resource subscriptions — polling instead');
}
// Only set log level if server supports logging
if (caps?.logging) {
await client.setLoggingLevel({ level: 'warning' });
console.log('Log level set to warning');
}
// ── Handle server → client sampling requests ──────────────────────────────
// If the server calls sampling/createMessage, YOUR client must handle it.
// This is where you route to an LLM (Anthropic, OpenAI, etc.)
client.setRequestHandler(
// The SDK exposes a type-safe handler setter for sampling
{ method: 'sampling/createMessage' } as any,
async (request: any) => {
const { messages, maxTokens, modelPreferences } = request.params;
console.log('Server requested sampling:', messages[0]?.content);
// In production: call your LLM here and return the response
// For now, return a stub
return {
role: 'assistant',
content: { type: 'text', text: 'Stub LLM response for sampling' },
model: 'stub-model',
stopReason: 'endTurn',
};
}
);
await client.close();src/client-capabilities.ts
⚠️
Sampling is client-side responsibility. If your client declares sampling: {} in its capabilities, the server will send sampling/createMessage requests to you. You must implement the handler — if you don't, those requests time out and the server's tool calls appear to hang.
Section 7
Connecting to HTTP+SSE Servers
When the MCP server isn't a local process but a remote HTTP endpoint, you swap StdioClientTransport for SSEClientTransport. Everything else stays identical — the same API methods, the same lifecycle.
// src/client-http.ts — connect to a remote HTTP+SSE MCP server
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
const client = new Client(
{ name: 'http-client', version: '1.0.0' },
{ capabilities: {} }
);
// Point at the SSE endpoint of the remote server
const transport = new SSEClientTransport(
new URL('https://mcp.yourdomain.com/sse'),
{
// Auth headers injected on every request
requestInit: {
headers: {
Authorization: `Bearer ${process.env.MCP_TOKEN}`,
},
},
}
);
await client.connect(transport);
// From here it's identical to stdio — same methods, same types
const { tools } = await client.listTools();
console.log('Remote server tools:', tools.map(t => t.name));
const result = await client.callTool({
name: 'search_repos',
arguments: { query: 'mcp typescript' },
});
console.log(result.content[0]?.text);
await client.close();src/client-http.ts
🔒
The requestInit option is passed to every fetch call the SSE transport makes — both the initial SSE GET and all subsequent POST messages. This is the correct place to inject bearer tokens, custom headers, or cookies.
Section 8
Client-Side Error Handling
Errors from the server arrive in two flavours. Knowing which is which lets you write the right recovery logic.
Error type
How to detect
Meaning
Recovery
Tool isError
result.isError === true
The tool ran but returned a domain error (e.g. 404 from GitHub). Not a crash.
Read result.content[0].text for the error message. Retry or surface to user.
JSON-RPC error
catch(err) on callTool()
Protocol-level failure — method not found, invalid params, server crashed, timeout.
Check err.code for the error code (see table below). Log and propagate.
Transport error
transport.onerror or connect rejection
TCP dropped, process exited, TLS failure, HTTP 4xx on SSE connect.
Reconnect with exponential backoff or fail fast depending on the use case.
Timeout
Promise hangs, no response
Server handler took too long or is blocked on a downstream call.
Wrap callTool() in Promise.race([call, sleep(timeout)]) or add AbortSignal.
// src/client-errors.ts — robust error handling patterns
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
async function safeCallTool(
client: Client,
name: string,
args: Record<string, unknown>,
timeoutMs = 30_000
) {
// ── Timeout wrapper ──────────────────────────────────────────────────────
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Tool "${name}" timed out after ${timeoutMs}ms`)), timeoutMs)
);
let result;
try {
result = await Promise.race([
client.callTool({ name, arguments: args }),
timeoutPromise,
]);
} catch (err) {
// ── JSON-RPC / protocol errors ─────────────────────────────────────────
if (err instanceof McpError) {
switch (err.code) {
case ErrorCode.MethodNotFound:
throw new Error(`Tool "${name}" does not exist on this server`);
case ErrorCode.InvalidParams:
throw new Error(`Invalid arguments for tool "${name}": ${err.message}`);
case ErrorCode.InternalError:
throw new Error(`Server error executing "${name}": ${err.message}`);
default:
throw new Error(`MCP error ${err.code}: ${err.message}`);
}
}
throw err; // re-throw transport errors, timeouts, etc.
}
// ── Tool domain errors (isError) ──────────────────────────────────────────
if (result.isError) {
const msg = result.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
// Tool errors are non-fatal — return them as structured data
return { success: false, error: msg, content: result.content };
}
return { success: true, content: result.content };
}
// Usage
const res = await safeCallTool(client, 'search_repos', { query: 'mcp' });
if (!res.success) {
console.warn('Tool returned error:', res.error);
} else {
console.log(res.content[0]?.text);
}src/client-errors.ts
Section 9
Multi-Server Orchestration
The real power of the Client SDK emerges when you connect to multiple servers simultaneously and route work intelligently between them. This is the foundation of every serious AI agent framework built on MCP.
// src/client-multi.ts — orchestrate multiple MCP servers
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
// ── Connect to multiple servers ───────────────────────────────────────────
async function connectServer(name: string, transport: any): Promise<Client> {
const client = new Client({ name: 'orchestrator', version: '1.0.0' }, { capabilities: {} });
await client.connect(transport);
console.log(`[${name}] Connected: ${client.getServerVersion()?.name}`);
return client;
}
const [githubClient, dbClient, notifyClient] = await Promise.all([
connectServer('github', new StdioClientTransport({
command: 'node', args: ['./devdash-mcp/dist/index.js'],
env: { ...process.env },
})),
connectServer('database', new StdioClientTransport({
command: 'node', args: ['./db-mcp/dist/index.js'],
env: { ...process.env },
})),
connectServer('notifications', new SSEClientTransport(
new URL('https://notify.internal/sse'),
{ requestInit: { headers: { Authorization: `Bearer ${process.env.NOTIFY_TOKEN}` } } }
)),
]);
// ── Build a unified tool registry ─────────────────────────────────────────
type ServerTool = { client: Client; serverName: string };
const toolRegistry = new Map<string, ServerTool>();
async function buildRegistry(client: Client, serverName: string) {
const { tools } = await client.listTools();
tools.forEach(t => toolRegistry.set(t.name, { client, serverName }));
console.log(`[${serverName}] Registered ${tools.length} tools`);
}
await Promise.all([
buildRegistry(githubClient, 'github'),
buildRegistry(dbClient, 'database'),
buildRegistry(notifyClient, 'notifications'),
]);
// ── Route tool calls to the right server ─────────────────────────────────
async function callTool(name: string, args: Record<string, unknown>) {
const entry = toolRegistry.get(name);
if (!entry) throw new Error(`No server provides tool: "${name}"`);
console.log(`[route] ${name} → ${entry.serverName}`);
return entry.client.callTool({ name, arguments: args });
}
// These automatically route to the correct server:
const repos = await callTool('search_repos', { query: 'mcp' });
const records = await callTool('query_users', { limit: 10 });
const sent = await callTool('send_slack', { channel: '#dev', message: 'Repos fetched!' });
// ── Graceful shutdown ─────────────────────────────────────────────────────
await Promise.all([
githubClient.close(),
dbClient.close(),
notifyClient.close(),
]);src/client-multi.ts
🧠
This is how AI agent frameworks work. When Claude Desktop connects to multiple MCP servers, it's doing exactly this — maintaining one Client per server, building a merged tool list, and routing tools/call to the right connection. You now have the same capability in pure TypeScript.
Section 10
Handling Server Notifications
Servers can push notifications to clients without a request — tool list changes, resource updates, log messages. A production client registers handlers for every notification type it cares about.
// src/client-notifications.ts — handle push events from the server
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client = new Client(
{ name: 'reactive-client', version: '1.0.0' },
{ capabilities: {} }
);
// ── Register notification handlers BEFORE connecting ─────────────────────
// (The SDK buffers notifications received before handlers are registered,
// but best practice is to set handlers first.)
// 1. Tool list changed — rebuild your tool registry
client.setNotificationHandler(
{ method: 'notifications/tools/list_changed' } as any,
async () => {
const { tools } = await client.listTools();
console.log('[notify] Tool list updated:', tools.map(t => t.name));
// Re-populate your UI / route table here
}
);
// 2. Resource updated — re-read the changed resource
client.setNotificationHandler(
{ method: 'notifications/resources/updated' } as any,
async (notification: any) => {
const { uri } = notification.params;
console.log('[notify] Resource changed:', uri);
const fresh = await client.readResource({ uri });
console.log('[notify] New content length:', fresh.contents[0]?.text?.length);
}
);
// 3. Log message from server — display in your UI
client.setNotificationHandler(
{ method: 'notifications/message' } as any,
(notification: any) => {
const { level, logger, data } = notification.params;
const emoji = { debug:'🔍', info:'ℹ️', warning:'⚠️', error:'🔴' }[level] ?? '📝';
console.log(`${emoji} [${logger ?? 'server'}] ${data}`);
}
);
await client.connect(new StdioClientTransport({ command: 'node', args: ['./dist/index.js'] }));
// Subscribe to a resource to trigger update notifications
if (client.getServerCapabilities()?.resources?.subscribe) {
await client.subscribeResource({ uri: 'devdash://limits' });
console.log('Subscribed — will receive push updates');
}
// Keep alive (in a real app, your event loop keeps it running)
await new Promise(resolve => setTimeout(resolve, 60_000));src/client-notifications.ts
Section 11
Real-World Client Patterns
Four patterns that appear in almost every production MCP client implementation.
🔄
Connection Pool
Keep a pool of pre-connected clients per server. Requests borrow a client from the pool and return it after use. Prevents cold-start latency on every tool call.
🏥
Health Monitoring
Call client.ping() on a 30-second interval. If it fails, mark the server unhealthy, stop routing to it, and attempt reconnection with exponential backoff.
📋
Tool Schema Cache
Cache the result of listTools() to avoid a round-trip on every AI inference step. Invalidate the cache on notifications/tools/list_changed.
🔁
Retry with Backoff
Wrap callTool() in a retry loop for transient errors (code -32603 InternalError). Use exponential backoff with jitter. Don't retry InvalidParams — it won't fix itself.
The Client SDK is intentionally symmetric to the Server SDK. If you know how to write a server, you know ~80% of the client. The same Zod types, the same transport classes, the same error codes — just flipped to the calling side.
Quiz · Day 9
Client SDK Check
5 questions covering the Client SDK lifecycle, error handling, and multi-server patterns. Score 5/5 and you're ready for Day 10.
Q1After calling await client.connect(transport), what has happened on the wire?
AThe transport is started but no messages have been exchanged yet
BAn initialize request was sent, the server's capabilities were received, and notifications/initialized was sent
COnly a TCP connection was established — MCP handshake happens on the first tool call
DThe server was spawned as a child process but stdin/stdout are not yet connected
Q2A callTool() call returns a result where result.isError === true. What does this mean?
AThe JSON-RPC protocol failed and the connection should be closed
BThe tool handler ran but encountered a domain error (e.g. 404, validation failure) — the protocol itself is fine
CThe server crashed — you should reconnect before calling any more tools
DThe tool name did not exist — you'll receive an McpError with code -32601
Q3Your client declares sampling: {} in its capabilities. What must you implement to avoid hanging tool calls?
AA notifications/message handler to receive log events
BA sampling/createMessage request handler that calls an LLM and returns a response
CA resources/subscribe call for every resource
DNothing — the SDK handles sampling automatically using a built-in model
Q4You're building a multi-server orchestrator and want to call a tool by name without knowing which server provides it. What's the correct pattern?
ABroadcast the tool call to all connected servers and use the first successful response
BBuild a unified tool registry at startup — call listTools() on each client and map tool name → client, then route calls to the correct client
CThe SDK includes a built-in router — pass all client instances to a McpRouter class
DAlways use the first server's client — servers automatically delegate to each other
Q5Which McpError code should you not retry, because retrying won't fix the underlying problem?