Build an MCP Client in TypeScript: Connect to Any AI Tool Server

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

You have learned how to build MCP servers. Now build the other side. In this tutorial, you will create a TypeScript MCP client that connects to any MCP server, discovers its capabilities, and calls tools programmatically — the foundation for building your own AI-powered applications.

What You Will Learn

By the end of this tutorial, you will:

  • Understand the MCP client-server architecture from the client perspective
  • Create a TypeScript MCP client using @modelcontextprotocol/sdk
  • Connect to MCP servers via stdio and SSE transports
  • Discover and call tools exposed by any MCP server
  • Read resources and use prompts from servers
  • Build a multi-server client that connects to several servers simultaneously
  • Create a CLI interface for interactive MCP exploration

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript knowledge (types, async/await, modules)
  • A code editor — VS Code or Cursor recommended
  • Familiarity with the MCP protocol concepts (helpful but not required)
  • Basic understanding of JSON-RPC

What is an MCP Client?

In the Model Context Protocol architecture, the client is the component that connects to MCP servers and consumes their capabilities. While MCP hosts like Claude Desktop and Cursor have built-in clients, you can build your own client to:

  • Integrate MCP tools into your own applications — chatbots, CLI tools, automation pipelines
  • Build custom AI workflows that orchestrate multiple MCP servers
  • Test and debug MCP servers during development
  • Create specialized interfaces tailored to specific use cases

Architecture Recap

┌─────────────────────────────────────────────────┐
│  Your Application (MCP Host)                    │
│                                                 │
│  ┌───────────┐  ┌───────────┐  ┌───────────┐  │
│  │  Client 1 │  │  Client 2 │  │  Client 3 │  │
│  └─────┬─────┘  └─────┬─────┘  └─────┬─────┘  │
└────────┼──────────────┼──────────────┼──────────┘
         │              │              │
    ┌────▼────┐   ┌─────▼────┐  ┌─────▼────┐
    │ Server A│   │ Server B │  │ Server C │
    │ (stdio) │   │  (SSE)   │  │ (stdio)  │
    └─────────┘   └──────────┘  └──────────┘

Each client maintains a 1:1 connection with a single server. Your application (the host) manages multiple clients to connect to multiple servers.


Step 1: Project Setup

Create a new TypeScript project for your MCP client:

mkdir mcp-client && cd mcp-client
npm init -y

Install the required dependencies:

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Here is what each package does:

PackagePurpose
@modelcontextprotocol/sdkOfficial MCP SDK with Client class
zodSchema validation for tool arguments
typescriptTypeScript compiler
tsxRun TypeScript files directly

Initialize TypeScript:

npx tsc --init

Update your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

Create the source directory:

mkdir src

Add scripts to package.json:

{
  "type": "module",
  "scripts": {
    "start": "tsx src/index.ts",
    "build": "tsc",
    "dev": "tsx watch src/index.ts"
  }
}

Step 2: Creating a Basic MCP Client

Create src/client.ts — the core client wrapper:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
 
export async function createMCPClient(
  serverCommand: string,
  serverArgs: string[] = [],
  env?: Record<string, string>
) {
  const transport = new StdioClientTransport({
    command: serverCommand,
    args: serverArgs,
    env: env ? { ...process.env, ...env } as Record<string, string> : undefined,
  });
 
  const client = new Client(
    {
      name: "my-mcp-client",
      version: "1.0.0",
    },
    {
      capabilities: {
        roots: { listChanged: true },
        sampling: {},
      },
    }
  );
 
  await client.connect(transport);
  return client;
}

Let us break down what happens here:

  1. StdioClientTransport — launches the MCP server as a child process and communicates over stdin/stdout. This is the most common transport for local MCP servers.

  2. Client — the MCP client instance. The first argument defines client info (name and version). The second declares capabilities your client supports.

  3. client.connect(transport) — establishes the connection, performs the MCP handshake, and negotiates capabilities with the server.


Step 3: Discovering Server Capabilities

