Introduction: From Server to MCP Client
In previous articles we built MCP servers that expose tools, resources, and prompts. But who consumes these capabilities? The MCP Client is the software component that connects to one or more servers, discovers available tools, and invokes them on behalf of a language model.
In this article we will explore how to build a programmatic MCP client in TypeScript, compare the two main transport mechanisms (STDIO and Streamable HTTP), analyze session management, and see how to integrate an MCP server into an Express web application. All code is available in the Tech-MCP repository.
What You Will Learn in This Article
- How to create an MCP client with the official TypeScript SDK
- The tool-use cycle: query, AI decision, invocation, response
- STDIO transport: local connection via child process
- Streamable HTTP transport: independent server accessible over the network
- Session management with session IDs and reconnection
- Multi-server pattern and InMemoryTransport for testing
- HTTP transport security: Origin validation, Bearer authentication
Client Project Setup
To create an MCP client from scratch, we start with a TypeScript project setup. The
@modelcontextprotocol/sdk package provides everything needed for the client side,
while @anthropic-ai/sdk allows integrating Claude as the decision-making model.
mkdir mcp-client && cd mcp-client
npm init -y
npm install @modelcontextprotocol/sdk @anthropic-ai/sdk dotenv
npm install -D typescript @types/node
Configure tsconfig.json as in the server project and add scripts to package.json:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Create the .env file with your Anthropic API key:
ANTHROPIC_API_KEY=sk-ant-...
MCP Client with STDIO Transport
The STDIO transport is the simplest mechanism for connecting a client to an MCP server.
The client launches the server as a child process and communicates via standard input/output.
Each message is a JSON-RPC object separated by newline, while stderr remains available
for logging.
STDIO Client Architecture
The flow of a STDIO connection follows these steps:
- The client creates a
StdioClientTransportspecifying the server command and arguments - The
connect()method launches the child process and performs theinitialize/initializedhandshake - The client discovers available tools with
listTools() - For each user query, the client invokes Claude with the tool list and manages the tool-use cycle
Complete STDIO Client Implementation
Here is the complete code for an interactive CLI client that uses Claude as the decision-making model:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import Anthropic from "@anthropic-ai/sdk";
import { config } from "dotenv";
import * as readline from "node:readline";
config(); // Load variables from .env
// Type for Anthropic API compatible tools
interface AnthropicTool {
name: string;
description: string | undefined;
input_schema: Record<string, unknown>;
}
class McpChatClient {
private client: Client;
private anthropic: Anthropic;
private tools: AnthropicTool[] = [];
constructor() {
this.client = new Client({
name: "mcp-chat-client",
version: "1.0.0",
});
this.anthropic = new Anthropic();
}
/**
* Connect to MCP server via STDIO.
* The client launches the server as a child process.
*/
async connect(serverCommand: string, serverArgs: string[]): Promise<void> {
const transport = new StdioClientTransport({
command: serverCommand,
args: serverArgs,
});
// connect() performs the lifecycle: initialize -> initialized
await this.client.connect(transport);
// Discover available tools
const result = await this.client.listTools();
this.tools = result.tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema as Record<string, unknown>,
}));
console.error(
`Connected. Available tools: ${this.tools.map((t) => t.name).join(", ")}`
);
}
/**
* Process a user query with Claude's tool-use cycle.
*/
async chat(userMessage: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
let response = await this.anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
messages,
tools: this.tools as Anthropic.Tool[],
});
// Tool-use cycle: Claude may request multiple tools in sequence
while (response.stop_reason === "tool_use") {
const assistantContent = response.content;
messages.push({ role: "assistant", content: assistantContent });
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of assistantContent) {
if (block.type === "tool_use") {
console.error(` -> Invoking: ${block.name}`);
// Call the tool on the MCP server
const result = await this.client.callTool({
name: block.name,
arguments: block.input as Record<string, unknown>,
});
const text = (result.content as Array<{ type: string; text: string }>)
.map((c) => c.text)
.join("\n");
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: text,
});
}
}
messages.push({ role: "user", content: toolResults });
response = await this.anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
messages,
tools: this.tools as Anthropic.Tool[],
});
}
return response.content
.filter((block): block is Anthropic.TextBlock => block.type === "text")
.map((block) => block.text)
.join("\n");
}
async disconnect(): Promise<void> {
await this.client.close();
}
}
The Tool-Use Flow in Detail
The heart of the MCP client is the tool-use cycle, an iterative mechanism that allows the AI to autonomously decide which tools to invoke to answer a query. Here is the complete flow:
User Client Claude API MCP Server
| | | |
| "Add | | |
| note X" | | |
| ────────────> | | |
| | ── messages ─────> | |
| | + tools list | |
| | | |
| | <── tool_use ──── | |
| | "add-note" | |
| | | |
| | ── callTool ────────────────────────> |
| | "add-note" | |
| | <── result ───────────────────────── |
| | | |
| | ── tool_result ──> | |
| | | |
| | <── text ──────── | |
| | "Note saved" | |
| <──────────── | | |
| "Note saved" | | |
Key Steps of the Cycle
- The client sends the user query to Claude along with the tool list (names, descriptions, JSON schemas)
- Claude analyzes the query and decides whether to use a tool (responds with
stop_reason: "tool_use") - The client invokes the tool on the MCP server with
client.callTool() - The result goes back to Claude as
tool_result - Claude may request additional tools or generate the final response (
stop_reason: "end_turn")
STDIO vs Streamable HTTP Transport
The MCP SDK supports two fundamentally different transport mechanisms. The choice between them depends on the use case, system architecture, and deployment requirements.
STDIO vs Streamable HTTP Comparison
| Aspect | STDIO | Streamable HTTP |
|---|---|---|
| Architecture | Client launches server as child process | Server is an independent service |
| Connections | 1:1 (one client per process) | N:1 (many clients, one server) |
| Deployment | Local only, same host | Local or remote, any network |
| Sessions | Implicit (process lifetime) | Explicit (session ID in header) |
| Authentication | Not needed (same host) | Required (Bearer token, OAuth) |
| Scalability | Limited (one process per client) | High (one server, many clients) |
| Use case | Claude Desktop, IDEs, local development | Microservices, shared APIs, production |
| Setup complexity | Minimal | Requires HTTP server (Express, Fastify) |
In general, STDIO is the best choice for local development and integration with desktop applications like Claude Desktop and Cursor. Streamable HTTP is necessary when the server needs to be accessible from multiple clients, remote hosts, or in containerized production environments.
MCP Server with HTTP Transport (Express)
To expose an MCP server via HTTP, the SDK provides StreamableHTTPServerTransport which
integrates natively with Express. The server becomes an independent service that accepts connections
from any compatible client.
Dependency Setup
npm install express
npm install -D @types/express
HTTP Server Implementation
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport }
from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { randomUUID } from "node:crypto";
import { z } from "zod";
// Create the MCP server (same API as STDIO)
const server = new McpServer({
name: "notes-http-server",
version: "1.0.0",
});
// Register tools
const notes: Map<string, string> = new Map();
server.tool(
"add-note",
"Adds a new note",
{
title: z.string(),
content: z.string(),
},
async ({ title, content }) => {
notes.set(title, content);
return {
content: [{ type: "text", text: `Note "${title}" saved.` }],
};
},
);
// --- HTTP Transport with Express ---
const app = express();
app.use(express.json());
// Create transport with session generator
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
// Connect MCP server to transport
await server.connect(transport);
// MCP endpoint: POST (client -> server messages)
app.post("/mcp", async (req, res) => {
await transport.handleRequest(req, res, req.body);
});
// MCP endpoint: GET (SSE stream server -> client)
app.get("/mcp", async (req, res) => {
await transport.handleRequest(req, res);
});
// MCP endpoint: DELETE (session termination)
app.delete("/mcp", async (req, res) => {
await transport.handleRequest(req, res);
});
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", server: "notes-http-server" });
});
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
console.log(`MCP HTTP Server at http://localhost:${PORT}/mcp`);
});
Endpoints and HTTP Protocol
The server exposes a single path /mcp with three HTTP methods, each with a specific role
in the MCP protocol:
- POST /mcp - The client sends JSON-RPC messages (request, notification, response). This is the main channel for tool invocation.
- GET /mcp - The client opens an SSE (Server-Sent Events) stream to receive real-time push notifications from the server.
- DELETE /mcp - The client explicitly terminates the session, freeing server-side resources.
Fundamental Difference from STDIO
With STDIO, each connection creates a new server process. With HTTP, the server connects to the transport
only once with server.connect(transport) and handles all sessions
concurrently. The sessionIdGenerator assigns a unique UUID to each connecting client.
MCP Client with HTTP Transport
To connect to an HTTP server, the SDK provides StreamableHTTPClientTransport. The client
API remains identical to STDIO: once the connection is established, listTools(),
callTool(), and close() work exactly the same way.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport }
from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({
name: "http-client",
version: "1.0.0",
});
const transport = new StreamableHTTPClientTransport(
new URL("http://localhost:3000/mcp"),
);
await client.connect(transport);
// From here on, same API as STDIO
const tools = await client.listTools();
console.log("Available tools:", tools.tools.map((t) => t.name));
const result = await client.callTool({
name: "add-note",
arguments: { title: "Test", content: "Test content" },
});
console.log("Result:", result);
await client.close();
The fundamental aspect is that the application code is identical regardless of the transport. The choice between STDIO and HTTP is an infrastructural decision, not an architectural one: the client's business logic remains unchanged.
Session Management
Session management is one of the most important aspects of the HTTP transport. While in STDIO the session is implicit (it coincides with the process lifetime), in HTTP each client connection receives a unique session ID that must be included in all subsequent requests.
How Sessions Work
- The client sends the first
initializerequest without a session ID - The server generates a UUID with
sessionIdGeneratorand returns it in theMcp-Session-Idheader - The client includes
Mcp-Session-Idin all subsequent requests - The server associates each request with the correct session and maintains state
- On disconnection, the client sends
DELETE /mcpto terminate the session
StreamableHTTPClientTransport Automatically Handles:
- Sending the
Mcp-Session-Idheader after initialization - Sending the
MCP-Protocol-Versionheader in all requests - Reconnection in case of expired session (HTTP 404)
Stateful Pattern: Per-Session State
In production, each session can have its own isolated state. Here is how to implement it:
import { randomUUID } from "node:crypto";
// Map sessions -> isolated state
const sessions = new Map<string, { notes: Map<string, string> }>();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => {
const sessionId = randomUUID();
sessions.set(sessionId, { notes: new Map() });
return sessionId;
},
});
Connecting to Multiple Servers
An MCP client can connect to multiple servers simultaneously, aggregating available tools into a single list. This pattern is essential in complex architectures where tools are distributed across specialized servers.
class MultiServerClient {
private clients: Map<string, Client> = new Map();
private allTools: AnthropicTool[] = [];
async addServer(
name: string, command: string, args: string[]
): Promise<void> {
const client = new Client({
name: `client-${name}`,
version: "1.0.0"
});
const transport = new StdioClientTransport({ command, args });
await client.connect(transport);
const result = await client.listTools();
for (const tool of result.tools) {
this.allTools.push({
// Prefix to avoid name collisions
name: `${name}__${tool.name}`,
description: `[${name}] ${tool.description}`,
input_schema: tool.inputSchema as Record<string, unknown>,
});
}
this.clients.set(name, client);
}
async callTool(
prefixedName: string, args: Record<string, unknown>
): Promise<unknown> {
const [serverName, toolName] = prefixedName.split("__");
const client = this.clients.get(serverName);
if (!client) throw new Error(`Server ${serverName} not connected`);
return client.callTool({ name: toolName, arguments: args });
}
}
The serverName__toolName prefix is essential to avoid collisions when different servers
expose tools with the same name. Claude receives the complete list and the client handles the routing.
Dual Transport: Supporting STDIO and HTTP
A professional MCP server should support both transports, deciding which one to use based on configuration. This allows using the same code both with Claude Desktop (STDIO) and in production (HTTP).
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport }
from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { randomUUID } from "node:crypto";
function createServer(): McpServer {
const server = new McpServer({ name: "my-server", version: "1.0.0" });
// ... register tools, resources, prompts ...
return server;
}
// Conditional startup based on environment variable
const mode = process.env.MCP_TRANSPORT ?? "stdio";
if (mode === "http") {
const server = createServer();
const app = express();
app.use(express.json());
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
app.post("/mcp", async (req, res) =>
await transport.handleRequest(req, res, req.body));
app.get("/mcp", async (req, res) =>
await transport.handleRequest(req, res));
app.delete("/mcp", async (req, res) =>
await transport.handleRequest(req, res));
app.get("/health", (_, res) => res.json({ status: "ok" }));
const port = process.env.PORT ?? 3000;
app.listen(port, () => console.log(`HTTP on port ${port}`));
} else {
const server = createServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Server started on STDIO");
}
Transport Configuration
MCP_TRANSPORT=stdio(default) - For Claude Desktop and local IDEsMCP_TRANSPORT=http PORT=3000- For HTTP deployment and microservices
InMemoryTransport for Testing
For unit tests, there is no need to launch processes or HTTP servers. The SDK provides
InMemoryTransport which directly connects client and server in memory:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
async function testInMemory() {
const server = new McpServer({ name: "test-server", version: "1.0.0" });
server.tool("ping", "Test ping", {}, async () => ({
content: [{ type: "text", text: "pong" }],
}));
// Create linked transport pair
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
// IMPORTANT: server connects BEFORE the client
await server.connect(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const result = await client.callTool({ name: "ping", arguments: {} });
console.log(result);
// { content: [{ type: "text", text: "pong" }] }
await client.close();
await server.close();
}
Critical Connection Order
With InMemoryTransport, server.connect() must be called
before client.connect(). The client sends the
initialize message immediately upon connect(), and the server must
already be listening to respond.
HTTP Transport Security
When the MCP server is exposed via HTTP, security becomes a critical aspect. Here are the fundamental measures to implement.
Origin Validation
To prevent DNS rebinding attacks, validate the Origin header of every request:
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowed = [
"http://localhost:3000",
"https://myapp.example.com"
];
if (origin && !allowed.includes(origin)) {
res.status(403).json({ error: "Origin not allowed" });
return;
}
next();
});
Local Binding and Bearer Authentication
For local servers, bind only to 127.0.0.1. For remote servers, implement authentication
with Bearer token:
// Local-only binding
app.listen(port, "127.0.0.1", () => {
console.log(`Server on localhost:${port} only`);
});
// Authentication for remote servers
app.use("/mcp", (req, res, next) => {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
res.status(401).json({ error: "Missing token" });
return;
}
const token = auth.slice(7);
if (!isValidToken(token)) {
res.status(403).json({ error: "Invalid token" });
return;
}
next();
});
Summary
In this article we explored in depth the client side of the MCP protocol and the two fundamental transport mechanisms. Here are the key concepts:
- An MCP client connects to one or more servers, discovers tools, and invokes them on behalf of a language model
- The tool-use cycle allows the AI to autonomously decide which tools to call, with the client acting as intermediary
- STDIO is ideal for local development and desktop integrations (Claude Desktop, Cursor)
- Streamable HTTP is necessary for remote servers, multiple connections, and production environments
- Session management with session IDs ensures state isolation between different clients
- The dual transport pattern allows supporting both modes with the same code
- InMemoryTransport simplifies testing without processes or HTTP servers
In the next article we will dive into Shared Packages in the Tech-MCP monorepo: the Core package with common utilities, the EventBus for inter-server communication, and the database access modules. We will see how to structure reusable code in a professional TypeScript monorepo.
The complete code for all examples is available in the Tech-MCP GitHub repository.
Commenti
Caricamento commenti...
Sign in to comment