Skip to main content

05 - Building an MCP Client and HTTP Transport

Implement an MCP client: stdio vs HTTP/SSE transport, programmatic server connection, session management and custom integration.

What do you think about this article?

At a Glance

Implement an MCP client: stdio vs HTTP/SSE transport, programmatic server connection, session management and custom integration.

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:

  1. The client creates a StdioClientTransport specifying the server command and arguments
  2. The connect() method launches the child process and performs the initialize / initialized handshake
  3. The client discovers available tools with listTools()
  4. 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

  1. The client sends the user query to Claude along with the tool list (names, descriptions, JSON schemas)
  2. Claude analyzes the query and decides whether to use a tool (responds with stop_reason: "tool_use")
  3. The client invokes the tool on the MCP server with client.callTool()
  4. The result goes back to Claude as tool_result
  5. 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

  1. The client sends the first initialize request without a session ID
  2. The server generates a UUID with sessionIdGenerator and returns it in the Mcp-Session-Id header
  3. The client includes Mcp-Session-Id in all subsequent requests
  4. The server associates each request with the correct session and maintains state
  5. On disconnection, the client sends DELETE /mcp to terminate the session

StreamableHTTPClientTransport Automatically Handles:

  • Sending the Mcp-Session-Id header after initialization
  • Sending the MCP-Protocol-Version header 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 IDEs
  • MCP_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:

  1. An MCP client connects to one or more servers, discovers tools, and invokes them on behalf of a language model
  2. The tool-use cycle allows the AI to autonomously decide which tools to call, with the client acting as intermediary
  3. STDIO is ideal for local development and desktop integrations (Claude Desktop, Cursor)
  4. Streamable HTTP is necessary for remote servers, multiple connections, and production environments
  5. Session management with session IDs ensures state isolation between different clients
  6. The dual transport pattern allows supporting both modes with the same code
  7. 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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects