# How to build an MCP client in TypeScript

- Tool: MCP
- Difficulty: Advanced
- Time required: 25-35 min
- Compatibility: MCP TypeScript SDK v1.x, Node.js 18+
- Last updated: March 2026

## TL;DR

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.

## Before you start

- 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

### 1. 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.

```
mkdir mcp-client && cd mcp-client
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init
```

**Expected result:** Project initialized with the MCP SDK ready for client development.

### 2. 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.

```
// src/client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

export async function createClient(
  command: string,
  args: string[],
  env?: Record<string, string>
): Promise<Client> {
  const transport = new StdioClientTransport({
    command,
    args,
    env: { ...process.env, ...env } as Record<string, string>,
  });

  const client = new Client(
    { name: "my-mcp-client", version: "1.0.0" },
    { capabilities: {} }
  );

  await client.connect(transport);

  // Get server info
  const serverInfo = client.getServerVersion();
  console.error(`Connected to: ${serverInfo?.name} v${serverInfo?.version}`);

  return client;
}
```

**Expected result:** A client that spawns the MCP server process and establishes a protocol connection.

### 3. 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.

```
// src/discover.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

export async function discoverTools(client: Client) {
  const { tools } = await client.listTools();

  console.error(`\nDiscovered ${tools.length} tools:\n`);
  for (const tool of tools) {
    console.error(`  ${tool.name}`);
    console.error(`    ${tool.description}`);
    if (tool.inputSchema?.properties) {
      const params = Object.entries(tool.inputSchema.properties)
        .map(([name, schema]) => {
          const s = schema as any;
          const required = tool.inputSchema.required?.includes(name) ? "required" : "optional";
          return `      - ${name} (${s.type}, ${required}): ${s.description || ""}`;
        });
      console.error(params.join("\n"));
    }
    console.error();
  }

  return tools;
}
```

**Expected result:** A function that lists all available tools with their names, descriptions, and parameter schemas.

### 4. 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.

```
// src/invoke.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

export interface ToolResult {
  text: string;
  isError: boolean;
}

export async function callTool(
  client: Client,
  toolName: string,
  args: Record<string, unknown>
): Promise<ToolResult> {
  try {
    const result = await client.callTool({
      name: toolName,
      arguments: args,
    });

    const content = result.content as Array<{ type: string; text?: string }>;
    const text = content
      .filter(c => c.type === "text" && c.text)
      .map(c => c.text!)
      .join("\n");

    return {
      text: text || "(no text content returned)",
      isError: (result as any).isError === true,
    };
  } catch (error) {
    return {
      text: `Client error: ${error instanceof Error ? error.message : String(error)}`,
      isError: true,
    };
  }
}

// Usage:
// const result = await callTool(client, "read_file", { filePath: "src/index.ts" });
// if (result.isError) console.error("Tool failed:", result.text);
// else console.log(result.text);
```

**Expected result:** A callTool function that invokes any MCP tool and returns the text result with error status.

### 5. 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.

```
// src/interactive.ts
import { createInterface } from "readline";
import { createClient } from "./client.js";
import { discoverTools } from "./discover.js";
import { callTool } from "./invoke.js";

async function main() {
  const command = process.argv[2];
  const args = process.argv.slice(3);
  if (!command) {
    console.error("Usage: node dist/interactive.js <server-command> [args...]");
    process.exit(1);
  }

  const client = await createClient(command, args);
  await discoverTools(client);

  const rl = createInterface({ input: process.stdin, output: process.stderr });

  const promptUser = () => rl.question("\nmcp> ", async (line) => {
    const trimmed = line.trim();
    if (trimmed === "quit" || trimmed === "exit") {
      await client.close();
      rl.close();
      return;
    }
    if (trimmed === "tools") {
      await discoverTools(client);
      promptUser();
      return;
    }

    // Parse: toolName key=value key=value
    const parts = trimmed.split(/\s+/);
    const toolName = parts[0];
    const toolArgs: Record<string, unknown> = {};
    for (const part of parts.slice(1)) {
      const [key, ...rest] = part.split("=");
      const value = rest.join("=");
      toolArgs[key] = isNaN(Number(value)) ? value : Number(value);
    }

    const result = await callTool(client, toolName, toolArgs);
    console.error(result.isError ? `ERROR: ${result.text}` : result.text);
    promptUser();
  });

  promptUser();
}

main().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 code example

File: `src/index.ts`

```typescript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

async function main() {
  const serverCmd = process.argv[2];
  const serverArgs = process.argv.slice(3);

  if (!serverCmd) {
    console.error("Usage: node dist/index.js <command> [args...]");
    console.error("Example: node dist/index.js node /path/to/server/dist/index.js");
    process.exit(1);
  }

  // Connect
  const transport = new StdioClientTransport({ command: serverCmd, args: serverArgs });
  const client = new Client(
    { name: "mcp-cli-client", version: "1.0.0" },
    { capabilities: {} }
  );
  await client.connect(transport);
  const info = client.getServerVersion();
  console.error(`Connected to ${info?.name} v${info?.version}`);

  // Discover tools
  const { tools } = await client.listTools();
  console.error(`\nAvailable tools (${tools.length}):`);
  tools.forEach(t => console.error(`  - ${t.name}: ${t.description}`));

  // Call first tool as demo
  if (tools.length > 0) {
    const tool = tools[0];
    console.error(`\nCalling ${tool.name} with empty args...`);
    try {
      const result = await client.callTool({ name: tool.name, arguments: {} });
      const text = (result.content as any[])
        .filter(c => c.type === "text")
        .map(c => c.text)
        .join("\n");
      console.error(`Result: ${text}`);
    } catch (e) {
      console.error(`Error: ${e}`);
    }
  }

  await client.close();
  console.error("Disconnected.");
}

main().catch(e => { console.error(e); process.exit(1); });
```

## Common mistakes

- **Not calling client.close() when done, leaving zombie server processes running** — undefined Fix: Always close the client connection in a finally block or process exit handler to terminate the server process.
- **Assuming tool results are always a single text string** — undefined Fix: Tool results are an array of content items that can include text, images, and resources. Filter by type and handle each appropriately.
- **Not passing environment variables to the transport, causing the server to fail on startup** — undefined Fix: Spread process.env into the transport env option and add any server-specific variables on top.
- **Treating MCP tool errors as exceptions instead of checking the isError flag** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-build-mcp-client-in-typescript
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-build-mcp-client-in-typescript
