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"

Quick Summary

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
2sides to MCP: most developers build servers, but understanding clients makes you a complete MCP engineer

The MCP Client Lifecycle

1

Create transport

Choose how to communicate with the server — stdio (spawn process), SSE (HTTP connection), or Streamable HTTP.

2

Initialize client

Create a Client instance with your client metadata and desired capabilities.

3

Connect and negotiate

The client and server exchange initialize messages, negotiating protocol version and capabilities.

4

Discover capabilities

List available tools, resources, and prompts. The server reports what it supports.

5

Use the server

Call tools, read resources, invoke prompts, and handle notifications.

6

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);
MCP Client

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);
TransportUse CaseClient Import
stdioLocal server, spawn as child processStdioClientTransport
SSERemote server over HTTPSSEClientTransport
Streamable HTTPModern remote serverStreamableHTTPClientTransport

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();
Handle Tool Name Conflicts

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");
  }
}
Tool Schema Compatibility

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

PatternDescriptionBest For
Single serverOne client connects to one serverSimple applications, testing
Multi-serverOne client aggregates multiple serversFull-featured AI applications
Client poolMultiple client instances for throughputHigh-concurrency automation
GatewayClient that proxies requests to many serversMicroservice architectures
Cache Capability Discovery

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.

Frequently Asked Questions