MCP (Model Context Protocol) lets AI models interact with external tools and data. The servers get most of the attention, but understanding the client side is just as important. Here's how to build one.

What We're Building

A simple TypeScript client that:

  1. Connects to an MCP server
  2. Lists available tools
  3. Calls a tool and handles the response

Nothing fancy—just the core pattern you'll use everywhere.

Setup

npm init -y
npm install @modelcontextprotocol/sdk

The SDK handles the JSON-RPC protocol, transport negotiation, and message framing. You focus on what to do with the tools.

The Basic Client

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
 
async function main() {
  // Create transport to spawn and communicate with server
  const transport = new StdioClientTransport({
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-memory"],
  });
 
  // Create client and connect
  const client = new Client({
    name: "my-client",
    version: "1.0.0",
  });
 
  await client.connect(transport);
  console.log("Connected to server");
 
  // List available tools
  const tools = await client.listTools();
  console.log("Available tools:", tools.tools.map(t => t.name));
 
  // Clean up
  await client.close();
}
 
main().catch(console.error);

Run it: npx tsx client.ts

Calling Tools

Once connected, calling tools is straightforward:

const result = await client.callTool({
  name: "store_memory",
  arguments: {
    key: "greeting",
    value: "Hello from my first MCP client!"
  }
});
 
console.log("Result:", result.content);

The response comes back as structured content—usually text, but can include images or other data types.

Error Handling

MCP uses JSON-RPC error codes. Handle them explicitly:

try {
  const result = await client.callTool({
    name: "nonexistent_tool",
    arguments: {}
  });
} catch (error) {
  if (error.code === -32601) {
    console.log("Tool not found");
  } else {
    throw error;
  }
}

Common codes:

  • -32601: Method not found (tool doesn't exist)
  • -32602: Invalid params (wrong arguments)
  • -32603: Internal error (server-side failure)

HTTP Transport

For remote servers, use StreamableHTTP instead of stdio:

import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
 
const transport = new StreamableHTTPClientTransport(
  new URL("http://localhost:3000/mcp")
);
 
const client = new Client({ name: "my-client", version: "1.0.0" });
await client.connect(transport);

Same client API, different transport. That's the point of the abstraction.

What I Learned

Building MCP clients taught me a few things:

  1. Transports are pluggable. The same client code works with stdio, HTTP, or custom transports. Design around this.

  2. Tools are typed loosely. The arguments field is a JSON object. Validate before calling if you need guarantees.

  3. Sessions matter for HTTP. The server assigns a session ID. If you lose it (server restart, etc.), you need to reconnect.

  4. Close your connections. Dangling connections cause resource leaks. Always call client.close() when done.

Next Steps

Once you have a basic client working:

  • Add tool discovery at startup
  • Implement retry logic for transient failures
  • Consider connection pooling for high-throughput use cases

The MCP spec has more features (resources, prompts, sampling), but tools are the core. Get this pattern solid first.

React to this post: