---
title: "Build an MCP server in TypeScript with the official SDK"
description: "Packages in SDK v2 The TypeScript SDK's v2 (released with the 2026-07-28 spec) splits into packages: - @modelcontextprotocol/server — build servers. -…"
url: https://optimizeall.com/learn/model-context-protocol-mcp/typescript-server-with-official-sdk
updated: 2026-10-05
---

Model Context Protocol (MCP): Connect AI to Your Tools and Data · Building MCP servers in Python and TypeScript · lesson 7 of 18 · 17 min

# Build an MCP server in TypeScript with the official SDK

## 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

```typescript
// 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.

## Video lecture: Build an MCP server in TypeScript with the official SDK

Lecture coming soon · 15 chapters · about 9 minutes. Read the full transcript below.

1. Build a TypeScript MCP server
2. Why TypeScript too?
3. SDK v2 packages
4. Core API
5. Stateless HTTP
6. Simple example: check_stock
7. Example: UK property agency
8. Code walkthrough
9. Testing and connecting
10. Pitfalls
11. Schema library choice
12. Deeper: out-of-hours viewings (illustrative)
13. Watch me do it: property-leads
14. Try this now
15. Recap

## Lecture transcript

### Build a TypeScript MCP server

Now let's build the same kind of server in TypeScript. You'll use the official SDK version two to create a property leads server with typed tools, structured output, an idempotent write, a resource template and a prompt, and you'll see how the same code scales over HTTP.

### Why TypeScript too?

Why learn the TypeScript SDK if you already know Python? Because many MCP servers ship where web teams already work: Node services, serverless functions and edge platforms. The TypeScript version two SDK is built on web standard request and response objects, which means one server can run on Node, Bun, Deno or an edge runtime with very little change. Think of it as a universal power adapter: the same device plugs into sockets around the world.

### SDK v2 packages

Version two of the TypeScript SDK arrived with the twenty twenty six spec and splits into packages. The server package builds servers. The client package builds clients. And thin middleware packages adapt to Node's HTTP module, Express, Fastify and Hono. It runs on Node, Bun and Deno, and schemas use the Standard Schema interface, so you can bring Zod version four, Valibot or ArkType. The older single package still gets fixes for a while, and there's a codemod to help you upgrade.

### Core API

The core API is three methods. Register tool takes a name, a config with title, description, input schema, optional output schema and annotations, and a handler that returns content, optional structured content and an optional error flag. Arguments that fail the schema come back as an error result without running your handler, and thrown errors are converted too. Register resource takes a name, a URI or template, metadata and a read callback. Register prompt takes a name, an arguments schema and a callback returning messages.

### Stateless HTTP

For HTTP, create MCP handler takes a factory: a function that builds a fresh server for every request. Nothing is held between requests, so the endpoint scales horizontally behind any load balancer. Keep expensive things like database pools and HTTP clients at module scope and close over them. The factory also receives request context, including verified auth information when you put token checks in front.

### Simple example: check_stock

A simple example. Register a tool called check stock that takes a product code matching a pattern like three letters and four digits. If someone passes an invalid code, the SDK rejects it before your handler runs and returns an error result explaining the pattern. The model reads that, fixes the code, and tries again. You wrote zero validation code. Now add an output schema with quantity and warehouse, and return structured content, and the SDK validates your output too.

### Example: UK property agency

Our example serves a UK property agency. Assistants need to search properties by area, price and bedrooms, register viewing requests, show each property as a resource, and offer a prepare viewing pack prompt. Search is read only and returns up to ten structured results. Request viewing is a write, but it's idempotent: the client supplies a request key and reuses it on retries, so a broken stream never creates duplicate viewings.

### Code walkthrough

Walk the code. The search schema describes each field, so the model knows area means a town or city, and bounds bedrooms between zero and ten. The output schema means clients receive validated structured results. Request viewing validates the date format with a regular expression, returns a helpful error for unknown property ids, and stores the request only once per key. The resource template reads one property by id. And the prompt tells the model to read that resource and draft a one page pack.

### Testing and connecting

Testing: the client package can drive your HTTP handler in process. Create a streamable HTTP client transport whose fetch calls your handler directly, connect a client, call tools and assert on structured content or the error flag. For standard I O servers, the standard I O client transport spawns the process. Then connect it to hosts: claude mcp add for Claude Code, a JSON file under the mcp servers key for Cursor, and a JSON file under the servers key with a type for VS Code. Formats evolve, so check each client's docs.

### Pitfalls

Watch for four pitfalls. Registering tools on a shared instance outside the factory, which leaks state between requests. Console dot log in a standard I O server, which corrupts the protocol, so use console dot error. Declaring an output schema but not returning structured content. And non idempotent writes, which the stateless transport makes risky because clients re issue broken requests.

### Schema library choice

Should you use Zod or another schema library? Any library that implements the Standard Schema interface works with version two, so pick what your team already uses. What matters more is how you use it: describe every field, set sensible bounds, and prefer enums for fixed choices. Those descriptions and constraints become the JSON schema the model reads, so they're part of your prompt, not just your validation.

### Deeper: out-of-hours viewings (illustrative)

Let's deepen the UK property agency example. The agency has branches in Manchester and Leeds, and buyers often message in the evening. With the server connected to its website assistant and to staff in Claude, buyers could search and request viewings any time. Because request viewing is idempotent, a flaky mobile connection that retried the call created one request, not three. Staff reviewed requests each morning in their CRM. In the first month, illustrative numbers, out of hours viewing requests grew noticeably and duplicate requests, a previous headache, disappeared. The prepare viewing pack prompt became a favorite with negotiators preparing for weekend viewings.

### Watch me do it: property-leads

Watch me do it. Let's walk through the property leads server. It starts with a small array of properties and a map for viewing requests. The search properties registration has a title, a description stating the ten result limit, an input schema built with Zod, where area is a described string and bedrooms is an integer between zero and ten, and an output schema describing the results. The handler filters by area, price and bedrooms, slices to ten, and returns both text and structured content. Request viewing requires a request key of at least eight characters and a date matching a year month day pattern. If the property doesn't exist, it returns is error true with a hint. If the key is new, it stores the request; if not, it does nothing, which makes retries safe. The resource template reads one property by id, and the prompt tells the model to read that resource first. Finally, the server connects over standard I O.

### Try this now

Try this now. Build the property leads server and add one tool of your own, maybe schedule callback with an idempotency key. Write two in process tests: one that calls search properties and checks the structured results, and one that calls request viewing twice with the same key and confirms only one record exists. Then connect it to Claude Code or VS Code and ask for three bedroom homes in Manchester under three hundred thousand pounds.

### Recap

To recap: the TypeScript SDK version two gives you three registration methods, schema validated tools with structured output, and a stateless HTTP factory that scales. Make writes idempotent and keep standard output clean. Your next step: build the property leads server, add one tool of your own, write two in process tests, and connect it to Claude Code or VS Code.

## 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.

## Try it

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.

- [Previous: Build an MCP server in Python with the official SDK](https://optimizeall.com/learn/model-context-protocol-mcp/python-server-with-official-sdk)
- [Next: Designing MCP tools and servers that LLMs use well](https://optimizeall.com/learn/model-context-protocol-mcp/designing-mcp-tools-for-llms)
- [All lessons of Model Context Protocol (MCP): Connect AI to Your Tools and Data](https://optimizeall.com/learn/model-context-protocol-mcp)
