Skip to content

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/sdk package is v1. If you’re maintaining v1 code, the differences are listed at the end of this guide.


  • Goal: A short list of task-shaped tools.
  • Do:
    1. List the user jobs, for example: “find notes about X” and “save this as a note”.
    2. Map one tool to each job, not one per API endpoint: search_notes, add_note.
    3. For each tool, decide its inputs, its output, and whether it’s read-only or destructive.
    4. 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.
  • Goal: A buildable TypeScript project.

  • Do:

    Terminal window
    mkdir notes-server && cd notes-server
    npm init -y
    npm install @modelcontextprotocol/server zod
    npm install -D typescript @types/node
    npx tsc --init

    In package.json, set "type": "module", a "bin" entry, and "build": "tsc". In tsconfig.json, set "module": "Node16", "moduleResolution": "Node16", "target": "ES2022", "outDir": "build", "rootDir": "src", "types": ["node"]. TypeScript 6+ needs the types entry. The template has all of this done already.

  • Verify: npm run build succeeds on an empty src/index.ts.

  • Goal: A server that completes the MCP handshake.

  • Do: In src/index.ts:

    #!/usr/bin/env node
    import { McpServer } from "@modelcontextprotocol/server";
    import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
    const server = new McpServer({ name: "notes", version: "0.1.0" });
    // tools, resources, prompts go here
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("notes MCP server running on stdio"); // stderr, never stdout
  • Verify: npm run build, then npx @modelcontextprotocol/inspector node build/index.js. The Inspector connects.

  • 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: true for 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.

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

  • Goal: Automated confidence.
  • Do:
    1. Test manually in the Inspector (every tool, including the error paths).
    2. Run the automated handshake smoke test: node ../../../qa/mcp-smoke.mjs node build/index.js (from the template folder), or npm run qa:templates from the guide root.
    3. Unit-test your handler logic separately from MCP by exporting the pure functions.
    4. See Testing & debugging.
  • Verify: The smoke test prints PASS, and the unit tests pass.
  • 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.
  • Goal: Others can install it.
  • Do:
    • npm: publish with a bin entry, so users can configure "command": "npx", "args": ["-y", "your-package"].
    • Claude Desktop: package it as an MCP Bundle (.mcpb) with npx @anthropic-ai/mcpb init / pack for 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.
  • 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/.