Model Context Protocol (MCP): Connect AI to Your Tools and DataBuilding MCP servers in Python and TypeScript · Lesson 7 of 18

Build an MCP server in TypeScript with the official SDK

Article · 17 min · 9 min lecture

Video lecture

Build an MCP server in TypeScript with the official SDK

15 chapters · about 9 min · full transcript

Coming soon

Chapter 1 of 15

Build a TypeScript MCP server

  • SDK v2 packages
  • registerTool / Resource / Prompt
  • Stateless HTTP factory
  • Tests and hosts

The narrated lecture is in production

Every chapter is scripted and ready. Browse the chapters and read the full transcript now — the video will appear here when it’s published.

Chapters

Packages in SDK v2

The TypeScript SDK's v2 (released with the 2026-07-28 spec) splits into packages:

  • @modelcontextprotocol/server — build servers.
  • @modelcontextprotocol/client — build clients.
  • Optional thin middleware: @modelcontextprotocol/node (Node HTTP), @modelcontextprotocol/express, @modelcontextprotocol/fastify, @modelcontextprotocol/hono.

It runs on Node.js, Bun and Deno. Schemas use Standard Schema, so you can bring Zod v4, Valibot or ArkType. v1 (the single @modelcontextprotocol/sdk package) continues to receive fixes for a period; a codemod and upgrade guide help migration.

Core API

  • new McpServer({ name, version }).
  • server.registerTool(name, { title?, description, inputSchema, outputSchema?, annotations? }, handler); the handler returns { content, structuredContent?, isError? }. Arguments that fail the input schema come back as an isError: true result without running your handler; thrown errors are also converted into isError results.
  • server.registerResource(name, uriOrTemplate, metadata, readCallback), with new ResourceTemplate('crm://contacts/{id}', { list: undefined }) for templates.
  • server.registerPrompt(name, { title, description, argsSchema }, callback).
  • Transports: StdioServerTransport from @modelcontextprotocol/server/stdio for local; createMcpHandler(factory) for HTTP, returning a web-standard fetch handler you mount on any runtime.

Stateless HTTP by design

createMcpHandler takes a factory that builds a fresh McpServer for every request. Nothing is held between requests, so the endpoint scales horizontally. Keep expensive objects (DB pools, HTTP clients) at module scope and close over them. The factory receives request context, including authInfo when you put bearer-token verification in front of it.

Worked example: a lead-capture server for a UK property agency

The agency wants its AI assistants to:

  • Search properties by area, price and bedrooms (read-only).
  • Register a viewing request (write, idempotent by request_key).
  • Expose each property as a resource template (property://{id}).
  • Offer a /prepare-viewing-pack prompt.

Hands-on: the server

// npm install @modelcontextprotocol/server zod
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';

type Property = { id: string; area: string; priceGbp: number; bedrooms: number; title: string };
const PROPERTIES: Property[] = [
  { id: 'p-101', area: 'Manchester', priceGbp: 285000, bedrooms: 3, title: '3-bed terrace near Northern Quarter' },
  { id: 'p-102', area: 'Leeds', priceGbp: 210000, bedrooms: 2, title: '2-bed flat, city center' },
];
const viewingRequests = new Map<string, { propertyId: string; name: string; preferredDate: string }>();

const server = new McpServer({ name: 'property-leads', version: '1.0.0' });

server.registerTool(
  'search_properties',
  {
    title: 'Search properties',
    description: 'Search listed properties by area, max price (GBP) and minimum bedrooms. Returns up to 10 results with IDs.',
    inputSchema: z.object({
      area: z.string().describe('Town or city, e.g. Manchester'),
      maxPriceGbp: z.number().int().positive().optional(),
      minBedrooms: z.number().int().min(0).max(10).optional(),
    }),
    outputSchema: z.object({
      results: z.array(z.object({ id: z.string(), title: z.string(), priceGbp: z.number(), bedrooms: z.number() })),
    }),
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async ({ area, maxPriceGbp, minBedrooms }) => {
    const results = PROPERTIES.filter(
      p => p.area.toLowerCase() === area.toLowerCase()
        && (maxPriceGbp === undefined || p.priceGbp <= maxPriceGbp)
        && (minBedrooms === undefined || p.bedrooms >= minBedrooms),
    ).slice(0, 10).map(({ id, title, priceGbp, bedrooms }) => ({ id, title, priceGbp, bedrooms }));
    const output = { results };
    return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
  },
);

server.registerTool(
  'request_viewing',
  {
    title: 'Request a viewing',
    description: 'Register a viewing request for one property. Idempotent: reuse the same requestKey when retrying.',
    inputSchema: z.object({
      requestKey: z.string().min(8).describe('Unique key for this request, reused on retries'),
      propertyId: z.string(),
      name: z.string().max(80),
      preferredDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    }),
    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
  },
  async ({ requestKey, propertyId, name, preferredDate }) => {
    if (!PROPERTIES.some(p => p.id === propertyId)) {
      return { content: [{ type: 'text', text: `Unknown property ${propertyId}; use search_properties first.` }], isError: true };
    }
    if (!viewingRequests.has(requestKey)) viewingRequests.set(requestKey, { propertyId, name, preferredDate });
    return { content: [{ type: 'text', text: `Viewing request ${requestKey} recorded for ${propertyId} on ${preferredDate}.` }] };
  },
);

server.registerResource(
  'property',
  new ResourceTemplate('property://{id}', { list: undefined }),
  { title: 'Property details', description: 'Full listing for one property', mimeType: 'application/json' },
  async (uri, { id }) => ({
    contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(PROPERTIES.find(p => p.id === id) ?? null) }],
  }),
);

server.registerPrompt(
  'prepare-viewing-pack',
  { title: 'Prepare viewing pack', description: 'Draft a viewing pack for a property', argsSchema: z.object({ propertyId: z.string() }) },
  ({ propertyId }) => ({
    messages: [{ role: 'user' as const, content: { type: 'text' as const,
      text: `Read property://${propertyId}, then draft a one-page viewing pack: highlights, nearby transport, questions to ask.` } }],
  }),
);

await server.connect(new StdioServerTransport());

For remote use, wrap the same registrations in a factory passed to createMcpHandler and mount it with createMcpExpressApp() and toNodeHandler() as shown in lesson 5, with bearer-token verification in front (lesson 11). In production the viewingRequests map becomes a database table with a unique constraint on request_key.

Testing

The client package can drive your HTTP handler in process: create a StreamableHTTPClientTransport whose fetch calls handler.fetch, connect a Client, call tools and assert on structuredContent or isError. For stdio servers, StdioClientTransport (from @modelcontextprotocol/client/stdio) spawns the process. Use your usual runner (Vitest, Jest or node:test).

Connecting

  • Claude Code: claude mcp add property-leads -- node /absolute/path/dist/server.js
  • Cursor: .cursor/mcp.json with mcpServers → command/args.
  • VS Code: .vscode/mcp.json with servers → type: "stdio", command, args.

Config formats evolve; check each client's current docs.

Pitfalls

  • Registering tools on a shared instance outside the HTTP factory (state leaks between requests).
  • Logging with console.log in a stdio server (stdout is the protocol); use console.error.
  • Missing structuredContent when you declared an outputSchema (the SDK validates it).
  • Non-idempotent write tools; the stateless transport means clients re-issue broken requests.

Measuring success

Schema-validation error rate per tool, idempotency conflicts caught, p95 latency, and test coverage for each tool's happy and error paths.

Key takeaways

  • TypeScript SDK v2 splits into server, client and middleware packages and uses Standard Schema (e.g., Zod v4).
  • registerTool, registerResource and registerPrompt cover the three primitives; schema failures return isError results.
  • createMcpHandler builds a fresh server per request for stateless, horizontally scalable HTTP.
  • Make write tools idempotent with a client-supplied key.
  • Log to stderr in stdio servers and test handlers in process.

Check your understanding

Quick questions to lock in the lesson. They don’t count towards your certificate.

  1. Why does createMcpHandler take a factory rather than a single server instance?
  2. In a stdio TypeScript server, how should you log debug output?
  3. A retrying client might call request_viewing twice. What prevents duplicate records?

Put it into practice

Build the property-leads server, add one more tool of your own, write two in-process tests, and connect it to Claude Code or VS Code.

Enrol for free to save your progress

Reading is always free. Enrol to keep your place, take the final assessment and earn a verifiable certificate.