# 📦 Build and Publish a Reusable TypeScript MCP Client SDK

---

*This is Part 8 of the **AI Engineering with TypeScript** series.*
*Prerequisites: [Part 1](https://blog.techtush.in/what-is-mcp-model-context-protocol-a-typescript-developer-s-guide) · [Part 2](https://blog.techtush.in/mcp-fundamentals-tools-resources-prompts-and-capability-negotiation-deep-dive) · [Part 3](https://blog.techtush.in/building-an-ai-agent-with-mcp-multi-step-tool-orchestration-in-typescript) · [Part 4](https://blog.techtush.in/streaming-ai-agents-and-an-interactive-cli-real-time-mcp-in-typescript) · [Part 5](https://blog.techtush.in/production-mcp-servers-streamable-http-oauth-zod-and-docker) · [Part 6](https://blog.techtush.in/multi-tenant-mcp-session-management-state-isolation-and-horizontal-scaling) · [Part 7](https://blog.techtush.in/observability-for-mcp-servers-structured-logging-distributed-tracing-and-metrics)*
*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 `tsup` that ships both ESM and CJS
- 🔑 A **`McpClientFactory`** with 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.json`** wired up for `npm publish` with 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** — `McpTimeoutError` keeps 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. 🚀

---

## 📚 Further Reading

- 📦 [tsup — TypeScript bundler](https://tsup.egoist.dev/)
- 🧪 [Vitest documentation](https://vitest.dev/)
- 🔗 [npm provenance attestations](https://docs.npmjs.com/generating-provenance-statements)
- 🔌 [MCP InMemoryTransport](https://ts.sdk.modelcontextprotocol.io/)
- 🎯 [Zod documentation](https://zod.dev/)
- 📊 [Part 7: Observability for MCP Servers](https://blog.techtush.in/observability-for-mcp-servers-structured-logging-distributed-tracing-and-metrics)
