---
title: 'Build an MCP Client in TypeScript: A 2026 Tutorial'
description: 'Build a Model Context Protocol (MCP) client in TypeScript with @modelcontextprotocol/sdk: connect over stdio, list and call tools, and handle errors correctly.'
url: https://nerdleveltech.com/mcp-client-typescript-stdio-tools-tutorial
language: en
date: '2026-06-03'
category: ai-ml
tags:
  - mcp
  - model context protocol
  - typescript
  - mcp client
  - '@modelcontextprotocol/sdk'
  - node.js
  - ai-ml
  - tool use
markdown_url: https://nerdleveltech.com/mcp-client-typescript-stdio-tools-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/mcp-client-typescript-stdio-tools-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Build an MCP Client in TypeScript: A 2026 Tutorial

To build an MCP client in TypeScript, install the stable `@modelcontextprotocol/sdk`, create a `Client`, connect it to a transport such as `StdioClientTransport`, then call `listTools()` and `callTool()` to discover and invoke a server's tools. This tutorial builds one end to end.

## TL;DR

Most Model Context Protocol tutorials build *servers*. This one builds the *client* — the side that connects to a server, discovers its tools, and calls them. You will scaffold a TypeScript project, write a tiny demo server to connect to, then write a client that connects over stdio, lists tools/resources/prompts, calls a tool and reads both its `content` and `structuredContent`, handles errors correctly, talks to a remote server over Streamable HTTP, and gets an in-process test with `InMemoryTransport`. It uses the stable [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) `1.29.0`[^sdk-npm] on Node.js 24 LTS. One catch worth knowing up front: the SDK's GitHub `main` docs already describe an unreleased `2.0` split-package API, so this guide is written against the version you actually `npm install`. Every code block was type-checked and run on 3 June 2026. Budget about 30 minutes.

## What you'll learn

- Scaffold a Node 24 TypeScript project for an MCP client with the stable SDK
- Write a minimal MCP server to connect to, so the tutorial is self-contained
- Connect a `Client` to a local server over `StdioClientTransport`
- Discover a server's tools, resources, and prompts, with pagination
- Call a tool and read both `content` (for the model) and `structuredContent` (for your code)
- Read resources and fetch prompt templates with arguments
- Handle the two distinct error surfaces correctly: tool `isError` versus a thrown `McpError`
- Connect to a remote server over `StreamableHTTPClientTransport`, with a bearer token
- Test a client in-process with `InMemoryTransport`, no subprocess required

## Prerequisites

- **Node.js 24 LTS** or newer. Node 24 is the current Active LTS line, supported through April 2028[^node-lts]. Check with `node --version`.
- Working knowledge of **TypeScript** and `async`/`await`.
- A terminal. No API keys and no LLM account are needed — this tutorial is about the client mechanics, not wiring a model.

The stack: `@modelcontextprotocol/sdk` `1.29.0`[^sdk-npm], `zod` `4.4.3`[^zod] for the demo server's schemas, and `tsx` `4.22.4`[^tsx] so we can run `.ts` files directly. The SDK requires Node 18 or newer, but Node 24 is the LTS to target in 2026[^node-lts].

> A note before you install. There is an `@modelcontextprotocol/client` package on npm, but as of this writing it is only published as a `2.0.0-alpha` preview of a future split-package layout[^client-alpha]. The stable, supported client ships *inside* `@modelcontextprotocol/sdk` under the `/client` subpath. The SDK's `main`-branch client guide already documents the alpha API[^client-docs], so if you copy code from there it will not match `1.29.0`. Everything below uses the stable package.

## Step 1 — Scaffold the project

Create a project, set it to ES modules, and install the SDK plus the dev toolchain. Pin exact versions with `--save-exact` so a later `npm install` cannot drift to a higher patch.

```bash
mkdir mcp-client-demo && cd mcp-client-demo
npm init -y
npm pkg set type=module
npm install --save-exact @modelcontextprotocol/sdk@1.29.0 zod@4.4.3
npm install --save-exact -D tsx@4.22.4 typescript@6.0.3 @types/node@24.12.4
```

Add a strict `tsconfig.json`. `NodeNext` resolution is required for the SDK's subpath exports, and `"types": ["node"]` makes the `process` global resolve under `verbatimModuleSyntax`.

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "lib": ["ES2022"],
    "types": ["node"],
    "outDir": "dist"
  },
  "include": ["src"]
}
```

Create the source folder: `mkdir src`. Two things to remember with `NodeNext` and ESM: import specifiers point at the SDK's compiled `.js` files (for example `@modelcontextprotocol/sdk/client/index.js`), and your own relative imports also need the `.js` extension. This trips up nearly everyone on their first MCP client.

## Step 2 — Write a server to connect to

A client needs something to talk to. Rather than depend on an external server, build a minimal one so the whole tutorial runs offline. If you want a production server — OAuth, Streamable HTTP, deployment — follow the companion [production MCP server tutorial](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial); here we just need a target with one tool, one resource, and one prompt.

Create `src/server.ts`:

```ts
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-server", version: "1.0.0" },
  { instructions: "A demo server with a calculator, a config resource, and a prompt." },
);

server.registerTool(
  "add",
  {
    title: "Add two numbers",
    description: "Returns the sum of a and b.",
    inputSchema: { a: z.number(), b: z.number() },
    outputSchema: { sum: z.number() },
  },
  async ({ a, b }) => {
    const sum = a + b;
    return {
      content: [{ type: "text", text: `${a} + ${b} = ${sum}` }],
      structuredContent: { sum },
    };
  },
);

server.registerResource(
  "app-config",
  "config://app",
  { title: "App config", mimeType: "application/json" },
  async (uri) => ({
    contents: [
      { uri: uri.href, mimeType: "application/json", text: JSON.stringify({ theme: "dark", locale: "en" }) },
    ],
  }),
);

server.registerPrompt(
  "summarize",
  { title: "Summarize text", argsSchema: { text: z.string() } },
  ({ text }) => ({
    messages: [
      { role: "user", content: { type: "text", text: `Summarize this in one sentence:\n\n${text}` } },
    ],
  }),
);

await server.connect(new StdioServerTransport());
```

A stdio server reads JSON-RPC from stdin and writes to stdout, so it must never `console.log` to stdout — that would corrupt the protocol stream. Log to `stderr` if you need to.

## Step 3 — Connect over stdio

Now the client. `StdioClientTransport` spawns the server as a child process and speaks JSON-RPC over its stdin/stdout, which is exactly how desktop hosts launch local servers. Create `src/client.ts`:

```ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const client = new Client(
  { name: "demo-client", version: "1.0.0" },
  { capabilities: {} },
);

const transport = new StdioClientTransport({
  command: process.execPath,            // the current node binary
  args: ["--import", "tsx", "src/server.ts"],
});

await client.connect(transport);

console.log("Connected to:", client.getServerVersion());
console.log("Instructions:", client.getInstructions());
```

The first argument to `Client` is your client's identity; the second declares capabilities (leave it empty for now — we add `sampling` and `elicitation` only when a server needs to call *back* into the client). `command: process.execPath` is the path to the running Node binary, and `--import tsx` lets Node execute the TypeScript server without a build step. `client.getInstructions()` returns the optional `instructions` string the server sent at initialization — fold it into your model's system prompt when you wire up an LLM.

## Step 4 — Discover tools, resources, and prompts

Discovery is how an MCP client learns what a server can do. `listTools()` returns a page of tools and an optional `nextCursor`; loop until the cursor is gone. `listResources()` and `listPrompts()` follow the same shape. Add to `src/client.ts`:

```ts
import type { Tool } from "@modelcontextprotocol/sdk/types.js";

const allTools: Tool[] = [];
let cursor: string | undefined;
do {
  const page = await client.listTools(cursor ? { cursor } : {});
  allTools.push(...page.tools);
  cursor = page.nextCursor;
} while (cursor);

console.log("Tools:", allTools.map((t) => t.name));
```

Each `Tool` carries a `name`, an optional `description`, and an `inputSchema` (JSON Schema). That schema is what you hand to a model so it knows how to fill in arguments — it is the bridge between MCP and an LLM's tool-use loop, the subject of our [Claude tool-use agentic loop tutorial](https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-agentic-loop-tutorial).

## Step 5 — Call a tool

`callTool()` invokes a tool by name with an `arguments` object. The result has two payloads: `content` is human- or model-readable (text, images, embedded resources), and `structuredContent` is a typed JSON object your application can use directly when the tool declares an `outputSchema`. Read whichever fits your use.

```ts
const result = await client.callTool({ name: "add", arguments: { a: 21, b: 21 } });

