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.
Generate & Validate Multi-Client MCP Config
One-click export with environment variables & path locators for Claude Desktop, Cursor, Windsurf, and OpenAI Codex CLI.
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:
┌────────────────────────────────────────────────────────┐
│ 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) │
└────────────────────────────────────────────────────────┘- ▸The Host: The outer application managing the user session, permissions, and conversation context.
- ▸The Client: The protocol engine that connects 1-to-1 with an MCP server, handles lifecycle handshakes, and executes JSON-RPC requests.
- ▸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):
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 tsxConfigure tsconfig.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:
- ▸Transport Connection: The client launches the server process as a child process via
child_process.spawn. - ▸
initializeRequest: Client sends its name, version, and declared capabilities (such asrootsorsampling). - ▸
initializeResponse: Server replies with its server info, protocol version, and server capabilities (tools,resources,prompts). - ▸
notifications/initialized: Client sends an acknowledgment confirming that initialization is complete.
Here is the implementation:
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:
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:
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:
- ▸Send conversation history + tool definitions to the LLM.
- ▸If the LLM generates normal text, display it to the user.
- ▸If the LLM generates one or more
tool_useblocks:- ▸Call the corresponding MCP tool via
client.callTool(). - ▸Parse the tool output (
contentblocks). - ▸Format output into a
tool_resultblock. - ▸Append to message history and repeat the LLM invocation.
- ▸Call the corresponding MCP tool via
Here is the full agentic loop implementation:
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:
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:
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:
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:
// 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:
export ANTHROPIC_API_KEY="sk-ant-..."
npx tsx main.tsKey Engineering Takeaways
Building an MCP client empowers you to decouple your agent's core application logic from external tool maintenance.
- ▸Protocol Decoupling: Build against the standard MCP interface instead of writing custom API adapters for every database, CLI, or cloud service.
- ▸Schema Translation: Translate MCP tool schemas dynamically to match your chosen LLM provider (OpenAI, Anthropic, Gemini).
- ▸Graceful Degradation: Always intercept and feed
is_errorstates back to the LLM so the model can self-correct without user intervention. - ▸Resource and Root Scoping: Enforce strict boundary controls by managing workspace roots directly in client negotiation.
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.
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.
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.
Related Guides
Debugging MCP Servers: A Developer Guide
Deep-dive guide to diagnosing, debugging, and resolving MCP server issues. Covers stdio stream corruption, inspector workflows, JSON-RPC errors, and client telemetry.
Dev ToolsAutonomous SWE Bench Toolchains with MCP
Architecting autonomous software engineering agents capable of solving real-world SWE-bench issues using chained Model Context Protocol tools.
Dev ToolsJEM Trajectory Evaluation for MCP Tool Calls
Implement Judged Exact Match (JEM) and Joint Step Verification to evaluate, benchmark, and regression-test multi-turn Model Context Protocol tool calling trajectories.