Dev Tools·
advanced
·20 min read·Sep 27, 2026
By Rad Tome·Lead AI Systems Architect

Building Custom MCP Clients: Developer Guide

Engineering guide to building custom Model Context Protocol (MCP) clients. Learn transport lifecycles, schema mapping to OpenAI/Anthropic, and tool execution loops.

clientarchitectureTypeScriptLLM integrationfunction callingStdioClientTransportSSE
Interactive Tool
1-Click Export

Generate & Validate Multi-Client MCP Config

One-click export with environment variables & path locators for Claude Desktop, Cursor, Windsurf, and OpenAI Codex CLI.

Open in Generator

Building Custom MCP Clients: Developer Guide

Most discussions surrounding the Model Context Protocol (MCP) focus on building servers—the providers of tools, resources, and database connections. However, if you are building an AI agent runtime, an automated coding assistant, or an enterprise workflow platform, your system acts as the MCP Client.

An MCP client is responsible for spawning and connecting to servers, negotiating capabilities, translating MCP tool definitions into LLM function-calling formats (such as OpenAI, Anthropic, or Gemini), dispatching tool executions, and maintaining workspace root boundaries.

This engineering guide walks through constructing a production-ready, type-safe custom MCP client from scratch using the official @modelcontextprotocol/sdk in TypeScript.


1. High-Level MCP Client Architecture

In the MCP specification, the application ecosystem is partitioned into three distinct entities:

text
┌────────────────────────────────────────────────────────┐
│                      Host Application                  │
│       (e.g., Your Custom Agent CLI, IDE, or SaaS)      │
│                                                        │
│  ┌────────────────────┐      ┌──────────────────────┐  │
│  │     MCP Client     │◄────►│   LLM Engine (API)   │  │
│  │   (Transport /     │      │ (OpenAI / Anthropic) │  │
│  │  Protocol Manager) │      └──────────────────────┘  │
│  └─────────┬──────────┘                                │
└────────────┼───────────────────────────────────────────┘
             │ JSON-RPC 2.0 (stdio or SSE)
             ▼
┌────────────────────────────────────────────────────────┐
│                       MCP Server                       │
│      (Filesystem, GitHub, PostgreSQL, Custom Tools)     │
└────────────────────────────────────────────────────────┘
  1. ▸The Host: The outer application managing the user session, permissions, and conversation context.
  2. ▸The Client: The protocol engine that connects 1-to-1 with an MCP server, handles lifecycle handshakes, and executes JSON-RPC requests.
  3. ▸The Server: The downstream process providing tools, resources, and contextual prompts.

2. Project Setup and Dependencies

Create a new TypeScript project and install the protocol SDK alongside an LLM provider library (we will use @anthropic-ai/sdk and zod):

bash
mkdir mcp-custom-client
cd mcp-custom-client
npm init -y
npm install @modelcontextprotocol/sdk @anthropic-ai/sdk zod
npm install -D typescript @types/node tsx

Configure tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  }
}

3. Initializing the Client and Transport Handshake

To connect to an MCP server running on your local machine, use StdioClientTransport.

Transport Lifecycle

The client-server connection follows a strict protocol handshake:

  1. ▸Transport Connection: The client launches the server process as a child process via child_process.spawn.
  2. ▸initialize Request: Client sends its name, version, and declared capabilities (such as roots or sampling).
  3. ▸initialize Response: Server replies with its server info, protocol version, and server capabilities (tools, resources, prompts).
  4. ▸notifications/initialized: Client sends an acknowledgment confirming that initialization is complete.

Here is the implementation:

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

export async function createMcpClient(
  serverName: string,
  command: string,
  args: string[] = [],
  env: Record<string, string> = {}
) {
  // 1. Configure the transport layer
  const transport = new StdioClientTransport({
    command,
    args,
    env: {
      ...process.env,
      ...env,
    },
  });

  // 2. Initialize the client with capabilities
  const client = new Client(
    {
      name: "CustomAgentClient",
      version: "1.0.0",
    },
    {
      capabilities: {
        // Declare client support for dynamic roots and sampling
        roots: {
          listChanged: true,
        },
        sampling: {},
      },
    }
  );

  // 3. Connect transport and execute protocol handshake
  await client.connect(transport);
  console.error(`[Client] Connected to ${serverName} successfully.`);

  return client;
}

4. Discovering Tools and Mapping to LLM Schemas