console.log("content:", result.content);                 // [{ type: 'text', text: '21 + 21 = 42' }]
console.log("structuredContent:", result.structuredContent); // { sum: 42 }
```

Use `content` when you are feeding the result back to a model, and `structuredContent` when your code needs the value. For a calculator that distinction is trivial; for a tool that returns a row of database fields it is the difference between parsing a string and reading an object.

## Step 6 — Read resources and prompts

Resources are read-only data — files, config, schemas — addressed by URI. `readResource()` returns a `contents` array whose items are a union of text and binary (`blob`) shapes, so narrow with an `in` check before touching `.text`:

```ts
const res = await client.readResource({ uri: "config://app" });
const first = res.contents[0];
if (first && "text" in first) {
  console.log("resource:", first.text);  // {"theme":"dark","locale":"en"}
}
```

Prompts are reusable message templates. `getPrompt()` takes a name plus arguments and returns ready-to-send `messages`:

```ts
const prompt = await client.getPrompt({
  name: "summarize",
  arguments: { text: "MCP standardizes tool access." },
});
console.log("prompt messages:", prompt.messages.length); // 1
```

## Step 7 — Handle errors the right way

This is where MCP clients most often go wrong, because the SDK has **two** error surfaces and they behave differently. A failing *tool* does not throw — `callTool()` resolves with `isError: true` and the message in `content`. That covers an unknown tool name, invalid arguments, and an exception thrown inside the tool handler; the server catches all three and reports them as a tool result.

```ts
const bad = await client.callTool({ name: "does-not-exist", arguments: {} });
if (bad.isError) {
  console.log("tool error:", bad.content); // [{ type: 'text', text: 'MCP error -32602: Tool does-not-exist not found' }]
}
```

A failing *request* — reading a missing resource, fetching an unknown prompt, a request timeout, a dropped connection — rejects with an `McpError` you must `try/catch`. Inspect `err.code` against the `ErrorCode` enum rather than matching on message text:

```ts
import { McpError } from "@modelcontextprotocol/sdk/types.js";

try {
  await client.readResource({ uri: "config://missing" });
} catch (err) {
  if (err instanceof McpError) {
    console.log(`McpError code=${err.code}: ${err.message}`);
  } else {
    throw err;
  }
}
```

The rule of thumb: check `result.isError` after every `callTool()`, and wrap resource/prompt reads (and any long-running call) in `try/catch` for `McpError`. Requests use a 60-second default timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`); override it per call with `{ timeout: 120_000 }` in the options argument, and a timeout rejects with an `McpError` whose code is `ErrorCode.RequestTimeout`[^client-docs].

## Step 8 — Connect to a remote server

Production servers usually run over HTTP, not as a spawned process. Swap the transport for `StreamableHTTPClientTransport` and point it at the server's `/mcp` endpoint — every other call (`listTools`, `callTool`, and so on) stays identical.

```ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "demo-client", version: "1.0.0" }, { capabilities: {} });

const transport = new StreamableHTTPClientTransport(
  new URL("http://localhost:3000/mcp"),
);

await client.connect(transport);
```

For a server that expects a bearer token managed outside the SDK — an API key or a gateway-issued token — attach an `Authorization` header through the transport's `requestInit` option, which is merged into every fetch the transport makes:

```ts
const transport = new StreamableHTTPClientTransport(
  new URL("http://localhost:3000/mcp"),
  { requestInit: { headers: { Authorization: `Bearer ${process.env.MCP_TOKEN ?? ""}` } } },
);
```

Full interactive OAuth — the browser authorization-code flow with dynamic client registration — is supported through the transport's `authProvider` option, which takes an `OAuthClientProvider` implementation (`tokens()`, `saveTokens()`, `redirectToAuthorization()`, and friends). The companion [production MCP server tutorial](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial) covers the server side of that handshake.

## Verification

Run the client. It spawns the demo server, so you only run one command:

```bash
npx tsx src/client.ts
```

Expected output:

```text
Connected to: { name: 'demo-server', version: '1.0.0' }
Instructions: A demo server with a calculator, a config resource, and a prompt.
Tools: [ 'add' ]
content: [ { type: 'text', text: '21 + 21 = 42' } ]
structuredContent: { sum: 42 }
resource: {"theme":"dark","locale":"en"}
prompt messages: 1
tool error: [ { type: 'text', text: 'MCP error -32602: Tool does-not-exist not found' } ]
McpError code=-32602: ... Resource config://missing not found
```