Once connected, the first thing your client should do is discover what the server offers. Create src/discover.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
export async function discoverCapabilities(client: Client) {
  const serverInfo = client.getServerVersion();
  console.log("Connected to server:", serverInfo);
 
  const capabilities = client.getServerCapabilities();
  console.log("Server capabilities:", capabilities);
 
  if (capabilities?.tools) {
    const toolsResult = await client.listTools();
    console.log(`\nFound ${toolsResult.tools.length} tools:`);
    for (const tool of toolsResult.tools) {
      console.log(`  - ${tool.name}: ${tool.description}`);
    }
  }
 
  if (capabilities?.resources) {
    const resourcesResult = await client.listResources();
    console.log(`\nFound ${resourcesResult.resources.length} resources:`);
    for (const resource of resourcesResult.resources) {
      console.log(`  - ${resource.uri}: ${resource.name}`);
    }
  }
 
  if (capabilities?.prompts) {
    const promptsResult = await client.listPrompts();
    console.log(`\nFound ${promptsResult.prompts.length} prompts:`);
    for (const prompt of promptsResult.prompts) {
      console.log(`  - ${prompt.name}: ${prompt.description}`);
    }
  }
 
  return capabilities;
}

This function queries three categories of server features:

  • Tools — functions the server exposes (like search_files, run_query, create_issue)
  • Resources — data sources the server provides (like files, database records, API responses)
  • Prompts — reusable prompt templates with parameters

Step 4: Calling Tools

Tools are the most powerful MCP feature. They let your client invoke server-side functions with structured arguments. Create src/tools.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
export async function callTool(
  client: Client,
  toolName: string,
  args: Record<string, unknown> = {}
) {
  const result = await client.callTool({
    name: toolName,
    arguments: args,
  });
 
  if (result.isError) {
    throw new Error(
      `Tool "${toolName}" failed: ${JSON.stringify(result.content)}`
    );
  }
 
  return result;
}
 
export async function listAndDescribeTools(client: Client) {
  const { tools } = await client.listTools();
 
  return tools.map((tool) => ({
    name: tool.name,
    description: tool.description,
    parameters: tool.inputSchema,
  }));
}

Example: Calling a File Search Tool

const result = await callTool(client, "search_files", {
  query: "authentication",
  path: "./src",
});
 
for (const content of result.content) {
  if (content.type === "text") {
    console.log(content.text);
  }
}

Handling Different Content Types

MCP tools can return different content types. Here is how to handle them:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
export function processToolResult(result: Awaited<ReturnType<Client["callTool"]>>) {
  const outputs: string[] = [];
 
  for (const content of result.content) {
    switch (content.type) {
      case "text":
        outputs.push(content.text);
        break;
      case "image":
        outputs.push(`[Image: ${content.mimeType}, ${content.data.length} bytes]`);
        break;
      case "resource":
        outputs.push(`[Resource: ${content.resource.uri}]`);
        break;
      default:
        outputs.push(`[Unknown content type]`);
    }
  }
 
  return outputs.join("\n");
}

Step 5: Reading Resources

Resources provide read-only access to data exposed by the server. Create src/resources.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
export async function readResource(client: Client, uri: string) {
  const result = await client.readResource({ uri });
 
  for (const content of result.contents) {
    if (content.text) {
      return content.text;
    }
    if (content.blob) {
      return `[Binary data: ${content.mimeType}]`;
    }
  }
 
  return null;
}
 
export async function listAllResources(client: Client) {
  const resources: Array<{ uri: string; name: string; description?: string }> = [];
 
  const result = await client.listResources();
  resources.push(...result.resources);
 
  let cursor = result.nextCursor;
  while (cursor) {
    const nextPage = await client.listResources({ cursor });
    resources.push(...nextPage.resources);
    cursor = nextPage.nextCursor;
  }
 
  return resources;
}

Resource Templates

Some servers expose resource templates — parameterized URIs that generate resources dynamically:

export async function listResourceTemplates(client: Client) {
  const result = await client.listResourceTemplates();
 
  for (const template of result.resourceTemplates) {
    console.log(`Template: ${template.uriTemplate}`);
    console.log(`  Name: ${template.name}`);
    console.log(`  Description: ${template.description}`);
  }
 
  return result.resourceTemplates;
}

For example, a GitHub MCP server might expose a template like github://repos/{owner}/{repo}/issues/{number}. Your client fills in the parameters to read specific resources.


Step 6: Working with Prompts

