Step-by-Step: Build an MCP Server in TypeScript
Each step has a Goal, what to Do, and how to Verify it. Track progress with the MCP checklist. The finished code is in templates/typescript-server/.
Running example: a notes server that lets the agent search and add notes stored in a local JSON file.
These snippets use the v2 TypeScript SDK (
@modelcontextprotocol/server, Zod 4). v2 is the stable line and implements the 2026-07-28 spec. The old monolithic@modelcontextprotocol/sdkpackage is v1. If you’re maintaining v1 code, the differences are listed at the end of this guide.
Step 1: Define the capabilities
Section titled “Step 1: Define the capabilities”- Goal: A short list of task-shaped tools.
- Do:
- List the user jobs, for example: “find notes about X” and “save this as a note”.
- Map one tool to each job, not one per API endpoint:
search_notes,add_note. - For each tool, decide its inputs, its output, and whether it’s read-only or destructive.
- Read Tool design before you finalise names and descriptions.
- Verify: Every tool maps to a real request, and you have fewer than about 10 tools.
Step 2: Scaffold the project
Section titled “Step 2: Scaffold the project”-
Goal: A buildable TypeScript project.
-
Do:
Terminal window mkdir notes-server && cd notes-servernpm init -ynpm install @modelcontextprotocol/server zodnpm install -D typescript @types/nodenpx tsc --initIn
package.json, set"type": "module", a"bin"entry, and"build": "tsc". Intsconfig.json, set"module": "Node16","moduleResolution": "Node16","target": "ES2022","outDir": "build","rootDir": "src","types": ["node"]. TypeScript 6+ needs thetypesentry. The template has all of this done already. -
Verify:
npm run buildsucceeds on an emptysrc/index.ts.
Step 3: Create the server and transport
Section titled “Step 3: Create the server and transport”-
Goal: A server that completes the MCP handshake.
-
Do: In
src/index.ts:#!/usr/bin/env nodeimport { McpServer } from "@modelcontextprotocol/server";import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";const server = new McpServer({ name: "notes", version: "0.1.0" });// tools, resources, prompts go hereconst transport = new StdioServerTransport();await server.connect(transport);console.error("notes MCP server running on stdio"); // stderr, never stdout -
Verify:
npm run build, thennpx @modelcontextprotocol/inspector node build/index.js. The Inspector connects.
Step 4: Add a read-only tool
Section titled “Step 4: Add a read-only tool”-
Goal: The model can call your first tool.
-
Do:
import { z } from "zod";server.registerTool("search_notes",{title: "Search notes",description:"Search saved notes by keyword in title or body. Returns up to `limit` matches, newest first. Use before add_note to avoid duplicates.",inputSchema: z.object({query: z.string().min(1).describe("Keyword or phrase, case-insensitive"),limit: z.number().int().min(1).max(20).default(5).describe("Max results"),}),annotations: { readOnlyHint: true, openWorldHint: false },},async ({ query, limit }) => {const hits = (await loadNotes()).filter((n) => `${n.title} ${n.body}`.toLowerCase().includes(query.toLowerCase())).slice(0, limit);return {content: [{type: "text",text: hits.length? hits.map((n) => `- ${n.title} (${n.created}): ${n.body.slice(0, 200)}`).join("\n"): `No notes match "${query}". Try a broader keyword.`,}],};},); -
Verify: In the Inspector, go to Tools → List, then run
search_notes. You get a sensible result, including when nothing matches.
Step 5: Add a write tool, with validation and errors
Section titled “Step 5: Add a write tool, with validation and errors”-
Goal: Safe state changes and helpful failures.
-
Do:
server.registerTool("add_note",{title: "Add note",description: "Save a new note. Fails if a note with the same title exists.",inputSchema: z.object({title: z.string().min(1).max(120),body: z.string().min(1).max(10_000),}),annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },},async ({ title, body }) => {const notes = await loadNotes();if (notes.some((n) => n.title.toLowerCase() === title.toLowerCase())) {return {isError: true,content: [{ type: "text", text: `A note titled "${title}" already exists. Choose a different title or search for it first.` }],};}notes.push({ title, body, created: new Date().toISOString() });await saveNotes(notes);return { content: [{ type: "text", text: `Saved note "${title}".` }] };},);- Validation comes free from the Zod schema; invalid input is rejected before your handler runs.
- Return
isError: truefor expected failures, with a message that tells the model what to do next. - Let unexpected exceptions throw; the SDK turns them into errors.
-
Verify: Add a note, add the same note again (you get a clear error), then search for it.
Step 6: Add resources and prompts (optional)
Section titled “Step 6: Add resources and prompts (optional)”-
Goal: Expose read-only context and user-invoked templates.
-
Do:
server.registerResource("all-notes","notes://all",{ title: "All notes", description: "Every saved note as JSON", mimeType: "application/json" },async (uri) => ({ contents: [{ uri: uri.href, text: JSON.stringify(await loadNotes(), null, 2) }] }),);server.registerPrompt("weekly-review",{ title: "Weekly review", description: "Summarise this week's notes", argsSchema: z.object({ focus: z.string().optional() }) },({ focus }) => ({messages: [{ role: "user", content: { type: "text", text: `Search my notes from this week and write a weekly review${focus ? ` focused on ${focus}` : ""}.` } }],}),); -
Verify: They show up on the Inspector’s Resources and Prompts tabs.
Step 7: Configuration and secrets
Section titled “Step 7: Configuration and secrets”-
Goal: No hard-coded paths or keys.
-
Do: Read configuration from environment variables with safe defaults, and fail fast with a clear stderr message if a required one is missing:
const NOTES_FILE = process.env.NOTES_FILE ?? `${process.env.HOME ?? process.env.USERPROFILE}/.notes-mcp.json`; -
Verify: Run it with the variable unset, set, and set to an invalid path. Each case behaves predictably.
Step 8: Test
Section titled “Step 8: Test”- Goal: Automated confidence.
- Do:
- Test manually in the Inspector (every tool, including the error paths).
- Run the automated handshake smoke test:
node ../../../qa/mcp-smoke.mjs node build/index.js(from the template folder), ornpm run qa:templatesfrom the guide root. - Unit-test your handler logic separately from MCP by exporting the pure functions.
- See Testing & debugging.
- Verify: The smoke test prints
PASS, and the unit tests pass.
Step 9: Connect to your clients
Section titled “Step 9: Connect to your clients”- Goal: It works in real hosts.
- Do: Follow Connect to clients. Use the absolute path to
build/index.js. - Verify: Cursor shows the server as green with 2 tools, and asking “search my notes for MCP” calls
search_notes.
Step 10: Package and ship
Section titled “Step 10: Package and ship”- Goal: Others can install it.
- Do:
- npm: publish with a
binentry, so users can configure"command": "npx", "args": ["-y", "your-package"]. - Claude Desktop: package it as an MCP Bundle (
.mcpb) withnpx @anthropic-ai/mcpb init/packfor one-click install. - Remote: switch to Streamable HTTP and deploy it (see Security & production).
- Write a README covering its tools, required environment variables, and config snippets for each client.
- npm: publish with a
- Verify: A colleague installs it from the README alone.
Migrating from v1 (@modelcontextprotocol/sdk)
Section titled “Migrating from v1 (@modelcontextprotocol/sdk)”| v1 | v2 |
|---|---|
npm i @modelcontextprotocol/sdk |
npm i @modelcontextprotocol/server (and @modelcontextprotocol/client for clients) |
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" |
import { McpServer } from "@modelcontextprotocol/server" |
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" |
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio" |
inputSchema: { a: z.string() } (raw shape) |
inputSchema: z.object({ a: z.string() }) (raw shape still works but is deprecated) |
| Zod 3 | Zod 4 |
HTTP: StreamableHTTPServerTransport wired by hand |
Framework adapters: @modelcontextprotocol/express, /hono, /fastify, /node |
v2 servers still accept the older initialize opening from 2025-era clients by default, so existing hosts keep working. The full migration guide is at https://ts.sdk.modelcontextprotocol.io/v2/migration/.