I've always been curious what Claude Desktop does internally when it connects to an MCP server. "It connects via stdio" is the short answer, but I needed to see the code to actually understand it. So today I installed @modelcontextprotocol/sdk, built a TypeScript MCP client from scratch, and ran it against a server I wrote myself.
The verdict: it's a lot simpler than I expected. There was also one behavior that surprised me — error handling works differently from what I assumed.
The idea: do what Claude Desktop does, manually
MCP (Model Context Protocol) is the standard interface for AI agents to access external tools and data. I've written a lot about building MCP servers — including how to build one in TypeScript and spinning one up with Python FastMCP in 30 minutes. But I've never written about implementing the client side myself.
Thinking about production use cases, there are clear situations where a custom MCP client makes sense:
- Calling MCP server tools automatically from a CI/CD pipeline
- Integrating an MCP server into a custom agent layer you're building yourself
- Using MCP server capabilities as a library inside existing Python or TypeScript code
You don't need Claude Desktop or Claude Code. The @modelcontextprotocol/sdk package contains everything needed to build a client.
Installing the SDK — two classes are all you need
npm install @modelcontextprotocol/sdk zod
The installed version is 1.29.0 as of today. The SDK includes both server and client implementations.
There are two core classes for building an MCP client.
Client — Manages the logical connection to a server. Provides methods like listTools(), callTool(), listResources(), and readResource().
StdioClientTransport — The transport layer for communicating with stdio-based MCP servers. It spawns the server process directly using command and args.
To connect to remote MCP servers (HTTP/SSE), you'd use a different Transport class. This guide covers stdio only.
Setting up the demo — server and client, both from scratch
I built a simple MCP server for the demo. It has two tools: calculate (basic arithmetic) and transform_text (string transformations).
// server.mjs
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "demo-tools", version: "1.0.0" });
server.tool(
"calculate",
"Basic arithmetic: add, subtract, multiply, divide",
{
operation: z.enum(["add", "subtract", "multiply", "divide"]),
a: z.number(),
b: z.number(),
},
async ({ operation, a, b }) => {
const ops = {
add: a + b,
subtract: a - b,
multiply: a * b,
divide: b === 0 ? null : a / b,
};
const result = ops[operation];
return {
content: [{
type: "text",
text: result === null
? "Error: division by zero"
: `${a} ${operation} ${b} = ${result}`,
}],
};
}
);
server.tool(
"transform_text",
"Text transformation: uppercase, lowercase, reverse, word_count",
{
text: z.string(),
op: z.enum(["uppercase", "lowercase", "reverse", "word_count"]),
},
async ({ text, op }) => {
const results = {
uppercase: text.toUpperCase(),
lowercase: text.toLowerCase(),
reverse: [...text].reverse().join(""),
word_count: `Word count: ${text.trim().split(/\s+/).length}`,
};
return { content: [{ type: "text", text: results[op] }] };
}
);
server.resource(
"server-info",
"mcp://demo/info",
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "text/plain",
text: "MCP demo server v1.0.0 — stdio transport",
}],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
There's no need to run the server separately. The client's StdioClientTransport spawns the server process automatically.
Client implementation — listTools, callTool, listResources
// client.mjs
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "node",
args: ["server.mjs"],
cwd: process.cwd(),
});
const client = new Client(
{ name: "demo-client", version: "1.0.0" },
{ capabilities: {} }
);
await client.connect(transport);
The moment client.connect(transport) is called, node server.mjs is spawned as a subprocess. The client and server then exchange JSON-RPC 2.0 messages over stdin/stdout pipes.
Once connected, I ran three operations in sequence.
1. List available tools
const { tools } = await client.listTools();
for (const t of tools) {
const params = Object.keys(t.inputSchema?.properties ?? {}).join(", ");
console.log(` • ${t.name}(${params}) — ${t.description}`);
}
2. Call a tool
const result = await client.callTool({
name: "calculate",
arguments: { operation: "multiply", a: 42, b: 7 },
});
console.log(result.content[0].text);
3. List and read resources
const { resources } = await client.listResources();
const info = await client.readResource({ uri: "mcp://demo/info" });
console.log(info.contents[0].text);
Here's the actual output from the sandbox run:
=== MCP Client Demo — @modelcontextprotocol/sdk v1.29.0 ===
✓ Connected to MCP server
Found 2 tool(s):
• calculate(operation, a, b) — Basic arithmetic: add, subtract, multiply, divide
• transform_text(text, op) — Text transformation: uppercase, lowercase, reverse, word_count
--- calculate tool ---
42 multiply 7 = 294
100 divide 4 = 25
999 add 1 = 1000
--- transform_text tool ---
"Model Context Protocol" → MODEL CONTEXT PROTOCOL
"BUILD ONCE RUN EVERYWHERE" → build once run everywhere
"hello world from MCP" → Word count: 4
Found 1 resource(s): mcp://demo/info
Content: MCP demo server v1.0.0 — stdio transport
✓ Client closed cleanly.
The server was never started separately. The client spawned node server.mjs, communicated with it, and terminated both processes cleanly when client.close() was called.
Errors come back as isError, not as exceptions
This is the behavior that surprised me. When you call a tool that doesn't exist, callTool() doesn't throw — it returns a response object with isError: true.
const result = await client.callTool({
name: "nonexistent_tool",
arguments: {},
});
console.log(result);
// {
// content: [{ type: "text", text: "MCP error -32602: Tool nonexistent_tool not found" }],
// isError: true
// }
No need for try/catch around tool calls. Instead, check result.isError in every tool call handler.
async function callToolSafe(client, name, args) {
const result = await client.callTool({ name, arguments: args });
if (result.isError) {
throw new Error(result.content[0]?.text ?? "Unknown MCP error");
}
return result.content[0]?.text;
}
This is intentional per the MCP spec. Tool execution errors and protocol errors are kept separate: protocol-level errors might throw, but tool-level errors come back in the content. Once I understood this, it made sense — it matches how Claude agents receive tool output. Even when a tool fails, the LLM gets the error text as part of the context and can reason about it.
Parallel callTool — 4 calls in 1ms
I also tested parallel calls with Promise.all.
const start = Date.now();
const ops = [
["add", 1, 1],
["multiply", 12, 12],
["subtract", 100, 37],
["divide", 144, 12],
];
const results = await Promise.all(
ops.map(([op, a, b]) =>
client.callTool({ name: "calculate", arguments: { operation: op, a, b } })
)
);
console.log(`${ops.length} parallel calls completed in ${Date.now() - start}ms`);
Output:
Parallel calls (4 ops) in 1ms:
1 add 1 = 2
12 multiply 12 = 144
100 subtract 37 = 63
144 divide 12 = 12
Even over stdio, the SDK handles request multiplexing internally. Four concurrent calls go out and get matched to their responses correctly. Keep in mind that with stdio the server still processes them one at a time — if your tools are CPU-heavy, parallel calling has limited upside.
Three real-world situations where a custom client is useful
Implementing this clarified where a custom client actually makes sense.
Automation scripts calling MCP tools
If your MCP server exposes code linting, file conversion, or external API lookups, you can invoke those tools from GitHub Actions or a local shell script — just a small Node.js program running node client.mjs. No GUI required.
Building a custom agent framework
If you're writing your own agent loop without LangGraph or LlamaIndex, a custom MCP client slots in as the tool execution layer. Pull the tool list with listTools(), inject it into your LLM prompt, parse the model's tool call decision, and run it with callTool(). The MCP Gateway post is a natural follow-up if you need to route traffic across multiple servers.
Testing and debugging MCP servers
During MCP server development, a custom client lets you verify tool behavior quickly without Claude Desktop. Call listTools() to check the generated inputSchema, then fire callTool() with various parameter combinations.
Connecting to an existing public MCP server
So far I've only connected to my own server. The same client works with any public MCP server package.
npm install @modelcontextprotocol/server-filesystem
Then update the transport:
const transport = new StdioClientTransport({
command: "npx",
args: [
"-y",
"@modelcontextprotocol/server-filesystem",
"/path/to/allowed/directory",
],
});
With this you can call read_file, write_file, and list_directory tools via callTool(). The command/args in Claude Desktop's config file maps directly to what StdioClientTransport accepts — copy and paste and it works.
If you need multiple servers, create a separate Client instance per server. Client-to-server is a 1:1 relationship.
Handling content types safely
One thing to be careful about: callTool() and readResource() return a content array where each item's structure depends on its type field.
for (const item of result.content) {
if (item.type === "text") {
console.log(item.text);
} else if (item.type === "image") {
console.log(`Image: ${item.mimeType}`);
} else if (item.type === "resource") {
console.log(`Resource: ${item.resource.uri}`);
}
}
If you blindly access content[0].text without checking type, you'll get undefined when the tool returns an image or embedded resource. With third-party MCP servers, always check the type field first.
The SDK's .d.ts files define TextContent, ImageContent, and EmbeddedResource as separate types. In TypeScript, importing and using these for explicit type narrowing is cleaner than relying on runtime checks alone.
Honest limitations
A few friction points I ran into:
Thin TypeScript generics. The return type of callTool() is { content: Content[], isError?: boolean } — Content being a union type. Narrowing it to TextContent requires explicit checks. Not a blocker, but not ergonomic.
SSE/HTTP needs a different transport. For remote MCP servers (HTTP-based), you'll need StreamableHTTPClientTransport or SSEClientTransport. The setup differs slightly and isn't covered in as many examples.
Process lifecycle management. StdioClientTransport doesn't spawn a new process per call — the server process stays alive for the duration of the connection. Always call client.close() at the end of a script, or use process.on('exit', ...) to clean up, otherwise the server process lingers.
zod v4 compatibility warnings. The SDK uses zod internally, and mixing it with zod v4 in your project may produce deprecation warnings. With SDK 1.29.0 and zod 4.4.3 everything ran fine for me, but it's worth watching.
My take: the client side is underrepresented
There's a lot of content about building MCP servers. Custom client implementations — doing what Claude Desktop does programmatically — are far less documented.
The use cases are real. Developers building AI agent pipelines from scratch, teams integrating MCP into existing code, engineers debugging server behavior without a GUI. The Client class in @modelcontextprotocol/sdk is stable and the API is clean.
Seventy lines of TypeScript is enough to connect to an MCP server, list its tools, call them, read its resources, and close cleanly. I wish this had been in the official docs as a worked example from the start. Since it wasn't, here it is.
Next up: attaching this client to a real-world MCP server — probably the filesystem one or the GitHub MCP server — and building a small automation script around it.
Top comments (0)