Prompts are reusable message templates that servers define for common tasks. Create src/prompts.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
export async function getPrompt(
  client: Client,
  promptName: string,
  args: Record<string, string> = {}
) {
  const result = await client.getPrompt({
    name: promptName,
    arguments: args,
  });
 
  console.log(`Prompt: ${result.description}`);
 
  for (const message of result.messages) {
    console.log(`[${message.role}]:`);
    if (message.content.type === "text") {
      console.log(message.content.text);
    }
  }
 
  return result;
}

Prompts are useful for getting pre-built instructions from a server. For example, a database MCP server might offer a write_query prompt that includes the database schema and SQL best practices.


Step 7: Building an Interactive CLI

Now let us combine everything into an interactive CLI. Create src/index.ts:

import * as readline from "node:readline";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createMCPClient } from "./client.js";
import { discoverCapabilities } from "./discover.js";
import { callTool, listAndDescribeTools, processToolResult } from "./tools.js";
import { readResource, listAllResources } from "./resources.js";
import { getPrompt } from "./prompts.js";
 
class MCPExplorer {
  private client: Client | null = null;
  private rl: readline.Interface;
 
  constructor() {
    this.rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
  }
 
  private prompt(question: string): Promise<string> {
    return new Promise((resolve) => {
      this.rl.question(question, resolve);
    });
  }
 
  async connect(command: string, args: string[] = []) {
    console.log(`Connecting to server: ${command} ${args.join(" ")}`);
    this.client = await createMCPClient(command, args);
    await discoverCapabilities(this.client);
    console.log("\nConnection established. Type 'help' for commands.\n");
  }
 
  async run() {
    if (!this.client) {
      const serverCmd = process.argv[2];
      const serverArgs = process.argv.slice(3);
 
      if (!serverCmd) {
        console.log("Usage: tsx src/index.ts <server-command> [args...]");
        console.log("Example: tsx src/index.ts npx -y @modelcontextprotocol/server-everything");
        process.exit(1);
      }
 
      await this.connect(serverCmd, serverArgs);
    }
 
    while (true) {
      const input = await this.prompt("mcp> ");
      const [command, ...params] = input.trim().split(" ");
 
      try {
        await this.handleCommand(command, params);
      } catch (error) {
        console.error("Error:", (error as Error).message);
      }
    }
  }
 
  private async handleCommand(command: string, params: string[]) {
    if (!this.client) return;
 
    switch (command) {
      case "help":
        this.showHelp();
        break;
 
      case "tools":
        const tools = await listAndDescribeTools(this.client);
        for (const tool of tools) {
          console.log(`\n${tool.name}`);
          console.log(`  ${tool.description}`);
          if (tool.parameters) {
            console.log(`  Parameters: ${JSON.stringify(tool.parameters, null, 2)}`);
          }
        }
        break;
 
      case "call": {
        const toolName = params[0];
        if (!toolName) {
          console.log("Usage: call <tool-name> [json-args]");
          break;
        }
        const argsStr = params.slice(1).join(" ");
        const args = argsStr ? JSON.parse(argsStr) : {};
        const result = await callTool(this.client, toolName, args);
        console.log(processToolResult(result));
        break;
      }
 
      case "resources": {
        const resources = await listAllResources(this.client);
        for (const r of resources) {
          console.log(`  ${r.uri} — ${r.name}`);
        }
        break;
      }
 
      case "read": {
        const uri = params[0];
        if (!uri) {
          console.log("Usage: read <resource-uri>");
          break;
        }
        const content = await readResource(this.client, uri);
        console.log(content);
        break;
      }
 
      case "prompts": {
        const promptsResult = await this.client.listPrompts();
        for (const p of promptsResult.prompts) {
          console.log(`  ${p.name}: ${p.description}`);
        }
        break;
      }
 
      case "prompt": {
        const promptName = params[0];
        if (!promptName) {
          console.log("Usage: prompt <prompt-name> [json-args]");
          break;
        }
        const promptArgs = params.slice(1).join(" ");
        const parsedArgs = promptArgs ? JSON.parse(promptArgs) : {};
        await getPrompt(this.client, promptName, parsedArgs);
        break;
      }
 
      case "quit":
      case "exit":
        await this.client.close();
        this.rl.close();
        process.exit(0);
 
      default:
        console.log(`Unknown command: ${command}. Type 'help' for options.`);
    }
  }
 
  private showHelp() {
    console.log(`
Available commands:
  tools              List all available tools
  call <name> [args] Call a tool with optional JSON arguments
  resources          List all available resources
  read <uri>         Read a resource by URI
  prompts            List all available prompts
  prompt <name>      Get a prompt with optional JSON arguments
  help               Show this help message
  quit               Disconnect and exit
    `);
  }
}
 
const explorer = new MCPExplorer();
explorer.run().catch(console.error);

Testing with the Everything Server

The MCP project provides a test server that exposes sample tools, resources, and prompts:

npx tsx src/index.ts npx -y @modelcontextprotocol/server-everything

You should see output like:

Connected to server: { name: "everything", version: "1.0.0" }
Server capabilities: { tools: {}, resources: {}, prompts: {} }

Found 2 tools:
  - echo: Echoes back the input
  - add: Adds two numbers

Found 2 resources:
  - file:///example.txt: Example File
  - file:///data.json: Example Data

Connection established. Type 'help' for commands.

mcp> call echo {"message": "Hello MCP!"}
Hello MCP!

mcp> call add {"a": 5, "b": 3}
8

Step 8: Connecting via SSE Transport

Some MCP servers run as standalone HTTP services and use Server-Sent Events (SSE) for communication. Create src/sse-client.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
 
export async function createSSEClient(serverUrl: string) {
  const transport = new SSEClientTransport(new URL(serverUrl));
 
  const client = new Client(
    {
      name: "my-mcp-client",
      version: "1.0.0",
    },
    {
      capabilities: {
        roots: { listChanged: true },
        sampling: {},
      },
    }
  );
 
  await client.connect(transport);
  return client;
}

When to Use SSE vs Stdio

TransportUse CaseHow It Works
StdioLocal servers, CLI toolsClient launches server as child process
SSERemote servers, shared servicesClient connects to running HTTP server

For remote MCP servers (like those running in the cloud), SSE is the right choice. For local development and personal tools, stdio is simpler.


Step 9: Building a Multi-Server Client

Real-world applications often need to connect to multiple MCP servers simultaneously. Create src/multi-client.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createMCPClient } from "./client.js";
import { createSSEClient } from "./sse-client.js";
 
interface ServerConfig {
  name: string;
  transport: "stdio" | "sse";
  command?: string;
  args?: string[];
  url?: string;
  env?: Record<string, string>;
}
 
export class MultiServerClient {
  private clients = new Map<string, Client>();
 
  async addServer(config: ServerConfig) {
    let client: Client;
 
    if (config.transport === "stdio" && config.command) {
      client = await createMCPClient(
        config.command,
        config.args ?? [],
        config.env
      );
    } else if (config.transport === "sse" && config.url) {
      client = await createSSEClient(config.url);
    } else {
      throw new Error(`Invalid server config for "${config.name}"`);
    }
 
    this.clients.set(config.name, client);
    console.log(`Connected to "${config.name}"`);
    return client;
  }
 
  async listAllTools() {
    const allTools: Array<{
      server: string;
      name: string;
      description?: string;
    }> = [];
 
    for (const [serverName, client] of this.clients) {
      const capabilities = client.getServerCapabilities();
      if (!capabilities?.tools) continue;
 
      const { tools } = await client.listTools();
      for (const tool of tools) {
        allTools.push({
          server: serverName,
          name: tool.name,
          description: tool.description,
        });
      }
    }
 
    return allTools;
  }
 
  async callTool(
    serverName: string,
    toolName: string,
    args: Record<string, unknown> = {}
  ) {
    const client = this.clients.get(serverName);
    if (!client) {
      throw new Error(`Server "${serverName}" not found`);
    }
 
    return client.callTool({ name: toolName, arguments: args });
  }
 
  async disconnectAll() {
    for (const [name, client] of this.clients) {
      await client.close();
      console.log(`Disconnected from "${name}"`);
    }
    this.clients.clear();
  }
}

Usage Example

import { MultiServerClient } from "./multi-client.js";
 
const manager = new MultiServerClient();
 
await manager.addServer({
  name: "filesystem",
  transport: "stdio",
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
});
 
await manager.addServer({
  name: "github",
  transport: "stdio",
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-github"],
  env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "" },
});
 
const allTools = await manager.listAllTools();
console.log("All available tools across servers:");
for (const tool of allTools) {
  console.log(`  [${tool.server}] ${tool.name}: ${tool.description}`);
}
 
