How to Build an MCP Client (Python and TypeScript)
An MCP client is the program that connects to an MCP server, lists the tools it offers, calls them, and gives the tool list to a model. With the official SDKs you create a Client, then connect it over stdio (the client starts the server as a child process) or over Streamable HTTP (you give it a URL). Call list_tools and call_tool on the connected client, and pass the tool list to your model in a loop. The example below does that against the bookmarks server from Build an MCP server, with a scripted stand-in for a real model, so you can run it without signing up for a model provider.
What you'll have at the end
A command-line client that starts the bookmarks server, lists its two tools, makes a direct call, and then runs a tool loop where the model saves a bookmark and searches for it. This is the output of the Python client over stdio, in the modern protocol era:
Connected. Protocol version: 2026-07-28
Tools: save_bookmark, search_bookmarks
Direct call: No bookmarks match 'mcp'.
Offering 2 tools to the model
Model called save_bookmark
INFO:bookmarks:Saved bookmark https://modelcontextprotocol.io
Model called search_bookmarks
Model answer: Done. Search result: - MCP docs: https://modelcontextprotocol.io
The Direct call line depends on what's already in your bookmarks file. On an empty file it says no bookmarks match. The INFO:bookmarks: line is logged by the server, which writes to stderr.
Before you start
- Python: Python 3.10 or later and uv. This guide uses version 2.3.0 of the
mcppackage. Version 2.0.0 or later is needed for theClientAPI used here. - TypeScript: Node.js 20 or later and npm. This guide uses version 2.3.1 of
@modelcontextprotocol/client, which declares Node.js 20 or later. - The server: the bookmarks server from Build an MCP server, built in its own folder (
npm run buildfor TypeScript). The client starts it as a subprocess, so it must run on this machine.
1. Set up the project
- Python
- TypeScript
uv init --bare --name mcp-client
uv add "mcp>=2.3.0"
uv init --bare creates a pyproject.toml with no other files. Open it and change requires-python to ">=3.10", so the client runs on any Python from 3.10 up. uv add installs the official MCP Python SDK and records the requirement.
mkdir mcp-client-ts
cd mcp-client-ts
npm init -y
npm install @modelcontextprotocol/client@^2.3.1
npm install -D @types/node@^22 typescript@^7.0.2
mkdir src
@modelcontextprotocol/client is the official TypeScript SDK for clients. Replace the package.json that npm init wrote with this one. It sets "type": "module" and adds a build script:
{
"name": "mcp-client-guide",
"version": "1.0.0",
"type": "module",
"bin": {
"mcp-client-guide": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js"
},
"files": ["build"],
"dependencies": {
"@modelcontextprotocol/client": "^2.3.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^7.0.2"
}
}
Then create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
2. Write the tool loop
The loop doesn't depend on any one model. It converts each MCP tool into a plain description, sends the conversation and that list to a model, runs whatever tool calls come back, and repeats until the model answers in text. The model sits behind a small interface, ChatModel. To use a real model, write a class that implements reply() by calling that provider's SDK and translating its tool-call format into ToolCall. Nothing else in the loop changes.
The file also includes MockModel. It asks for the two bookmark tools in a fixed order, so the example runs the same way every time. It refuses to call a tool the server didn't offer.
- Python
- TypeScript
import logging
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol
from mcp import Client, MCPError
from mcp_types import CallToolResult, TextContent, Tool
logger = logging.getLogger(__name__)
MAX_STEPS = 5
@dataclass(frozen=True)
class ToolSpec:
"""A tool as the model sees it. Convert each MCP tool once, then pass the list to any model."""
name: str
description: str
input_schema: dict[str, Any]
@dataclass(frozen=True)
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass(frozen=True)
class Message:
"""One turn in the conversation, in a provider-neutral shape."""
role: Literal["user", "assistant", "tool"]
content: str = ""
tool_calls: list[ToolCall] = field(default_factory=list)
tool_call_id: str | None = None
is_error: bool = False
@dataclass(frozen=True)
class ModelReply:
text: str | None = None
tool_calls: list[ToolCall] = field(default_factory=list)
class ChatModel(Protocol):
"""The only thing the loop needs from a model. Wrap any provider's SDK in this shape."""
async def reply(self, messages: list[Message], tools: list[ToolSpec]) -> ModelReply: ...
class MockModel:
"""A scripted stand-in for a real model, so the example runs without an API key.
It asks for the two bookmark tools in a fixed order, then answers from the last
tool result. It refuses to call a tool the server did not offer.
"""
def __init__(self) -> None:
self._steps = [
ToolCall(
id="call-1",
name="save_bookmark",
arguments={
"url": "https://modelcontextprotocol.io",
"title": "MCP docs",
"tags": ["mcp"],
},
),
ToolCall(id="call-2", name="search_bookmarks", arguments={"query": "mcp"}),
]
async def reply(self, messages: list[Message], tools: list[ToolSpec]) -> ModelReply:
results = [message for message in messages if message.role == "tool"]
if len(results) < len(self._steps):
call = self._steps[len(results)]
if call.name not in {tool.name for tool in tools}:
raise ValueError(f"The model asked for {call.name}, which the server did not offer")
return ModelReply(tool_calls=[call])
return ModelReply(text=f"Done. Search result: {results[-1].content}")
def tool_specs(tools: list[Tool]) -> list[ToolSpec]:
return [
ToolSpec(name=tool.name, description=tool.description or "", input_schema=tool.input_schema)
for tool in tools
]
def result_text(result: CallToolResult) -> str:
return "\n".join(block.text for block in result.content if isinstance(block, TextContent))
async def run_tool_call(client: Client, call: ToolCall) -> Message:
try:
result = await client.call_tool(call.name, call.arguments)
except MCPError as error:
# A protocol error (for example, an unknown tool) still goes back to the model
# as an error result, so it can recover instead of the whole run failing.
logger.info("Tool %s failed: %s", call.name, error)
return Message(role="tool", content=str(error), tool_call_id=call.id, is_error=True)
# A tool that ran but failed sets is_error on the result. It does not raise.
return Message(
role="tool",
content=result_text(result),
tool_call_id=call.id,
is_error=result.is_error,
)
async def run_tool_loop(client: Client, model: ChatModel, request: str) -> str:
tools = tool_specs((await client.list_tools()).tools)
logger.info("Offering %d tools to the model", len(tools))
messages = [Message(role="user", content=request)]
for _ in range(MAX_STEPS):
reply = await model.reply(messages, tools)
if not reply.tool_calls:
return reply.text or ""
messages.append(
Message(role="assistant", content=reply.text or "", tool_calls=reply.tool_calls)
)
for call in reply.tool_calls:
logger.info("Model called %s", call.name)
messages.append(await run_tool_call(client, call))
raise RuntimeError(f"The model did not finish within {MAX_STEPS} steps")
Two details matter here. A tool that runs and fails sets is_error on its result, and the client doesn't raise. A protocol error, such as an unknown tool name, raises MCPError. Both go back to the model as error results, so the model can recover on its next turn instead of the whole run stopping.
import type { CallToolResult, Client, TextContent, Tool } from "@modelcontextprotocol/client";
const MAX_STEPS = 5;
/** A tool as the model sees it. Convert each MCP tool once, then pass the list to any model. */
export interface ToolSpec {
name: string;
description: string;
inputSchema: Tool["inputSchema"];
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
/** One turn in the conversation, in a provider-neutral shape. */
export interface Message {
role: "user" | "assistant" | "tool";
content: string;
toolCalls?: ToolCall[];
toolCallId?: string;
isError?: boolean;
}
export interface ModelReply {
text?: string;
toolCalls: ToolCall[];
}
/** The only thing the loop needs from a model. Wrap any provider's SDK in this shape. */
export interface ChatModel {
reply(messages: Message[], tools: ToolSpec[]): Promise<ModelReply>;
}
/**
* A scripted stand-in for a real model, so the example runs without an API key.
* It asks for the two bookmark tools in a fixed order, then answers from the last
* tool result. It refuses to call a tool the server did not offer.
*/
export class MockModel implements ChatModel {
private readonly steps: ToolCall[] = [
{
id: "call-1",
name: "save_bookmark",
arguments: {
url: "https://modelcontextprotocol.io",
title: "MCP docs",
tags: ["mcp"],
},
},
{ id: "call-2", name: "search_bookmarks", arguments: { query: "mcp" } },
];
async reply(messages: Message[], tools: ToolSpec[]): Promise<ModelReply> {
const results = messages.filter((message) => message.role === "tool");
if (results.length < this.steps.length) {
const call = this.steps[results.length];
if (!tools.some((tool) => tool.name === call.name)) {
throw new Error(`The model asked for ${call.name}, which the server did not offer`);
}
return { toolCalls: [call] };
}
const last = results[results.length - 1];
return { text: `Done. Search result: ${last.content}`, toolCalls: [] };
}
}
export function resultText(result: CallToolResult): string {
return result.content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
async function runToolCall(client: Client, call: ToolCall): Promise<Message> {
try {
const result = await client.callTool({ name: call.name, arguments: call.arguments });
// A tool that ran but failed sets isError on the result. It does not throw.
return {
role: "tool",
content: resultText(result),
toolCallId: call.id,
isError: result.isError === true,
};
} catch (error: unknown) {
// A protocol error (for example, an unknown tool) still goes back to the model
// as an error result, so it can recover instead of the whole run failing.
const message = error instanceof Error ? error.message : String(error);
console.error(`Tool ${call.name} failed: ${message}`);
return { role: "tool", content: message, toolCallId: call.id, isError: true };
}
}
export async function runToolLoop(client: Client, model: ChatModel, request: string): Promise<string> {
const { tools } = await client.listTools();
const specs: ToolSpec[] = tools.map((tool) => ({
name: tool.name,
description: tool.description ?? "",
inputSchema: tool.inputSchema,
}));
console.error(`Offering ${specs.length} tools to the model`);
const messages: Message[] = [{ role: "user", content: request }];
for (let step = 0; step < MAX_STEPS; step++) {
const reply = await model.reply(messages, specs);
if (reply.toolCalls.length === 0) {
return reply.text ?? "";
}
messages.push({ role: "assistant", content: reply.text ?? "", toolCalls: reply.toolCalls });
for (const call of reply.toolCalls) {
console.error(`Model called ${call.name}`);
messages.push(await runToolCall(client, call));
}
}
throw new Error(`The model did not finish within ${MAX_STEPS} steps`);
}
Two details matter here. A tool that runs and fails sets isError on its result, and callTool resolves normally. A protocol error, such as an unknown tool name, makes callTool throw. Both go back to the model as error results, so the model can recover on its next turn instead of the whole run stopping.
3. Connect, list tools and call one
This is the client itself. It connects over stdio by default, lists the tools, makes a direct call to search_bookmarks, and then runs the loop from step 2.
- Python
- TypeScript
import argparse
import asyncio
import logging
import os
from pathlib import Path
from mcp import Client, StdioServerParameters
from agent import MockModel, result_text, run_tool_loop
logger = logging.getLogger(__name__)
# The build-mcp-server example, referenced in place rather than copied.
SERVER_DIR = Path(__file__).resolve().parents[2] / "build-mcp-server" / "python"
# "legacy" runs the 2025-era initialize handshake. A version string adopts that
# revision directly, without probing the server first.
ERA_MODES = {"modern": "2026-07-28", "legacy": "legacy"}
REQUEST = "Bookmark https://modelcontextprotocol.io as MCP docs, then find my MCP bookmarks."
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Connect to a bookmarks MCP server and use its tools.")
parser.add_argument(
"--url",
help="Streamable HTTP endpoint, for example http://127.0.0.1:8000/mcp. "
"Leave out to start the server over stdio.",
)
parser.add_argument(
"--era",
choices=sorted(ERA_MODES),
default="modern",
help="Protocol era to negotiate. Default: modern (2026-07-28).",
)
return parser.parse_args(argv)
def server_target(url: str | None) -> str | StdioServerParameters:
if url:
return url
return StdioServerParameters(
command="uv",
args=["run", "--quiet", "--directory", str(SERVER_DIR), "bookmarks.py"],
# The server reads BOOKMARKS_FILE, so pass the whole environment through.
env=dict(os.environ),
)
async def run(target: str | StdioServerParameters, era: str) -> None:
async with Client(target, mode=ERA_MODES[era]) as client:
logger.info("Connected. Protocol version: %s", client.protocol_version)
tools = await client.list_tools()
logger.info("Tools: %s", ", ".join(tool.name for tool in tools.tools))
search = await client.call_tool("search_bookmarks", {"query": "mcp"})
logger.info("Direct call: %s", result_text(search))
answer = await run_tool_loop(client, MockModel(), REQUEST)
logger.info("Model answer: %s", answer)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(message)s")
# The HTTP layer logs every request at INFO. Keep only warnings from it.
logging.getLogger("httpx2").setLevel(logging.WARNING)
args = parse_args()
asyncio.run(run(server_target(args.url), args.era))
if __name__ == "__main__":
main()
Client is the one object your program talks to the server through. Give it a StdioServerParameters to start a local server as a subprocess, or a URL string for a Streamable HTTP server. Entering the async with block connects and agrees a protocol version, and leaving it disconnects and stops the subprocess. The mode argument picks the protocol era, and step 5 covers it.
SERVER_DIR points at the bookmarks server. This example computes that path from its own location, which matches the folder layout of this repository. In your own project, replace that line with the path to your server, for example SERVER_DIR = Path("/Users/you/bookmarks/python").
#!/usr/bin/env node
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import {
Client,
type ClientOptions,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { MockModel, resultText, runToolLoop } from "./agent.js";
// The build-mcp-server example, referenced in place rather than copied.
const SERVER_SCRIPT = fileURLToPath(
new URL("../../../build-mcp-server/typescript/build/index.js", import.meta.url),
);
const REQUEST = "Bookmark https://modelcontextprotocol.io as MCP docs, then find my MCP bookmarks.";
type Era = "modern" | "legacy";
// "legacy" runs the 2025-era initialize handshake. The pin adopts the 2026-07-28
// revision directly, with no probe, and fails if the server does not speak it.
function clientOptions(era: Era): ClientOptions {
if (era === "legacy") {
return { versionNegotiation: { mode: "legacy" } };
}
return { versionNegotiation: { mode: { pin: "2026-07-28" } } };
}
function childEnv(): Record<string, string> {
// The server reads BOOKMARKS_FILE, so pass the whole environment through.
return Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
);
}
function transport(url: string | undefined) {
if (url) {
return new StreamableHTTPClientTransport(new URL(url));
}
return new StdioClientTransport({
command: process.execPath,
args: [SERVER_SCRIPT],
env: childEnv(),
});
}
async function main(): Promise<void> {
const { values } = parseArgs({
options: {
url: { type: "string" },
era: { type: "string", default: "modern" },
},
});
if (values.era !== "modern" && values.era !== "legacy") {
throw new Error(`--era must be modern or legacy, got ${values.era}`);
}
const client = new Client({ name: "mcp-client-guide", version: "1.0.0" }, clientOptions(values.era));
await client.connect(transport(values.url));
try {
console.error(`Connected. Protocol version: ${client.getNegotiatedProtocolVersion()}`);
const { tools } = await client.listTools();
console.error(`Tools: ${tools.map((tool) => tool.name).join(", ")}`);
const search = await client.callTool({ name: "search_bookmarks", arguments: { query: "mcp" } });
console.error(`Direct call: ${resultText(search)}`);
const answer = await runToolLoop(client, new MockModel(), REQUEST);
console.error(`Model answer: ${answer}`);
} finally {
await client.close();
}
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
new Client(...) takes your client's name and version, then connect() takes a transport. StdioClientTransport starts a local server as a subprocess and talks to it over stdin and stdout. StreamableHTTPClientTransport takes a URL. Always close the client in a finally block, as this example does, so the subprocess stops even when a call fails.
SERVER_SCRIPT points at the built bookmarks server. As with Python, this example computes the path from its own location. In your own project, set it to the path of your build/index.js.
Both versions read BOOKMARKS_FILE through the server's environment, so the client passes its whole environment to a stdio server. If you don't set it, the server uses ~/.bookmarks-mcp.json.
4. Run it over stdio
Build the TypeScript client first, then run both versions:
- Python
- TypeScript
uv run client.py
npm run build
node build/index.js
Each run prints the output shown at the top of this guide. Run it again and the first direct call finds the bookmark that was saved on the previous run, so the Direct call line changes.
5. Choose a protocol era
The MCP protocol has two eras. The 2026-07-28 revision is stateless: each request carries what the server needs to answer it, and the server describes itself with server/discover. Older servers and clients use the 2025-era initialize handshake, which the SDKs call legacy. A client built with the official SDK can talk to either, and you choose which one when you create the client.
| Era | Python Client | TypeScript Client | Version in the test |
|---|---|---|---|
| Modern | mode="2026-07-28" | versionNegotiation: { mode: { pin: "2026-07-28" } } | 2026-07-28 |
| Legacy | mode="legacy" | versionNegotiation: { mode: "legacy" } | 2025-11-25 |
| Probe, then fall back | mode="auto" (Python default) | versionNegotiation: { mode: "auto" } | not run here |
The example takes --era modern or --era legacy, and modern is the default. The pin in the TypeScript column adopts the 2026-07-28 revision directly, with no probe. If the server doesn't speak that revision, the connection fails. It doesn't fall back to the older handshake. The auto mode probes first and falls back to initialize on a legacy server. The TypeScript SDK defaults to legacy, and the Python SDK defaults to auto. The auto row is documented in the SDK, but this example doesn't run it, so treat it as untested here.
6. Connect over Streamable HTTP
Streamable HTTP is the transport for a server that runs somewhere else. Start a server that listens on http://127.0.0.1:8000/mcp, such as the HTTP server from Deploy a remote MCP server, then pass its URL. The client doesn't start anything in this case.
- Python
- TypeScript
uv run client.py --url http://127.0.0.1:8000/mcp
node build/index.js --url http://127.0.0.1:8000/mcp
The loop, the tool list and the output are the same as over stdio. Only the transport changes. Use --era legacy with either command to test the older handshake against the same server.
7. Check both protocol eras
Run every combination before you rely on the client. For each one, check that the negotiated version matches the era, that both tools are listed, and that the saved bookmark comes back in the answer:
uv run client.py --era modern
uv run client.py --era legacy
node build/index.js --era modern
node build/index.js --era legacy
The example folder includes a test.sh that runs all eight combinations, two languages by two transports by two eras, and checks each output. It also checks both servers with the MCP Inspector command-line tool first, so you can tell a client fault from a server fault. To look at a server on its own, the Inspector's CLI mode is enough:
npx @modelcontextprotocol/inspector@2.11.0 --cli uv run --directory /path/to/bookmarks/python bookmarks.py --protocol-era legacy --method tools/list
Common problems
Error executing tool search_bookmarks on the first call.
The bookmarks file exists but is empty, so the server can't read it as JSON. Delete the file, or write [] into it. The server treats a missing file as having no bookmarks.
The TypeScript client fails with the modern era but the server works with legacy.
The pin in step 5 doesn't fall back, by design. Either run the server on a revision that supports 2026-07-28, or switch the client to { mode: 'auto' } so it probes first and falls back to the initialize handshake.
The client can't reach the URL.
Start the HTTP server before the client, and check that the port and path in the URL match the server's. The endpoint in this guide ends in /mcp.
The client connects, then the server stops responding. The server is probably writing to standard output. Over stdio, stdout carries protocol messages only. See the warning in Build an MCP server for how to log safely.
FAQ
Which SDK should I use to build an MCP client?
Use the official SDK for your language. This guide uses the Python package mcp and the TypeScript package @modelcontextprotocol/client. Both support the 2026-07-28 revision and the older handshake, so one client can work with current and older servers.
Does the example call a real AI model?
No. It uses a scripted mock so the tests run the same way every time. To use a real model, write a class that implements reply() by calling the provider SDK, and leave the rest of the loop unchanged.
Should my client use stdio or Streamable HTTP?
Use stdio for a server on the same computer that the client starts itself. Use Streamable HTTP for a server that runs elsewhere, or that many people share. See Local vs remote MCP servers for the tradeoffs.
How do I connect to more than one server?
Create one client per server. List each server's tools, then send every tool call to the client that listed that tool. Keep the tool names unique across servers, or prefix them, so the model can tell them apart.
- Build an MCP serverWrite the server this client talks to, in Python or TypeScript.
- Test with MCP InspectorCheck a server from the command line or the Inspector web interface.
- Local vs remote serversChoose between stdio and Streamable HTTP for your server.
- Deploy a remote MCP serverHost a Streamable HTTP server that your client can reach.
- MCP clientsCompare the AI apps that already act as MCP clients.