📦 Build and Publish a Reusable TypeScript MCP Client SDK
Wrap raw JSON-RPC behind a typed, resilient, pluggable interface — then ship it to npm so every team in your org connects to MCP servers the right way
Hi 👋, I'm Tushar Patil. Currently I am working as Frontend Developer (Angular) and also have expertise with .Net Core and Framework.
This is Part 8 of the AI Engineering with TypeScript series. Prerequisites: Part 1 · Part 2 · Part 3 · Part 4 · Part 5 · Part 6 · Part 7 Stack: Node.js 20+ · TypeScript 5.x · @modelcontextprotocol/sdk v1.x · tsup · Vitest
🗺️ What we'll cover
In every post since Part 3 we have written MCP client code — connecting to servers, calling tools, handling errors. Each time, the same boilerplate: create a transport, create a Client, call connect(), discover tools, handle isError, remember to call close().
If you have two teams using your MCP server, they both write the same boilerplate. If the MCP SDK releases a breaking change, you fix it in two places. If you want to add retry logic or logging, you copy-paste it into both projects.
The fix is obvious: extract the client into a reusable SDK package, publish it to npm, and let consumers install one dependency and call clean typed methods.
By the end you will have:
- 🏗️ A well-structured SDK package built with
tsupthat ships both ESM and CJS - 🔑 A
McpClientFactorywith a fluent builder API for connecting to stdio or HTTP servers - 🎯 A typed
callTool<I, O>()generic that validates inputs with Zod and infers output types - ♻️ Retry with exponential backoff and per-call timeouts baked into every tool call
- 🔌 A plugin / middleware system so consumers can inject logging, tracing, or caching without forking the SDK
- 🧪 A Vitest test suite that exercises the SDK against a real in-process MCP server
- 🚀 A
package.jsonwired up fornpm publishwith proper exports, types, and provenance
🏗️ Part 1: Project Setup
mkdir mcp-client-sdk && cd mcp-client-sdk
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsup vitest @vitest/coverage-v8 @types/node
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}
tsup.config.ts — this is what builds and bundles the SDK for publishing:
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
splitting: false,
});
package.json — the exports map tells Node.js (and bundlers) which file to use for ESM vs CJS:
{
"name": "@techtush/mcp-client",
"version": "1.0.0",
"description": "Typed, resilient MCP client SDK for TypeScript",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "npm run build && npm test"
}
}
🏭 Part 2: McpClientFactory — Fluent Builder API
A factory hides the transport complexity behind a clean builder. Consumers never touch StdioClientTransport or StreamableHTTPClientTransport directly:
// src/factory.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { McpClientOptions, StdioOptions, HttpOptions } from "./types.js";
import { McpClientWrapper } from "./client.js";
export class McpClientFactory {
private options: McpClientOptions = {
name: "mcp-sdk-client",
version: "1.0.0",
timeoutMs: 30_000,
retries: 3,
plugins: [],
};
named(name: string, version = "1.0.0"): this {
this.options.name = name;
this.options.version = version;
return this;
}
withTimeout(ms: number): this {
this.options.timeoutMs = ms;
return this;
}
withRetries(count: number): this {
this.options.retries = count;
return this;
}
use(plugin: McpPlugin): this {
this.options.plugins!.push(plugin);
return this;
}
async connectStdio(opts: StdioOptions): Promise<McpClientWrapper> {
const transport = new StdioClientTransport({
command: opts.command,
args: opts.args ?? [],
env: opts.env,
});
return this.connect(transport);
}
async connectHttp(opts: HttpOptions): Promise<McpClientWrapper> {
const transport = new StreamableHTTPClientTransport(
new URL(opts.url),
{
requestInit: {
headers: {
Authorization: `Bearer ${opts.token}`,
...(opts.headers ?? {}),
},
},
}
);
return this.connect(transport);
}
private async connect(transport: unknown): Promise<McpClientWrapper> {
const raw = new Client(
{ name: this.options.name!, version: this.options.version! },
{ capabilities: { sampling: {} } }
);
await (raw as Client).connect(transport as Parameters<Client["connect"]>[0]);
const { tools } = await raw.listTools();
return new McpClientWrapper(raw, tools, this.options);
}
}
export type McpPlugin = (context: PluginContext, next: () => Promise<unknown>) => Promise<unknown>;
export interface PluginContext {
toolName: string;
input: unknown;
sessionId?: string;
}
Usage looks like this — clean, no transport details exposed:
import { McpClientFactory } from "@techtush/mcp-client";
const client = await new McpClientFactory()
.named("my-agent", "2.0.0")
.withTimeout(10_000)
.withRetries(2)
.use(loggingPlugin)
.connectHttp({ url: "http://localhost:3000", token: process.env.MCP_TOKEN! });
🎯 Part 3: Typed callTool with Generics and Zod
The raw MCP SDK returns unknown from tool calls. Your consumers deserve real types. A generic callTool<I, O>() accepts a Zod schema for input validation and an output parser:
// src/client.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { McpClientOptions, McpPlugin, PluginContext } from "./factory.js";
import { withRetry } from "./retry.js";
import { withTimeout } from "./timeout.js";
export class McpClientWrapper {
constructor(
private readonly raw: Client,
private readonly tools: Tool[],
private readonly options: McpClientOptions
) {}
getTools(): Tool[] {
return this.tools;
}
async callTool<I extends z.ZodTypeAny, O>(
toolName: string,
inputSchema: I,
input: z.input<I>,
outputParser: (raw: string) => O
): Promise<O> {
// Validate input with Zod before sending
const parsed = inputSchema.parse(input) as z.output<I>;
const context: PluginContext = { toolName, input: parsed };
// Run through the plugin middleware chain
const execute = async (): Promise<unknown> => {
return this.runWithPlugins(context, async () => {
const result = await withTimeout(
withRetry(
() => this.raw.callTool({ name: toolName, arguments: parsed as Record<string, unknown> }),
this.options.retries ?? 3
),
this.options.timeoutMs ?? 30_000
);
if (result.isError) {
const msg = result.content
.filter((c) => c.type === "text")
.map((c) => (c as { type: "text"; text: string }).text)
.join("\n");
throw new McpToolError(toolName, msg);
}
const text = result.content
.filter((c) => c.type === "text")
.map((c) => (c as { type: "text"; text: string }).text)
.join("\n");
return outputParser(text);
});
};
return execute() as Promise<O>;
}
private async runWithPlugins(
context: PluginContext,
core: () => Promise<unknown>
): Promise<unknown> {
const plugins = this.options.plugins ?? [];
let index = 0;
const next = (): Promise<unknown> => {
if (index < plugins.length) {
const plugin = plugins[index++];
return plugin(context, next);
}
return core();
};
return next();
}
async close(): Promise<void> {
await this.raw.close();
}
}
export class McpToolError extends Error {
constructor(public readonly toolName: string, message: string) {
super(`Tool "\({toolName}" returned an error: \){message}`);
this.name = "McpToolError";
}
}
Now a consumer calls tools with full type safety:
import { z } from "zod";
const WeatherOutput = z.object({
city: z.string(),
temperature: z.number(),
condition: z.string(),
});
const weather = await client.callTool(
"get_current_weather",
z.object({ city: z.string(), units: z.enum(["metric", "imperial"]).optional() }),
{ city: "Pune", units: "metric" },
(raw) => WeatherOutput.parse(JSON.parse(raw))
);
// weather is fully typed: { city: string; temperature: number; condition: string }
console.log(weather.temperature); // TypeScript knows this is a number ✅
♻️ Part 4: Retry with Exponential Backoff
Network hiccups should not crash your agent. A well-designed retry wraps every tool call with configurable attempts and exponential backoff:
// src/retry.ts
export interface RetryOptions {
attempts: number;
baseDelayMs?: number;
shouldRetry?: (err: unknown) => boolean;
}
function defaultShouldRetry(err: unknown): boolean {
// Do not retry if the tool itself returned a business logic error
if (err instanceof Error && err.name === "McpToolError") return false;
// Do not retry Zod validation errors — bad input won't get better
if (err instanceof Error && err.name === "ZodError") return false;
// Retry everything else (network errors, timeouts, unexpected crashes)
return true;
}
export async function withRetry<T>(
fn: () => Promise<T>,
attempts: number,
opts: Partial<RetryOptions> = {}
): Promise<T> {
const baseDelayMs = opts.baseDelayMs ?? 200;
const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === attempts || !shouldRetry(err)) {
throw err;
}
const delay = baseDelayMs * Math.pow(2, attempt - 1);
const jitter = Math.random() * delay * 0.2;
await sleep(delay + jitter);
}
}
throw lastError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
The jitter (Math.random() * delay * 0.2) prevents thundering-herd — if ten clients all hit the same error simultaneously, they retry at slightly different times instead of all hammering the server at once. 🎯
⏱️ Part 5: Per-Call Timeout
An MCP tool call that hangs forever will stall your agent loop forever. A timeout wrapper races the tool call against a timer:
// src/timeout.ts
export class McpTimeoutError extends Error {
constructor(ms: number) {
super(`MCP tool call timed out after ${ms}ms`);
this.name = "McpTimeoutError";
}
}
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new McpTimeoutError(ms)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
McpTimeoutError is distinct from McpToolError so callers can handle them differently — retry a timeout, but surface a tool error to the user. ✅
🔌 Part 6: The Plugin System
Plugins let consumers add cross-cutting concerns — logging, tracing, caching, rate limiting — without modifying the SDK itself. The pattern is identical to Express middleware or Koa's next() chain:
// A logging plugin
const loggingPlugin: McpPlugin = async (ctx, next) => {
const start = Date.now();
console.log(`[MCP] calling ${ctx.toolName}`, ctx.input);
try {
const result = await next();
console.log(`[MCP] \({ctx.toolName} completed in \){Date.now() - start}ms`);
return result;
} catch (err) {
console.error(`[MCP] \({ctx.toolName} failed after \){Date.now() - start}ms`, err);
throw err;
}
};
// An OpenTelemetry tracing plugin
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("mcp-client-sdk");
const tracingPlugin: McpPlugin = async (ctx, next) => {
return tracer.startActiveSpan(`mcp.tool.${ctx.toolName}`, async (span) => {
span.setAttribute("mcp.tool.name", ctx.toolName);
try {
const result = await next();
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
};
// A simple in-memory caching plugin
const cache = new Map<string, { value: unknown; expiresAt: number }>();
const cachingPlugin: McpPlugin = async (ctx, next) => {
const key = `\({ctx.toolName}:\){JSON.stringify(ctx.input)}`;
const hit = cache.get(key);
if (hit && hit.expiresAt > Date.now()) {
return hit.value;
}
const result = await next();
cache.set(key, { value: result, expiresAt: Date.now() + 60_000 });
return result;
};
Wire them all up at construction time:
const client = await new McpClientFactory()
.use(loggingPlugin) // runs first — wraps everything
.use(tracingPlugin) // runs second — span encloses the cached call
.use(cachingPlugin) // runs third — cache check before hitting the server
.connectHttp({ url: "http://localhost:3000", token: "my-token" });
Plugins run in order. The cache plugin checks the cache before next() reaches the actual MCP call — so cache hits never open a trace span or emit a log line for the round trip. 🏎️
🧪 Part 7: Testing with Vitest and an In-Process Server
The best SDK tests run against a real MCP server, not mocks. The MCP SDK lets you run server and client in the same process using InMemoryTransport:
// src/__tests__/client.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
import { McpClientWrapper } from "../client.js";
import { McpToolError } from "../client.js";
let wrapper: McpClientWrapper;
let rawClient: Client;
beforeAll(async () => {
// Spin up a test MCP server in-process
const server = new McpServer({ name: "test-server", version: "0.0.1" });
server.tool(
"echo",
"Returns the input unchanged",
{ message: { type: "string" } },
async (args) => ({
content: [{ type: "text", text: args.message as string }],
})
);
server.tool(
"fail",
"Always returns an error",
{ reason: { type: "string" } },
async (args) => ({
isError: true,
content: [{ type: "text", text: args.reason as string }],
})
);
// Connect client and server via in-memory transport
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
rawClient = new Client({ name: "test-client", version: "0.0.1" }, { capabilities: {} });
await rawClient.connect(clientTransport);
const { tools } = await rawClient.listTools();
wrapper = new McpClientWrapper(rawClient, tools, {
name: "test-client",
version: "0.0.1",
timeoutMs: 5_000,
retries: 1,
plugins: [],
});
});
afterAll(async () => {
await rawClient.close();
});
describe("McpClientWrapper.callTool", () => {
it("returns typed output for a successful tool call", async () => {
const result = await wrapper.callTool(
"echo",
z.object({ message: z.string() }),
{ message: "hello Pune 🌤️" },
(raw) => raw
);
expect(result).toBe("hello Pune 🌤️");
});
it("throws McpToolError when the tool returns isError", async () => {
await expect(
wrapper.callTool(
"fail",
z.object({ reason: z.string() }),
{ reason: "something broke" },
(raw) => raw
)
).rejects.toThrow(McpToolError);
});
it("throws ZodError when input schema validation fails", async () => {
await expect(
wrapper.callTool(
"echo",
z.object({ message: z.string().min(1) }),
{ message: "" }, // violates min(1)
(raw) => raw
)
).rejects.toThrow();
});
});
Run the suite:
npm test
✓ returns typed output for a successful tool call (12ms)
✓ throws McpToolError when the tool returns isError (3ms)
✓ throws ZodError when input schema validation fails (2ms)
InMemoryTransport.createLinkedPair() is the secret weapon here — real JSON-RPC serialisation happens, real MCP protocol messages are exchanged, but no network or child processes are involved. Tests are fast, reliable, and CI-friendly. 🧪
📤 Part 8: Publishing to npm
Make sure you have an npm account and are logged in (npm login). Then:
# Verify the build output looks right
npm run build
ls dist/
# index.js index.cjs index.d.ts index.d.cts
# Do a dry run to see exactly what will be published
npm publish --dry-run
# Publish publicly (use --access public for scoped packages)
npm publish --access public --provenance
The --provenance flag generates a cryptographically signed attestation linking the published package to the exact GitHub Actions run that built it — npm shows a "Verified" badge on the package page. It requires running the publish step inside GitHub Actions with id-token: write permission:
# .github/workflows/publish.yml
name: Publish to npm
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
- run: npm ci
- run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Tag a release to trigger the workflow:
git tag v1.0.0
git push origin v1.0.0
📁 Final Package Structure
mcp-client-sdk/
├── src/
│ ├── index.ts ← public API — re-exports everything
│ ├── factory.ts ← McpClientFactory builder
│ ├── client.ts ← McpClientWrapper + callTool generics
│ ├── retry.ts ← withRetry + exponential backoff
│ ├── timeout.ts ← withTimeout + McpTimeoutError
│ ├── types.ts ← shared TypeScript interfaces
│ └── __tests__/
│ ├── client.test.ts
│ ├── retry.test.ts
│ └── timeout.test.ts
├── tsup.config.ts
├── tsconfig.json
├── vitest.config.ts
├── package.json
├── README.md ← document every public API
└── .github/
└── workflows/
└── publish.yml
src/index.ts controls the public surface — only export what you intend to be stable API:
export { McpClientFactory } from "./factory.js";
export { McpClientWrapper, McpToolError } from "./client.js";
export { McpTimeoutError } from "./timeout.js";
export type { McpPlugin, PluginContext, McpClientOptions } from "./types.js";
💡 Key Design Decisions
Fluent builder over a config object. new McpClientFactory().withRetries(3).use(plugin).connectHttp(...) is self-documenting. A flat config object buries intent.
Zod at the callsite, not inside the SDK. The SDK enforces that you validate — by requiring an inputSchema argument — but uses your schema, so you are not locked into the SDK's type system.
Plugin order matches mental model. Plugins run in the order you .use() them. The first plugin wraps everything. This is the same model as Express, Koa, and Hono — developers already know it.
InMemoryTransport for tests. Never mock the MCP client in tests. The mock drifts from reality; the in-process server stays honest. A test that runs real JSON-RPC is infinitely more trustworthy.
Separate McpToolError from McpTimeoutError. Consumers need to distinguish "the tool said no" from "the network died". Different errors, different handling. Never collapse them into a single Error.
🎯 Summary
In Part 8 you packaged all the MCP client knowledge from Parts 3–7 into a publishable SDK:
- 🏭
McpClientFactory— fluent builder hiding stdio vs HTTP transport details - 🎯 Typed
callTool<I, O>()— Zod input validation + inferred output types - ♻️ Retry with exponential backoff + jitter — survives transient network hiccups
- ⏱️ Per-call timeouts —
McpTimeoutErrorkeeps the agent loop moving - 🔌 Plugin middleware chain — logging, tracing, caching added without forking
- 🧪 Vitest +
InMemoryTransport— real protocol tests with zero network overhead - 🚀 npm publish with provenance — signed, verified, CI-automated releases
In Part 9 we will put everything together — server + client SDK + agent + observability — into a production deployment on Fly.io, complete with secrets management, rolling deploys, and a GitHub Actions CD pipeline. 🚀