Model Context Protocol turned two years old as a specification in late 2025, and by mid-2026 it stopped being a nice-to-have. Anthropic open-sourced it in November 2024, and the ecosystem now counts well over 10,000 community servers on the official registry. If your product talks to LLMs, there is a decent chance a customer will ask whether it can connect to their MCP servers. That means someone on your team has to write a client.
I have built MCP clients for two internal tools and one client-facing product at this point, and the parts that eat time are never the happy path. The happy path is fifteen lines with the official SDK. The real work is handling stdio servers that die silently, validating tool arguments before you ship them across the wire, recovering from sessions that drop mid-call, and knowing when to fall back to HTTP instead of spawning a subprocess. This guide covers those four areas with production patterns, not toy examples.

The Transport Decision Comes First
MCP defines two transports in the current specification, Streamable HTTP (introduced in the 2025-06-18 spec revision, replacing the older HTTP+SSE transport) and stdio. Picking the wrong one for your deployment model causes more production pain than anything else in the build.
Use stdio when the server runs on the same machine or in the same container as your client. This is the model desktop AI applications use: the client spawns the server as a child process, talks JSON-RPC over stdin and stdout, and kills the process when the session ends. The official TypeScript SDK wraps this in StdioClientTransport, and in practice it looks like this:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
});
const client = new Client({ name: "my-app", version: "1.0.0" });
await client.connect(transport);
Use Streamable HTTP when the server lives elsewhere: a colleague’s service, a SaaS offering an MCP endpoint, or your own hosted tool server. The endpoint accepts POST requests carrying JSON-RPC messages and returns responses over a single streamed HTTP response. It also supports resumability via an optional Mcp-Session-Id header, which matters if your client makes long-running tool calls over flaky networks.
Here is how to configure a production Streamable HTTP client transport with session persistence and error boundaries:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHttpClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
interface RemoteMcpConfig {
endpoint: string;
authToken?: string;
sessionId?: string;
}
export async function createRemoteMcpClient(config: RemoteMcpConfig): Promise<{
client: Client;
sessionId: string | null;
}> {
let activeSessionId: string | null = config.sessionId ?? null;
const transport = new StreamableHttpClientTransport(
new URL(config.endpoint),
{
requestInit: {
headers: {
...(config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {}),
...(activeSessionId ? { "Mcp-Session-Id": activeSessionId } : {}),
},
},
}
);
const client = new Client(
{ name: "enterprise-agent-client", version: "2.1.0" },
{ capabilities: { tools: {}, resources: {} } }
);
await client.connect(transport);
// Capture session id from response headers if the server issued one
const responseHeaders = transport.sessionHeaders;
if (responseHeaders?.has("mcp-session-id")) {
activeSessionId = responseHeaders.get("mcp-session-id")!;
}
return { client, sessionId: activeSessionId };
}

One thing I learned the hard way: stdio servers write logs to stderr by default, and if a server accidentally writes to stdout, your JSON-RPC stream corrupts. When you spawn servers yourself, audit their output during integration testing. A server that prints a startup banner to stdout will break every client that connects to it, and the error message you get (“Parse error on line 1”) gives you no hint about the actual cause.
Tool Discovery Is a Runtime Problem
After connecting, the client calls tools/list to learn what the server offers. Unlike a compiled API client, this list changes at runtime. A filesystem server pointed at a different directory advertises the same tools, but a CRM server exposes different tools per authenticated account. Your client cannot hardcode a tool catalog.
The pattern that works: cache the tool list per session, key it by server and credentials, and refresh it when a tool call returns a “tool not found” error or when the server sends a change notification. The SDK returns tool definitions that include a name, description, and an input schema written as JSON Schema. That schema is the contract you must enforce.
When we rolled out the VePrompts 2.0 MCP server directory, we saw thousands of community servers with subtle differences in schema quality. Strict client-side runtime validation was the only thing preventing silent execution failures across disparate LLM agents.
Validate Arguments Against the Schema, Always
Here is the failure mode that convinced me schema validation is non-negotiable. An LLM called a send_email tool with recipient spelled recipent. The server (a lenient JavaScript implementation) accepted it, treated the missing field as undefined, and sent the email to an empty-string fallback address. Nothing crashed. The email just went nowhere, and we found out from a customer two days later.
Now every tool call in my clients passes through a JSON Schema validator before it leaves the process. The server should also validate, and the stricter MCP servers written in Rust or Go will reject malformed calls with proper JSON-RPC error codes. But you cannot count on every server being strict, and even strict servers benefit from catching the error before a network round trip.
The practical stack is ajv (MIT license, roughly 8 million weekly downloads on npm) or zod with a JSON Schema bridge. Given a tool definition from tools/list, you compile its inputSchema into a validator once per session with ajv.compile(tool.inputSchema) (using new Ajv({ allErrors: true, strict: false })), then check every call and throw with ajv.errorsText(validate.errors) when validation fails.
Here is a production TypeScript validator class with per-tool compilation caching and structured error formatting:
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
export class ToolSchemaValidator {
private ajv: Ajv;
private cache: Map<string, ValidateFunction> = new Map();
constructor() {
this.ajv = new Ajv({
allErrors: true,
strict: false,
coerceTypes: false,
});
}
public validateCall(
toolName: string,
schema: Record<string, unknown>,
args: unknown
): { valid: boolean; errors?: string } {
let validate = this.cache.get(toolName);
if (!validate) {
validate = this.ajv.compile(schema);
this.cache.set(toolName, validate);
}
const valid = validate(args);
if (!valid && validate.errors) {
return {
valid: false,
errors: this.ajv.errorsText(validate.errors, { separator: "; " }),
};
}
return { valid: true };
}
public invalidate(toolName?: string): void {
if (toolName) {
this.cache.delete(toolName);
} else {
this.cache.clear();
}
}
}
Two details matter. First, compile once and cache. Recompiling the same schema on every call costs 1 to 5 ms each, which compounds fast when a single user turn triggers ten tool calls. Second, use allErrors: true so you can return every problem to the model in one shot. LLMs are good at fixing argument errors when you give them the complete list of what’s wrong, and bad at iterating one error at a time across five round trips.
This is the exact principle we follow in spec-driven development for AI agents and when building RAG systems for business knowledge bases: never trust model output at the boundary of a system that takes real actions. Validation is cheap. Explaining a bad action to a customer is not.
Error Recovery Patterns That Actually Work
MCP uses JSON-RPC 2.0 error codes, and the SDK maps them to a structured error type. In production you will see four failure classes, and each wants a different response.
Transport death. The stdio process exits or the HTTP connection resets. My rule: retry once immediately (transient network blips are common), then attempt one full reconnect with a fresh initialize handshake, then surface the failure to the user. Unlimited retries on a dead server just add latency to an already failed request. Log the server’s stderr before you kill it; that is where the crash reason lives.
Tool execution errors. The server responded but the call failed, codes like -32602 (invalid params) or application-level errors in the result payload. Retry only if the error is clearly transient, such as a rate limit or a 503-equivalent. Never auto-retry validation errors; sending the same bad arguments again will fail identically and burn tokens.
Protocol negotiation failures. If your client sends protocol version 2025-06-18 and an older server only speaks 2024-11-05, the initialize handshake fails. Handle this by catching the version error and reconnecting with the server’s declared version. The TypeScript SDK exposes client.getServerCapabilities() after connect, so you can gate features (like sampling or elicitation) behind capability checks instead of trying calls blind.
Timeouts. Long-running tools exist. A database migration server or a report generator can take minutes. Set a per-tool timeout based on the tool’s declared behavior rather than a global 30-second default, and tell the model the timeout exists so it can choose a shorter alternative.
The table below summarizes what I ship in every client:
| Failure class | First response | Retry policy |
|---|---|---|
| Transport death | Immediate retry | 1 retry, then reconnect once |
| Validation error (-32602) | Return errors to model | Never auto-retry |
| Transient server error | Exponential backoff | Max 3 retries, jittered |
| Timeout | Cancel and report | Depends on tool, 30s to 10min |
| Version mismatch | Reconnect at server version | Once per session |
Here is a practical wrapper implementing per-tool timeouts with AbortController, JSON-RPC status discrimination, and exponential backoff:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
interface ExecutionOptions {
timeoutMs?: number;
maxRetries?: number;
}
export async function executeMcpToolWithRecovery(
client: Client,
toolName: string,
args: Record<string, unknown>,
options: ExecutionOptions = {}
): Promise<unknown> {
const timeoutMs = options.timeoutMs ?? 30000;
const maxRetries = options.maxRetries ?? 2;
let attempt = 0;
while (attempt <= maxRetries) {
attempt++;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const result = await client.callTool(
{ name: toolName, arguments: args },
{ signal: controller.signal }
);
clearTimeout(timeoutId);
return result;
} catch (err: unknown) {
clearTimeout(timeoutId);
// Handle explicit protocol abort
if (err instanceof Error && err.name === "AbortError") {
throw new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms`);
}
// Handle JSON-RPC protocol error codes
if (err instanceof McpError) {
if (err.code === ErrorCode.InvalidParams) {
// Hard client validation error: return immediately to LLM without retry
throw new Error(`Invalid arguments for tool ${toolName}: ${err.message}`);
}
if (err.code === ErrorCode.MethodNotFound) {
throw new Error(`Tool ${toolName} not found on connected server.`);
}
}
// Handle transient connection drop or server reset
if (attempt <= maxRetries) {
const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
continue;
}
throw err;
}
}
}

Security Boundaries Worth Enforcing
MCP servers can execute code, read files, and call external APIs with your user’s credentials. A client that auto-executes every tool an LLM requests is a remote code execution waiting to happen. Three controls I consider mandatory.
First, a tool allowlist per integration. When a user connects their filesystem server, ask which directories it may touch, and reject tool calls outside that scope client-side before the request leaves your process. Second, human confirmation for side-effecting tools. Any tool that writes, deletes, sends, or pays gets a confirmation prompt showing the validated arguments. Third, treat tool descriptions as untrusted input. Research published in early 2025 demonstrated indirect prompt injection through MCP tool descriptions, where a malicious server’s description instructs the model to exfiltrate data. Strip instructions from descriptions before presenting them to the model, or at minimum flag servers that are not user-installed.

If you are evaluating AI vendors on how they handle these failure modes, my guide to AI hallucination detection for businesses covers the adjacent problem of validating model claims, which pairs well with tool-call validation. When automating software audits and web infrastructure, tools like vComplianceScanner by Veduis demonstrate how automated deterministic engines complement AI pipelines by eliminating LLM guessing on strict regulatory checks.
What Most Guides Get Wrong
Most MCP tutorials stop at the connect-and-list happy path and imply that the SDK handles everything. It does not. Three things get skipped consistently.
Session lifecycle is your job. The SDK opens connections but does not own reconnect logic, session resumption across process restarts, or graceful shutdown that sends the protocol’s notifications/cancelled message. Clients that skip cancellation leave servers running tools for requests nobody is waiting on. I have seen a report generator burn 20 minutes of compute after the user closed the tab.
stdio is not sandboxing. Spawning npx some-package executes arbitrary code with your process’s permissions. The MCP spec defines no permission model, and the official docs are explicit that security is the host application’s responsibility. Running third-party stdio servers in a container or a separate user account is table stakes, not paranoia.
Tool lists drift. Servers update. A tool that accepted a format argument last month may have renamed it. Clients that cache tool schemas forever start generating calls the server rejects. Refresh your schema cache when you see -32602 errors or when the server signals a list change with the notifications/tools/list_changed notification, which the 2025-06-18 spec supports.
Building the Client Loop Around the Model
The final piece is wiring tool calls into your LLM loop. The standard pattern: send the tool schemas to the model as function-calling definitions, let the model emit a call, validate it, execute it against the MCP server, and feed the result back as a tool message. Repeat until the model stops calling tools. Keep the number of connected servers per session small (under five in my experience) because tool descriptions consume context window fast. Twenty tools with detailed schemas can eat 15,000 tokens before the user says a word.
For a broader view of where MCP fits in a small business AI stack, see the MCP guide for small business owners, which covers the server side and the business case without the implementation detail.
Conclusion
A production MCP client is a transport manager, a schema validator, and an error recovery engine wrapped around a fifteen-line SDK call. Start with the official TypeScript SDK, pick Streamable HTTP for remote servers and stdio for local ones, validate every tool argument against the server’s own JSON Schema before sending it, and build explicit retry policies for each failure class instead of a single global catch. Add a permission boundary for side-effecting tools, and you have a client that survives contact with real servers, real networks, and real users.



