Build an MCP Client in TypeScript: A 2026 Tutorial
June 3, 2026
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 1.29.01 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
Clientto a local server overStdioClientTransport - Discover a server's tools, resources, and prompts, with pagination
- Call a tool and read both
content(for the model) andstructuredContent(for your code) - Read resources and fetch prompt templates with arguments
- Handle the two distinct error surfaces correctly: tool
isErrorversus a thrownMcpError - 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 20282. 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.01, zod 4.4.33 for the demo server's schemas, and tsx 4.22.44 so we can run .ts files directly. The SDK requires Node 18 or newer, but Node 24 is the LTS to target in 20262.
A note before you install. There is an
@modelcontextprotocol/clientpackage on npm, but as of this writing it is only published as a2.0.0-alphapreview of a future split-package layout5. The stable, supported client ships inside@modelcontextprotocol/sdkunder the/clientsubpath. The SDK'smain-branch client guide already documents the alpha API6, so if you copy code from there it will not match1.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.
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.
{
"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; here we just need a target with one tool, one resource, and one prompt.
Create src/server.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:
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:
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.
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.
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:
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:
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.
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:
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.RequestTimeout6.
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.
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:
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 covers the server side of that handshake.
Verification
Run the client. It spawns the demo server, so you only run one command:
npx tsx src/client.ts
Expected output:
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:
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'smain-branch docs, which describe the unreleased2.0split packages6. On the stable release, import from@modelcontextprotocol/sdk/client/...instead5.ERR_MODULE_NOT_FOUNDor "Cannot use import statement outside a module" — yourpackage.jsonis missing"type": "module", or an import is missing its.jsextension. UnderNodeNext, both your relative imports and the SDK subpaths need.js.Cannot find name 'process'— add"types": ["node"]totsconfig.json(and ensure@types/nodeis installed).verbatimModuleSyntaxwill 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 checkisError. Tool failures resolve, they do not throw. Inspectresult.isErrorandresult.contentafter 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.
- Build the other half of the protocol with the production MCP server tutorial.
- New to the protocol itself? Start with MCP servers explained.
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 API6. 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-257.
Footnotes
-
@modelcontextprotocol/sdk on npm, version 1.29.0 (published 2026-03-30). https://www.npmjs.com/package/@modelcontextprotocol/sdk ↩ ↩2
-
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 ↩ ↩2
-
zod on npm, version 4.4.3. https://www.npmjs.com/package/zod ↩
-
tsx on npm, version 4.22.4. https://www.npmjs.com/package/tsx ↩
-
@modelcontextprotocol/client on npm — published only as a 2.0.0-alpha preview as of June 2026. https://www.npmjs.com/package/@modelcontextprotocol/client ↩ ↩2
-
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 ↩ ↩2 ↩3 ↩4 -
Model Context Protocol specification, revision 2025-11-25 (current stable). https://modelcontextprotocol.io/specification/2025-11-25 ↩