Type-check the whole project with `npx tsc --noEmit` — it should pass clean under the strict config.

## Bonus — Test a client with InMemoryTransport

For unit tests you do not want to spawn a subprocess. `InMemoryTransport.createLinkedPair()` returns two transports wired to each other, so a client and server can talk in the same process. Create `src/inmemory.ts`:

```ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

const server = new McpServer({ name: "test-server", version: "1.0.0" });
server.registerTool(
  "add",
  { inputSchema: { a: z.number(), b: z.number() } },
  async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }),
);

const client = new Client({ name: "test-client", version: "1.0.0" });
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverSide), client.connect(clientSide)]);

const result = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } });
console.log(result.content); // [{ type: 'text', text: '5' }]

await client.close();
```

Run it with `npx tsx src/inmemory.ts`. Because there is no subprocess and no socket, this pattern is fast enough to run in CI on every commit.

## Troubleshooting

- **`Cannot find module '@modelcontextprotocol/client'`** — you copied code from the SDK's `main`-branch docs, which describe the unreleased `2.0` split packages[^client-docs]. On the stable release, import from `@modelcontextprotocol/sdk/client/...` instead[^client-alpha].
- **`ERR_MODULE_NOT_FOUND` or "Cannot use import statement outside a module"** — your `package.json` is missing `"type": "module"`, or an import is missing its `.js` extension. Under `NodeNext`, both your relative imports and the SDK subpaths need `.js`.
- **`Cannot find name 'process'`** — add `"types": ["node"]` to `tsconfig.json` (and ensure `@types/node` is installed). `verbatimModuleSyntax` will not pull in ambient Node globals without it.
- **The client hangs on `connect()` over stdio** — the server is probably writing to stdout. A stdio MCP server must keep stdout clean for JSON-RPC; move any logging to stderr.
- **`callTool()` "succeeds" but nothing happened** — you did not check `isError`. Tool failures resolve, they do not throw. Inspect `result.isError` and `result.content` after every call.

## Next steps and further reading

You now have a client that connects over stdio and HTTP, discovers a server's capabilities, calls tools, and handles both error surfaces. From here:

- Wire the discovered tools into a model's tool-use loop — see the [Claude tool-use agentic loop tutorial](https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-agentic-loop-tutorial).
- Build the other half of the protocol with the [production MCP server tutorial](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial).
- New to the protocol itself? Start with [MCP servers explained](https://nerdleveltech.com/mcp-servers-explained-claudes-new-ai-backbone-for-real-automation).

The client SDK also supports progress notifications for long tools, resource subscriptions, server-initiated sampling and elicitation, and SSE resumption tokens — all documented in the SDK's client guide, with the caveat that it tracks the newer API[^client-docs]. Pin the stable `1.29.0` until the `2.0` packages reach a stable release. The current protocol revision the SDK negotiates is `2025-11-25`[^spec].

## Related reads

- [MCP Server Not Showing Up in Claude Desktop: Fixes (2026)](https://nerdleveltech.com/mcp-server-not-showing-up-in-claude-desktop)

[^sdk-npm]: @modelcontextprotocol/sdk on npm, version 1.29.0 (published 2026-03-30). https://www.npmjs.com/package/@modelcontextprotocol/sdk
[^client-alpha]: @modelcontextprotocol/client on npm — published only as a 2.0.0-alpha preview as of June 2026. https://www.npmjs.com/package/@modelcontextprotocol/client
[^client-docs]: MCP TypeScript SDK client guide (GitHub `main`), which documents the upcoming split-package 2.0 API. https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/client.md
[^spec]: Model Context Protocol specification, revision 2025-11-25 (current stable). https://modelcontextprotocol.io/specification/2025-11-25
[^node-lts]: Node.js releases — Node 24 entered Active LTS on 2025-10-28 and is supported through 2028-04-30. https://nodejs.org/en/about/previous-releases
[^tsx]: tsx on npm, version 4.22.4. https://www.npmjs.com/package/tsx
[^zod]: zod on npm, version 4.4.3. https://www.npmjs.com/package/zod
