Build a custom MCP client in TypeScript using the Client class and StdioClientTransport from the official SDK. The client connects to any MCP server, discovers available tools via listTools(), calls them with callTool(), and processes results. This lets you integrate MCP servers into custom applications, automation scripts, and AI agent frameworks beyond Claude Desktop or Cursor.
Building a Custom MCP Client in TypeScript
MCP clients are the other half of the protocol — they connect to servers, discover tools and resources, and call them on behalf of users or AI agents. While Claude Desktop and Cursor are popular MCP clients, building your own lets you integrate MCP servers into custom workflows, automated pipelines, and agent frameworks. This tutorial walks through the Client class, transport setup, tool discovery, invocation, and error handling.
Prerequisites
- Node.js 18+ and npm installed
- MCP TypeScript SDK installed (@modelcontextprotocol/sdk)
- An MCP server to connect to (built or installed)
- Basic TypeScript knowledge
- Understanding of the MCP protocol concepts (tools, resources, prompts)
Step-by-step guide
Set up the project and install the MCP client SDK
Set up the project and install the MCP client SDK
Create a new project and install the MCP SDK. The same @modelcontextprotocol/sdk package provides both server and client APIs. The client classes are in the /client/ subpath. You also need zod for any schema work and a readline module for interactive input.
1mkdir mcp-client && cd mcp-client2npm init -y3npm install @modelcontextprotocol/sdk zod4npm install -D typescript @types/node5npx tsc --initExpected result: Project initialized with the MCP SDK ready for client development.
Create the MCP client and connect via stdio transport
Create the MCP client and connect via stdio transport
The Client class manages the MCP protocol lifecycle. StdioClientTransport spawns the server as a child process and communicates over stdin/stdout. Pass the server command and arguments to the transport. The client constructor takes a client info object (name and version) and a capabilities object. Call client.connect() to establish the connection and perform the protocol handshake.
1// src/client.ts2import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";45export async function createClient(6 command: string,7 args: string[],8 env?: Record<string, string>9): Promise<Client> {10 const transport = new StdioClientTransport({11 command,12 args,13 env: { ...process.env, ...env } as Record<string, string>,14 });1516 const client = new Client(17 { name: "my-mcp-client", version: "1.0.0" },18 { capabilities: {} }19 );2021 await client.connect(transport);2223 // Get server info24 const serverInfo = client.getServerVersion();25 console.error(`Connected to: ${serverInfo?.name} v${serverInfo?.version}`);2627 return client;28}Expected result: A client that spawns the MCP server process and establishes a protocol connection.
Discover available tools on the connected server
Discover available tools on the connected server
Call client.listTools() to get the list of tools the server provides. Each tool has a name, description, and inputSchema (JSON Schema). Print or display these to let users know what capabilities are available. The input schema tells you what parameters each tool expects. Use this for dynamic UI generation, validation, or documentation.
1// src/discover.ts2import { Client } from "@modelcontextprotocol/sdk/client/index.js";34export async function discoverTools(client: Client) {5 const { tools } = await client.listTools();67 console.error(`\nDiscovered ${tools.length} tools:\n`);8 for (const tool of tools) {9 console.error(` ${tool.name}`);10 console.error(` ${tool.description}`);11 if (tool.inputSchema?.properties) {12 const params = Object.entries(tool.inputSchema.properties)13 .map(([name, schema]) => {14 const s = schema as any;15 const required = tool.inputSchema.required?.includes(name) ? "required" : "optional";16 return ` - ${name} (${s.type}, ${required}): ${s.description || ""}`;17 });18 console.error(params.join("\n"));19 }20 console.error();21 }2223 return tools;24}Expected result: A function that lists all available tools with their names, descriptions, and parameter schemas.
Call tools and handle results
Call tools and handle results
Use client.callTool() to invoke a tool with a name and arguments object. The result contains a content array with one or more content items (text, images, resources) and an optional isError flag. Parse the content array to extract text responses. Handle errors by checking isError and displaying the error message to the user.
1// src/invoke.ts2import { Client } from "@modelcontextprotocol/sdk/client/index.js";34export interface ToolResult {5 text: string;6 isError: boolean;7}89export async function callTool(10 client: Client,11 toolName: string,12 args: Record<string, unknown>13): Promise<ToolResult> {14 try {15 const result = await client.callTool({16 name: toolName,17 arguments: args,18 });1920 const content = result.content as Array<{ type: string; text?: string }>;21 const text = content22 .filter(c => c.type === "text" && c.text)23 .map(c => c.text!)24 .join("\n");2526 return {27 text: text || "(no text content returned)",28 isError: (result as any).isError === true,29 };30 } catch (error) {31 return {32 text: `Client error: ${error instanceof Error ? error.message : String(error)}`,33 isError: true,34 };35 }36}3738// Usage:39// const result = await callTool(client, "read_file", { filePath: "src/index.ts" });40// if (result.isError) console.error("Tool failed:", result.text);41// else console.log(result.text);Expected result: A callTool function that invokes any MCP tool and returns the text result with error status.
Build an interactive CLI client
Build an interactive CLI client
Combine discovery and invocation into an interactive command-line client. On startup, connect to the server and list available tools. Then enter a read-eval-print loop where the user types tool calls and sees results. Parse input as "toolName param1=value1 param2=value2". This is useful for testing MCP servers and for building custom admin interfaces.
1// src/interactive.ts2import { createInterface } from "readline";3import { createClient } from "./client.js";4import { discoverTools } from "./discover.js";5import { callTool } from "./invoke.js";67async function main() {8 const command = process.argv[2];9 const args = process.argv.slice(3);10 if (!command) {11 console.error("Usage: node dist/interactive.js <server-command> [args...]");12 process.exit(1);13 }1415 const client = await createClient(command, args);16 await discoverTools(client);1718 const rl = createInterface({ input: process.stdin, output: process.stderr });1920 const promptUser = () => rl.question("\nmcp> ", async (line) => {21 const trimmed = line.trim();22 if (trimmed === "quit" || trimmed === "exit") {23 await client.close();24 rl.close();25 return;26 }27 if (trimmed === "tools") {28 await discoverTools(client);29 promptUser();30 return;31 }3233 // Parse: toolName key=value key=value34 const parts = trimmed.split(/\s+/);35 const toolName = parts[0];36 const toolArgs: Record<string, unknown> = {};37 for (const part of parts.slice(1)) {38 const [key, ...rest] = part.split("=");39 const value = rest.join("=");40 toolArgs[key] = isNaN(Number(value)) ? value : Number(value);41 }4243 const result = await callTool(client, toolName, toolArgs);44 console.error(result.isError ? `ERROR: ${result.text}` : result.text);45 promptUser();46 });4748 promptUser();49}5051main().catch(e => { console.error(e); process.exit(1); });Expected result: An interactive CLI that connects to any MCP server, lists tools, and lets users call them interactively.
Complete working example
1import { Client } from "@modelcontextprotocol/sdk/client/index.js";2import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";34async function main() {5 const serverCmd = process.argv[2];6 const serverArgs = process.argv.slice(3);78 if (!serverCmd) {9 console.error("Usage: node dist/index.js <command> [args...]");10 console.error("Example: node dist/index.js node /path/to/server/dist/index.js");11 process.exit(1);12 }1314 // Connect15 const transport = new StdioClientTransport({ command: serverCmd, args: serverArgs });16 const client = new Client(17 { name: "mcp-cli-client", version: "1.0.0" },18 { capabilities: {} }19 );20 await client.connect(transport);21 const info = client.getServerVersion();22 console.error(`Connected to ${info?.name} v${info?.version}`);2324 // Discover tools25 const { tools } = await client.listTools();26 console.error(`\nAvailable tools (${tools.length}):`);27 tools.forEach(t => console.error(` - ${t.name}: ${t.description}`));2829 // Call first tool as demo30 if (tools.length > 0) {31 const tool = tools[0];32 console.error(`\nCalling ${tool.name} with empty args...`);33 try {34 const result = await client.callTool({ name: tool.name, arguments: {} });35 const text = (result.content as any[])36 .filter(c => c.type === "text")37 .map(c => c.text)38 .join("\n");39 console.error(`Result: ${text}`);40 } catch (e) {41 console.error(`Error: ${e}`);42 }43 }4445 await client.close();46 console.error("Disconnected.");47}4849main().catch(e => { console.error(e); process.exit(1); });Common mistakes when building an MCP client in TypeScript
Why it's a problem: Not calling client.close() when done, leaving zombie server processes running
How to avoid: Always close the client connection in a finally block or process exit handler to terminate the server process.
Why it's a problem: Assuming tool results are always a single text string
How to avoid: Tool results are an array of content items that can include text, images, and resources. Filter by type and handle each appropriately.
Why it's a problem: Not passing environment variables to the transport, causing the server to fail on startup
How to avoid: Spread process.env into the transport env option and add any server-specific variables on top.
Why it's a problem: Treating MCP tool errors as exceptions instead of checking the isError flag
How to avoid: MCP returns errors as normal results with isError: true. Check the flag instead of wrapping calls in try-catch for business logic errors.
Best practices
- Always call client.close() to clean up server processes when the client is done
- Handle the content array properly — filter by type and join text items
- Pass environment variables through the transport for server configuration
- Check isError on results instead of relying solely on try-catch
- Cache tool lists if making repeated calls to the same server
- Log connection status and discovered tools to stderr for debugging
- Set a reasonable timeout for tool calls to prevent hanging on unresponsive servers
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Build a custom MCP client in TypeScript using @modelcontextprotocol/sdk. Show me how to create a Client with StdioClientTransport, connect to a server, discover tools with listTools(), call them with callTool(), and handle results including errors.
Create an interactive MCP client CLI. Connect to any MCP server via stdio, list available tools, let the user call tools with parameters, and display results. Use the Client and StdioClientTransport from @modelcontextprotocol/sdk.
Frequently asked questions
Can I connect to multiple MCP servers from one client?
Yes. Create multiple Client instances, each with its own transport pointing to a different server. Manage them in an array or map keyed by server name.
Does the MCP client work with HTTP/SSE transport too?
Yes. Replace StdioClientTransport with SSEClientTransport for connecting to remote MCP servers over HTTP. The Client API (listTools, callTool) is the same regardless of transport.
How do I handle timeouts when calling slow tools?
The MCP SDK does not have built-in per-call timeouts. Wrap callTool in a Promise.race with a setTimeout to implement your own timeout logic.
Can my MCP client also read resources, not just call tools?
Yes. Use client.listResources() to discover resources and client.readResource() to read them. Resources provide data (files, database records) while tools perform actions.
Can RapidDev build custom MCP clients for our team?
Yes, RapidDev builds custom MCP clients integrated into existing applications, admin dashboards, and automation pipelines. They handle the protocol details so your team can focus on business logic.
What happens if the MCP server crashes while my client is connected?
The transport emits an error event and the client connection closes. Implement reconnection logic by catching transport errors and calling createClient again after a delay.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation