Building Your Own MCP Client
Build a custom MCP client from scratch — connect to servers, discover capabilities, call tools, read resources, invoke prompts, and handle the full MCP protocol lifecycle using the official TypeScript SDK.
title: "Building Your Own MCP Client" description: "Build a custom MCP client from scratch — connect to servers, discover capabilities, call tools, read resources, invoke prompts, and handle the full MCP protocol lifecycle using the official TypeScript SDK." order: 20 level: "advanced" duration: "40 min" keywords:
- "MCP client"
- "build MCP client"
- "MCP client SDK"
- "MCP client implementation"
- "@modelcontextprotocol/sdk client"
- "MCP client TypeScript"
- "connect to MCP server"
- "MCP capabilities"
- "MCP protocol client" date: "2026-04-01"
While most developers build MCP servers, understanding the client side completes your knowledge of the protocol. This lesson teaches you to build a custom MCP client that connects to servers, discovers capabilities, calls tools, reads resources, invokes prompts, and handles notifications. You will use the official TypeScript SDK's Client class and learn how to integrate MCP into your own applications — beyond the built-in support in Claude Desktop, Cursor, or VS Code.
Why Build a Custom MCP Client?
Existing MCP clients like Claude Desktop and Cursor handle server connections for you. But custom clients open up new possibilities:
- Custom AI applications — integrate MCP tools into your own AI-powered products
- Automation pipelines — call MCP tools from scripts, CI/CD, or cron jobs
- Testing harnesses — build advanced test infrastructure for MCP servers
- Aggregation layers — connect to multiple MCP servers and present a unified interface
- Non-AI use cases — use MCP as a general-purpose plugin/extension protocol
The MCP Client Lifecycle
Create transport
Choose how to communicate with the server — stdio (spawn process), SSE (HTTP connection), or Streamable HTTP.
Initialize client
Create a Client instance with your client metadata and desired capabilities.
Connect and negotiate
The client and server exchange initialize messages, negotiating protocol version and capabilities.
Discover capabilities
List available tools, resources, and prompts. The server reports what it supports.
Use the server
Call tools, read resources, invoke prompts, and handle notifications.
Disconnect gracefully
Close the connection, allowing the server to clean up.
A Minimal MCP Client
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
async function main() {
// Create a transport that spawns the server process
const transport = new StdioClientTransport({
command: "node",
args: ["path/to/server/dist/index.js"],
env: {
...process.env,
MCP_API_KEY: "your-key",
},
});
// Create the client
const client = new Client(
{
name: "my-custom-client",
version: "1.0.0",
},
{
capabilities: {
// Declare what capabilities this client supports
roots: { listChanged: true },
sampling: {},
},
}
);
// Connect to the server
await client.connect(transport);
console.log("Connected to MCP server");
// Discover what the server offers
const tools = await client.listTools();
console.log("Available tools:", tools.tools.map(t => t.name));
const resources = await client.listResources();
console.log("Available resources:", resources.resources.map(r => r.uri));
const prompts = await client.listPrompts();
console.log("Available prompts:", prompts.prompts.map(p => p.name));
// Use a tool
const result = await client.callTool("search-documents", {
query: "getting started",
limit: 5,
});
console.log("Tool result:", result);
// Clean up
await client.close();
}
main().catch(console.error);
An application that connects to one or more MCP servers, discovers their capabilities, and uses tools, resources, and prompts on behalf of users or AI models. Claude Desktop, Cursor, and VS Code Copilot are examples of MCP clients. The official SDK provides a Client class for building custom clients.
Connecting to Different Transports
stdio Transport (Local Servers)
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["dist/index.js"],
env: {
NODE_ENV: "production",
API_KEY: process.env.API_KEY,
},
// Optional: working directory for the server
cwd: "/path/to/server",
});
await client.connect(transport);
SSE Transport (Remote Servers)
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
const transport = new SSEClientTransport(
new URL("http://localhost:3001/sse")
);
await client.connect(transport);
Streamable HTTP Transport
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3001/mcp")
);
await client.connect(transport);
| Transport | Use Case | Client Import |
|---|---|---|
| stdio | Local server, spawn as child process | StdioClientTransport |
| SSE | Remote server over HTTP | SSEClientTransport |
| Streamable HTTP | Modern remote server | StreamableHTTPClientTransport |
Discovering Server Capabilities
After connecting, discover what the server provides:
// List all tools with full schema information
const { tools } = await client.listTools();
for (const tool of tools) {
console.log(`Tool: ${tool.name}`);
console.log(` Description: ${tool.description}`);
console.log(` Input schema:`, JSON.stringify(tool.inputSchema, null, 2));
}
// List all resources
const { resources } = await client.listResources();
for (const resource of resources) {
console.log(`Resource: ${resource.uri}`);
console.log(` Name: ${resource.name}`);
console.log(` Description: ${resource.description}`);
console.log(` MIME type: ${resource.mimeType}`);
}
// List resource templates (parameterized resources)
const { resourceTemplates } = await client.listResourceTemplates();
for (const template of resourceTemplates) {
console.log(`Template: ${template.uriTemplate}`);
console.log(` Name: ${template.name}`);
console.log(` Description: ${template.description}`);
}
// List all prompts
const { prompts } = await client.listPrompts();
for (const prompt of prompts) {
console.log(`Prompt: ${prompt.name}`);
console.log(` Description: ${prompt.description}`);
if (prompt.arguments) {
for (const arg of prompt.arguments) {
console.log(` Arg: ${arg.name} (${arg.required ? "required" : "optional"})`);
console.log(` ${arg.description}`);
}
}
}
Calling Tools
// Simple tool call
const searchResult = await client.callTool("search-documents", {
query: "MCP protocol",
limit: 10,
});
// Handle the result
if (searchResult.isError) {
console.error("Tool failed:", searchResult.content);
} else {
for (const item of searchResult.content) {
if (item.type === "text") {
console.log("Text result:", item.text);
} else if (item.type === "image") {
console.log("Image result:", item.mimeType, item.data.length, "bytes");
}
}
}
Tool Call with Error Handling
async function safeTool(
client: Client,
name: string,
args: Record<string, unknown>
): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const result = await client.callTool(name, args);
if (result.isError) {
const errorText = result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("\n");
return { success: false, error: errorText };
}
const text = result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("\n");
return { success: true, data: text };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
// Usage
const result = await safeTool(client, "get-weather", { location: "Tokyo" });
if (result.success) {
console.log("Weather data:", result.data);
} else {
console.error("Failed:", result.error);
}
Reading Resources
// Read a static resource
const configResource = await client.readResource("app://config/main");
for (const content of configResource.contents) {
console.log(`URI: ${content.uri}`);
console.log(`MIME: ${content.mimeType}`);
if (content.text) {
console.log(`Text: ${content.text}`);
}
if (content.blob) {
console.log(`Binary data: ${content.blob.length} base64 chars`);
}
}
// Read a template resource with parameters
const userProfile = await client.readResource("users://user-123/profile");
console.log("User profile:", userProfile.contents[0]?.text);
Subscribing to Resource Updates
// Subscribe to changes
await client.subscribeResource("orders://recent");
// Handle update notifications
client.setNotificationHandler(
"notifications/resources/updated",
async (notification) => {
const { uri } = notification.params;
console.log(`Resource updated: ${uri}`);
// Re-read the resource
const updated = await client.readResource(uri);
console.log("New data:", updated.contents[0]?.text);
}
);
// Later: unsubscribe
await client.unsubscribeResource("orders://recent");
Invoking Prompts
// Get a prompt with arguments
const promptResult = await client.getPrompt("code-review", {
code: "function add(a, b) { return a + b; }",
language: "javascript",
focus: ["security", "performance"],
});
// The result contains messages to send to an AI model
for (const message of promptResult.messages) {
console.log(`[${message.role}]:`);
if (message.content.type === "text") {
console.log(message.content.text);
} else if (message.content.type === "resource") {
console.log(`Resource: ${message.content.resource.uri}`);
}
}
Building a Multi-Server Client
Connect to multiple MCP servers and present a unified interface:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
interface ServerConfig {
name: string;
transport: "stdio" | "sse";
command?: string;
args?: string[];
url?: string;
}
class MultiServerClient {
private clients = new Map<string, Client>();
private toolMap = new Map<string, string>(); // tool name -> server name
async addServer(config: ServerConfig): Promise<void> {
let transport;
if (config.transport === "stdio") {
transport = new StdioClientTransport({
command: config.command!,
args: config.args || [],
});
} else {
transport = new SSEClientTransport(new URL(config.url!));
}
const client = new Client(
{ name: "multi-client", version: "1.0.0" },
{ capabilities: {} }
);
await client.connect(transport);
this.clients.set(config.name, client);
// Map tool names to server names
const { tools } = await client.listTools();
for (const tool of tools) {
this.toolMap.set(tool.name, config.name);
}
console.log(
`Connected to ${config.name}: ${tools.length} tools available`
);
}
async listAllTools() {
const allTools = [];
for (const [serverName, client] of this.clients) {
const { tools } = await client.listTools();
allTools.push(
...tools.map(t => ({ ...t, server: serverName }))
);
}
return allTools;
}
async callTool(toolName: string, args: Record<string, unknown>) {
const serverName = this.toolMap.get(toolName);
if (!serverName) {
throw new Error(`Unknown tool: ${toolName}`);
}
const client = this.clients.get(serverName)!;
return client.callTool(toolName, args);
}
async closeAll(): Promise<void> {
for (const [name, client] of this.clients) {
await client.close();
console.log(`Disconnected from ${name}`);
}
this.clients.clear();
this.toolMap.clear();
}
}
// Usage
const multiClient = new MultiServerClient();
await multiClient.addServer({
name: "file-server",
transport: "stdio",
command: "node",
args: ["servers/file-server/dist/index.js"],
});
await multiClient.addServer({
name: "api-server",
transport: "sse",
url: "http://localhost:3001/sse",
});
// Call tools across servers transparently
const files = await multiClient.callTool("search-files", { pattern: "*.ts" });
const apiData = await multiClient.callTool("fetch-api", { endpoint: "/users" });
await multiClient.closeAll();
When connecting to multiple servers, tool names may conflict. Prefix tool names with the server name (e.g., file-server:search-files) or maintain a priority list. Log a warning when conflicts are detected.
Integrating with AI Models
The most common use case for custom MCP clients is connecting MCP servers to AI models:
import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
class AIWithMCPTools {
private anthropic: Anthropic;
private mcpClient: Client;
constructor(mcpClient: Client) {
this.anthropic = new Anthropic();
this.mcpClient = mcpClient;
}
async chat(userMessage: string): Promise<string> {
// Get available tools from MCP server
const { tools } = await this.mcpClient.listTools();
// Convert MCP tools to Anthropic tool format
const anthropicTools = tools.map(tool => ({
name: tool.name,
description: tool.description || "",
input_schema: tool.inputSchema,
}));
// Send message with tools
const response = await this.anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
tools: anthropicTools,
messages: [{ role: "user", content: userMessage }],
});
// Handle tool use
const toolUseBlocks = response.content.filter(
(block) => block.type === "tool_use"
);
if (toolUseBlocks.length > 0) {
// Execute MCP tools
const toolResults = await Promise.all(
toolUseBlocks.map(async (block) => {
const result = await this.mcpClient.callTool(
block.name,
block.input as Record<string, unknown>
);
return {
type: "tool_result" as const,
tool_use_id: block.id,
content: result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map(c => c.text)
.join("\n"),
};
})
);
// Send tool results back to the model
const followUp = await this.anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
tools: anthropicTools,
messages: [
{ role: "user", content: userMessage },
{ role: "assistant", content: response.content },
{ role: "user", content: toolResults },
],
});
return followUp.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
}
return response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
}
}
MCP tool schemas use JSON Schema format, which is compatible with the tool input_schema format used by Anthropic's API. You can pass MCP tool schemas directly to Claude without conversion.
Handling Notifications
MCP servers can send notifications to clients. Handle them to keep your client in sync:
// Server capabilities changed
client.setNotificationHandler(
"notifications/tools/list_changed",
async () => {
console.log("Tools list changed, refreshing...");
const { tools } = await client.listTools();
updateToolCache(tools);
}
);
client.setNotificationHandler(
"notifications/resources/list_changed",
async () => {
console.log("Resources list changed, refreshing...");
const { resources } = await client.listResources();
updateResourceCache(resources);
}
);
client.setNotificationHandler(
"notifications/prompts/list_changed",
async () => {
console.log("Prompts list changed, refreshing...");
const { prompts } = await client.listPrompts();
updatePromptCache(prompts);
}
);
// Progress updates from long-running tools
client.setNotificationHandler(
"notifications/progress",
async (notification) => {
const { progressToken, progress, total } = notification.params;
if (total) {
const percent = Math.round((progress / total) * 100);
console.log(`Progress [${progressToken}]: ${percent}%`);
}
}
);
Error Handling for Clients
class ResilientClient {
private client: Client | null = null;
private config: ServerConfig;
private reconnecting = false;
constructor(config: ServerConfig) {
this.config = config;
}
async connect(): Promise<void> {
this.client = new Client(
{ name: "resilient-client", version: "1.0.0" },
{ capabilities: {} }
);
const transport = this.createTransport();
// Handle transport closure
transport.onclose = () => {
console.error("Connection lost");
this.scheduleReconnect();
};
transport.onerror = (error) => {
console.error("Transport error:", error.message);
};
await this.client.connect(transport);
}
async callToolSafe(
name: string,
args: Record<string, unknown>,
retries = 2
): Promise<any> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
if (!this.client) {
await this.connect();
}
return await this.client!.callTool(name, args);
} catch (error) {
console.error(`Tool call failed (attempt ${attempt + 1}):`, error);
if (attempt < retries) {
// Reconnect and retry
await this.connect();
} else {
throw error;
}
}
}
}
private scheduleReconnect(): void {
if (this.reconnecting) return;
this.reconnecting = true;
setTimeout(async () => {
try {
await this.connect();
console.log("Reconnected successfully");
} catch (error) {
console.error("Reconnection failed:", error);
this.scheduleReconnect();
} finally {
this.reconnecting = false;
}
}, 5000);
}
private createTransport() {
if (this.config.transport === "stdio") {
return new StdioClientTransport({
command: this.config.command!,
args: this.config.args || [],
});
}
return new SSEClientTransport(new URL(this.config.url!));
}
}
Client Architecture Patterns
| Pattern | Description | Best For |
|---|---|---|
| Single server | One client connects to one server | Simple applications, testing |
| Multi-server | One client aggregates multiple servers | Full-featured AI applications |
| Client pool | Multiple client instances for throughput | High-concurrency automation |
| Gateway | Client that proxies requests to many servers | Microservice architectures |
Listing tools, resources, and prompts requires round-trips to the server. Cache the results and only refresh when you receive a list_changed notification. This significantly reduces latency for repeated operations.