The complete example is in typescript/ of the examples repository. This page walks through the parts that matter.
Files in the repository
Every snippet on this page comes from these files; open them to see the whole thing.
Install
The TypeScript SDK is published as separate packages since version 2: the server library, a Node adapter for Streamable HTTP, and an optional Express helper.
npm install @modelcontextprotocol/server @modelcontextprotocol/node @modelcontextprotocol/express express zod
The example pins @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/node 2.1.1, @modelcontextprotocol/express 2.0.2 and Zod 4. Node 20 or later.
Keep the upstream credential out of the tools
src/shop.ts is a small client for the Sample Shop API. It reads UPSTREAM_URL and UPSTREAM_API_KEY from the environment, attaches the key to every request, and converts HTTP failures into one typed error with four kinds:
export class UpstreamError extends Error {
constructor(
public readonly kind: "invalid_input" | "not_found" | "conflict" | "unavailable",
message: string,
) {
super(message);
}
}
The tools never touch fetch or headers; they call shop.searchProducts(...) and friends. That is the boundary that keeps credentials server-side.
Register tools with schemas and annotations
registerTool takes a name, a definition and a handler. The input schema is a Zod object; the SDK derives JSON Schema from it and validates arguments before the handler runs.
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
server.registerTool(
"search_products",
{
title: "Search products",
description:
"Search the shop catalogue by phrase and optional category. Returns one page of up to `limit` products and a `nextCursor`; when nextCursor is not null, more results exist and you must say so rather than claiming the list is complete. Use get_product for full details of one item. This tool does not place orders.",
inputSchema: z.object({
query: z.string().max(100).optional().describe("Words to match against product names and descriptions."),
category: z.enum(["lighting", "furniture", "kitchen"]).optional().describe("Restrict results to one category."),
limit: z.number().int().min(1).max(20).default(5).describe("Page size."),
cursor: z.string().optional().describe("Opaque token from a previous page's nextCursor. Never invent one."),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ query, category, limit, cursor }) => {
try {
const page = await shop.searchProducts({ q: query, category, limit, cursor });
return ok({ items: page.items.map(productSummary), nextCursor: page.nextCursor });
} catch (error) {
return explain(error);
}
},
);
Two helpers shape every result. ok returns both a text block (what the model reads) and structuredContent (what clients can parse); fail returns an isError result whose text tells the assistant what to do next:
const ok = (data: unknown) => ({
content: [{ type: "text" as const, text: JSON.stringify(data) }],
structuredContent: data as Record<string, unknown>,
});
const fail = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true });
explain maps the four UpstreamError kinds to those messages.
The guarded tool
cancel_order is the pattern for anything irreversible:
async ({ orderId, confirm }) => {
try {
if (!confirm) {
const order = await shop.getOrder(orderId);
return fail(
`Confirmation required. Order ${order.id}: ${order.quantity} × ${order.productId}, status ${order.status}, total $${(order.totalCents / 100).toFixed(2)}. Ask the user to confirm, then call cancel_order again with confirm=true.`,
);
}
const order = await shop.cancelOrder(orderId);
return ok(order);
} catch (error) {
return explain(error);
}
}
It is registered with annotations: { destructiveHint: true, idempotentHint: true }.
Serve it over Streamable HTTP, stateless
createMcpHandler takes a factory and builds a fresh McpServer per request, so nothing is held between calls. toNodeHandler adapts it to Node's request and response objects; createMcpExpressApp gives an Express app with host-header validation against DNS rebinding.
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
const handler = createMcpHandler(() => buildServer());
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all("/mcp", (req, res) => void node(req, res, req.body));
app.get("/health", (_req, res) => void res.json({ ok: true }));
app.listen(PORT, "127.0.0.1");
Run and test
node sample-api/server.mjs &
cd typescript && npm ci && npm run build && npm start
# in another shell
cd tests && npm ci && MCP_URL=http://127.0.0.1:3001/mcp npm test
The conformance test runs the same scenario the other three languages must pass. Next: connect it to Claude or ChatGPT, or see what production needs.