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.