const files = await manager.callTool("filesystem", "list_directory", {
  path: "/tmp",
});
 
await manager.disconnectAll();

Step 10: Integrating with an LLM

The real power of an MCP client comes when you connect it to an LLM. The client becomes the bridge between the AI model and external tools. Here is a simplified example using the Anthropic SDK:

import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
 
export async function runAgentLoop(
  anthropic: Anthropic,
  mcpClient: Client,
  userMessage: string
) {
  const { tools: mcpTools } = await mcpClient.listTools();
 
  const anthropicTools: Anthropic.Tool[] = mcpTools.map((tool) => ({
    name: tool.name,
    description: tool.description ?? "",
    input_schema: tool.inputSchema as Anthropic.Tool.InputSchema,
  }));
 
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ];
 
  let response = await anthropic.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 4096,
    tools: anthropicTools,
    messages,
  });
 
  while (response.stop_reason === "tool_use") {
    const toolUseBlocks = response.content.filter(
      (block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
    );
 
    const toolResults: Anthropic.ToolResultBlockParam[] = [];
 
    for (const toolUse of toolUseBlocks) {
      console.log(`Calling tool: ${toolUse.name}`);
      const result = await mcpClient.callTool({
        name: toolUse.name,
        arguments: toolUse.input as Record<string, unknown>,
      });
 
      const textContent = result.content
        .filter((c): c is { type: "text"; text: string } => c.type === "text")
        .map((c) => c.text)
        .join("\n");
 
      toolResults.push({
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: textContent,
      });
    }
 
    messages.push({ role: "assistant", content: response.content });
    messages.push({ role: "user", content: toolResults });
 
    response = await anthropic.messages.create({
      model: "claude-sonnet-4-20250514",
      max_tokens: 4096,
      tools: anthropicTools,
      messages,
    });
  }
 
  const finalText = response.content
    .filter((block): block is Anthropic.TextBlock => block.type === "text")
    .map((block) => block.text)
    .join("\n");
 
  return finalText;
}

This creates an agentic loop: the LLM receives the user message along with the available MCP tools, decides which tools to call, your client executes them, and the results go back to the LLM until it produces a final answer.


Step 11: Error Handling and Reconnection

Production MCP clients need robust error handling. Create src/resilient-client.ts:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { createMCPClient } from "./client.js";
 
export class ResilientMCPClient {
  private client: Client | null = null;
  private command: string;
  private args: string[];
  private maxRetries: number;
 
  constructor(command: string, args: string[] = [], maxRetries = 3) {
    this.command = command;
    this.args = args;
    this.maxRetries = maxRetries;
  }
 
  async connect(): Promise<Client> {
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        this.client = await createMCPClient(this.command, this.args);
        return this.client;
      } catch (error) {
        console.error(`Connection attempt ${attempt} failed:`, error);
        if (attempt === this.maxRetries) throw error;
        const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
        await new Promise((r) => setTimeout(r, delay));
      }
    }
    throw new Error("Failed to connect after all retries");
  }
 
  async callToolSafely(
    toolName: string,
    args: Record<string, unknown> = {}
  ) {
    if (!this.client) {
      await this.connect();
    }
 
    try {
      return await this.client!.callTool({ name: toolName, arguments: args });
    } catch (error) {
      console.error(`Tool call failed, reconnecting...`);
      await this.connect();
      return this.client!.callTool({ name: toolName, arguments: args });
    }
  }
 
  async disconnect() {
    if (this.client) {
      await this.client.close();
      this.client = null;
    }
  }
}

Key Error Handling Patterns

  1. Exponential backoff — wait longer between each retry attempt
  2. Automatic reconnection — if a tool call fails due to a broken connection, reconnect and retry
  3. Graceful shutdown — always close the client when done to clean up child processes

Step 12: Configuration File Support

Real MCP clients load server configurations from a file, just like Claude Desktop uses claude_desktop_config.json. Create src/config.ts:

import { readFile } from "node:fs/promises";
import { MultiServerClient } from "./multi-client.js";
 
interface MCPConfig {
  mcpServers: Record<
    string,
    {
      command: string;
      args?: string[];
      env?: Record<string, string>;
    }
  >;
}
 