Once connected, your client queries the server for its available tools via client.listTools().

To pass these tools to an LLM like Claude, you must translate MCP tool schemas into Anthropic's tool definition format:

typescript
import Anthropic from "@anthropic-ai/sdk";

export async function getToolsForAnthropic(client: Client): Promise<Anthropic.Tool[]> {
  const result = await client.listTools();

  return result.tools.map((tool) => ({
    name: tool.name,
    description: tool.description || "",
    input_schema: {
      type: "object",
      properties: tool.inputSchema.properties || {},
      required: tool.inputSchema.required || [],
    },
  }));
}

Mapping for OpenAI Function Calling

If you are integrating with OpenAI models (gpt-4o, o3-mini), convert the schema to the OpenAI function tool format:

typescript
export async function getToolsForOpenAI(client: Client) {
  const result = await client.listTools();

  return result.tools.map((tool) => ({
    type: "function" as const,
    function: {
      name: tool.name,
      description: tool.description || "",
      parameters: tool.inputSchema,
    },
  }));
}

5. The Autonomous Tool Execution Loop

The core responsibility of an MCP client is managing the execution cycle between the LLM and the server:

  1. ▸Send conversation history + tool definitions to the LLM.
  2. ▸If the LLM generates normal text, display it to the user.
  3. ▸If the LLM generates one or more tool_use blocks:
    • ▸Call the corresponding MCP tool via client.callTool().
    • ▸Parse the tool output (content blocks).
    • ▸Format output into a tool_result block.
    • ▸Append to message history and repeat the LLM invocation.

Here is the full agentic loop implementation:

typescript
import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

export async function runAgentLoop(
  client: Client,
  userPrompt: string,
  maxIterations = 10
) {
  const tools = await getToolsForAnthropic(client);
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userPrompt },
  ];

  console.log(`\nUser: ${userPrompt}\n`);

  for (let iteration = 0; iteration < maxIterations; iteration++) {
    const response = await anthropic.messages.create({
      model: "claude-3-7-sonnet-20250219",
      max_tokens: 4096,
      tools,
      messages,
    });

    // Check if the model concluded with text
    const textBlocks = response.content.filter((b) => b.type === "text");
    for (const text of textBlocks) {
      console.log(`Agent: ${text.text}`);
    }

    // Stop if model is not calling any tools
    if (response.stop_reason !== "tool_use") {
      break;
    }

    // Append assistant response containing the tool call to history
    messages.push({ role: "assistant", content: response.content });

    // Handle tool invocations
    const toolBlocks = response.content.filter((b) => b.type === "tool_use");
    const toolResults: Anthropic.ToolResultBlockParam[] = [];

    for (const toolUse of toolBlocks) {
      console.log(`[Tool Call] Executing: ${toolUse.name}`);
      console.log(`[Tool Call] Arguments:`, JSON.stringify(toolUse.input, null, 2));

      try {
        // Execute the tool call against the MCP server
        const callResult = await client.callTool({
          name: toolUse.name,
          arguments: toolUse.input as Record<string, unknown>,
        });

        // Format MCP content items into string output for the LLM
        const outputText = callResult.content
          .map((item) => {
            if (item.type === "text") return item.text;
            if (item.type === "image") return `[Image: ${item.mimeType}]`;
            if (item.type === "resource") return `[Resource: ${item.resource.uri}]`;
            return "";
          })
          .join("\n");

        toolResults.push({
          type: "tool_result",
          tool_use_id: toolUse.id,
          content: outputText,
          is_error: callResult.isError || false,
        });
      } catch (err: any) {
        console.error(`[Tool Error] Failed: ${err.message}`);
        toolResults.push({
          type: "tool_result",
          tool_use_id: toolUse.id,
          content: `Error executing tool: ${err.message}`,
          is_error: true,
        });
      }
    }

    // Feed tool results back into the conversation context
    messages.push({
      role: "user",
      content: toolResults,
    });
  }
}

6. Managing Dynamic Workspace Roots

If your client interacts with filesystem servers (like @modelcontextprotocol/server-filesystem), you should declare and manage Roots. Roots define which directories the server is authorized to access.

Setting Up Root Handlers

Register a root list handler on the client:

typescript
let activeWorkspaceDir = process.cwd();

client.setRequestHandler(ListRootsRequestSchema, async () => {
  return {
    roots: [
      {
        uri: `file://${activeWorkspaceDir}`,
        name: "Current Project Workspace",
      },
    ],
  };
});

Notifying the Server of Changes

When the user switches project folders or adds a new workspace root, dispatch a notification so the server updates its internal security sandbox:

typescript
export async function updateWorkspaceRoot(client: Client, newPath: string) {
  activeWorkspaceDir = newPath;
  await client.sendNotification({
    method: "notifications/roots/list_changed",
  });
  console.log(`[Client] Workspace root updated to: ${newPath}`);
}

7. Connecting to Remote MCP Servers via SSE

While local development utilizes StdioClientTransport, enterprise microservices and multi-agent systems often run remote MCP servers hosted behind HTTPS and Server-Sent Events (SSE).

Connecting to an SSE-based MCP server:

typescript
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";

export async function createRemoteMcpClient(endpointUrl: string, authToken: string) {
  const transport = new SSEClientTransport(new URL(endpointUrl), {
    headers: {
      Authorization: `Bearer ${authToken}`,
      "X-Client-Version": "1.0.0",
    },
  });

  const client = new Client(
    { name: "RemoteMcpClient", version: "1.0.0" },
    { capabilities: { tools: {}, roots: {} } }
  );

  await client.connect(transport);
  console.log(`Connected to remote SSE MCP server at ${endpointUrl}`);
  return client;
}

8. Complete Working Example

Here is a self-contained runner (main.ts) that launches a local filesystem server, asks Claude to inspect files, and prints the output:

typescript
// main.ts
import { createMcpClient } from "./client.js";
import { runAgentLoop } from "./agent.js";
import path from "path";

async function main() {
  const allowedDir = path.resolve("./workspace");

  // Launch local filesystem MCP server
  const client = await createMcpClient(
    "FilesystemServer",
    "npx",
    ["-y", "@modelcontextprotocol/server-filesystem", allowedDir]
  );

  try {
    await runAgentLoop(
      client,
      "What files are located in the project workspace? Summarize their contents."
    );
  } finally {
    await client.close();
  }
}

main().catch(console.error);

Run with:

bash
export ANTHROPIC_API_KEY="sk-ant-..."
npx tsx main.ts

Key Engineering Takeaways

Building an MCP client empowers you to decouple your agent's core application logic from external tool maintenance.

  1. ▸Protocol Decoupling: Build against the standard MCP interface instead of writing custom API adapters for every database, CLI, or cloud service.
  2. ▸Schema Translation: Translate MCP tool schemas dynamically to match your chosen LLM provider (OpenAI, Anthropic, Gemini).
  3. ▸Graceful Degradation: Always intercept and feed is_error states back to the LLM so the model can self-correct without user intervention.
  4. ▸Resource and Root Scoping: Enforce strict boundary controls by managing workspace roots directly in client negotiation.
Ready to Deploy?

Build your full agent toolstack in the Visual Generator

Combine Building Custom MCP Clients: Developer Guide with databases, search APIs, and memory graphs in a single configuration file.

Customize in Generator
Developer Verification & Feedback

Did this setup guide work with your AI host?

Real-time developer votes ensure configurations stay current across client updates.

Building Custom MCP Clients: Developer Guide FAQ

What is the Building Custom MCP Clients: Developer Guide?

Engineering guide to building custom Model Context Protocol (MCP) clients. Learn transport lifecycles, schema mapping to OpenAI/Anthropic, and tool execution loops.

How do I configure Building Custom MCP Clients: Developer Guide in Claude Desktop or Cursor?

You can copy the configuration JSON from our guide or launch the interactive MCP Codex Config Generator at https://mcp-codex.com/generator to export valid configs in 1 click.

Can I use Building Custom MCP Clients: Developer Guide with the OpenAI Codex CLI?

Yes, OpenAI Codex CLI supports Model Context Protocol. You can add it directly to ~/.codex/config.toml or pass arguments to codex mcp add.

RT

Written by Rad Tome

Lead AI Systems Architect & Founder, MCP Codex

@RadTome

Specializing in Model Context Protocol (MCP) integrations, autonomous AI agent orchestration, and distributed developer toolchains. Researches and benchmarks production MCP client-server architectures across OpenAI Codex, Claude, and Cursor.

Editorial Integrity: All configurations, schemas, and commands verified against live GitHub repositories and tested in local sandbox runtimes.

Related Guides