export async function loadFromConfig(configPath: string) {
  const raw = await readFile(configPath, "utf-8");
  const config: MCPConfig = JSON.parse(raw);
 
  const manager = new MultiServerClient();
 
  for (const [name, server] of Object.entries(config.mcpServers)) {
    await manager.addServer({
      name,
      transport: "stdio",
      command: server.command,
      args: server.args,
      env: server.env,
    });
  }
 
  return manager;
}

Example config file (mcp-config.json):

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_TOKEN": "ghp_your_token_here"
      }
    }
  }
}

This mirrors the format Claude Desktop uses, making it easy to share server configurations between your custom client and Claude Desktop.


Testing Your Implementation

Using the MCP Inspector

The MCP project provides an inspector tool for testing:

npx @modelcontextprotocol/inspector

This launches a web-based UI where you can connect to servers, browse tools/resources/prompts, and test calls interactively.

Writing Unit Tests

import { describe, it, expect } from "vitest";
import { createMCPClient } from "./client.js";
 
describe("MCP Client", () => {
  it("should connect to the everything server", async () => {
    const client = await createMCPClient("npx", [
      "-y",
      "@modelcontextprotocol/server-everything",
    ]);
 
    const version = client.getServerVersion();
    expect(version).toBeDefined();
 
    const { tools } = await client.listTools();
    expect(tools.length).toBeGreaterThan(0);
 
    await client.close();
  });
 
  it("should call the echo tool", async () => {
    const client = await createMCPClient("npx", [
      "-y",
      "@modelcontextprotocol/server-everything",
    ]);
 
    const result = await client.callTool({
      name: "echo",
      arguments: { message: "test" },
    });
 
    expect(result.content).toBeDefined();
    await client.close();
  });
});

Troubleshooting

Common Issues

"Server process exited unexpectedly"

  • Verify the server command works standalone: npx -y @modelcontextprotocol/server-everything
  • Check that the server binary is installed and in PATH
  • Ensure Node.js 20+ is installed

"Transport error: connection refused"

  • For SSE transport, verify the server URL is correct and the server is running
  • Check for firewall rules blocking the connection port

"Tool not found"

  • Run listTools() to see available tool names
  • Tool names are case-sensitive
  • The server may have updated its tool list — re-check after reconnecting

"Invalid arguments"

  • Check the tool's inputSchema for required parameters
  • Ensure argument types match (string vs number vs boolean)
  • Use JSON.stringify() to debug the arguments you are sending

Project Structure

Here is the final project layout:

mcp-client/
├── src/
│   ├── index.ts           # CLI entry point
│   ├── client.ts          # Stdio client factory
│   ├── sse-client.ts      # SSE client factory
│   ├── multi-client.ts    # Multi-server manager
│   ├── discover.ts        # Capability discovery
│   ├── tools.ts           # Tool calling utilities
│   ├── resources.ts       # Resource reading utilities
│   ├── prompts.ts         # Prompt handling
│   ├── resilient-client.ts # Error handling wrapper
│   └── config.ts          # Config file loader
├── mcp-config.json        # Server configuration
├── package.json
└── tsconfig.json

Next Steps

Now that you have a working MCP client, consider:

  • Add more transports — the MCP SDK also supports Streamable HTTP for modern deployments
  • Build a web UI — create a Next.js frontend that connects to MCP servers through your client
  • Integrate with your AI app — use the LLM integration pattern from Step 10 in your chatbot or assistant
  • Create a plugin system — let users configure which MCP servers to connect to dynamically
  • Add logging and metrics — track tool call latency, error rates, and usage patterns
  • Explore the MCP server tutorial to build your own servers

Conclusion

You have built a fully functional MCP client in TypeScript that can:

  • Connect to any MCP server via stdio or SSE
  • Discover and call tools, read resources, and use prompts
  • Manage connections to multiple servers simultaneously
  • Handle errors with automatic reconnection
  • Integrate with LLMs for agentic tool-calling workflows
  • Load server configurations from files

The MCP ecosystem is growing rapidly, with new servers being published daily for databases, APIs, cloud services, and developer tools. Your custom client gives you full control over how your applications interact with this ecosystem — without being locked into any specific AI host application.


Want to read more tutorials? Check out our latest tutorial on How to Monitor OpenAI Usage and Costs with the Usage API and Cost API.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles