mcpstepbystep

Tutorials Foundations

How to build an MCP server in TypeScript, step by step

Build an MCP server in TypeScript with the official SDK — McpServer, zod schemas, stdio transport, a clean build setup, and the stdout logging trap to avoid.

updated 2026-07-19 ⏱ 30–45 min

TypeScript is MCP’s home turf — the reference SDK, the Inspector, and most published servers are written in it, and if your server will ship to users via npx, TypeScript is the natural choice. We’ll rebuild the same dev-notes server from the Python tutorial: notes saved to a JSON file, exposed through two tools and a resource. Building the same thing twice is deliberate — the protocol stays identical, so you’ll see exactly which parts are MCP and which parts are language ceremony.

Step 1 — Scaffold the project

mkdir dev-notes-ts && cd dev-notes-ts
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D typescript @types/node
mkdir src

@modelcontextprotocol/sdk is the official SDK (stable 1.29.0 as of writing). Pin zod to v3: the SDK accepts zod 3 or 4 as a peer, but the documented idioms and most published examples use 3, and mixing zod major versions in one project causes baffling type errors. zod@3 keeps you on the well-trodden path.

Edit package.json — three fields matter:

{
  "name": "dev-notes-ts",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "dev-notes": "./build/index.js"
  },
  "scripts": {
    "build": "tsc"
  },
  "files": ["build"]
}

"type": "module" puts Node in ES-module mode, which the SDK requires. Without it, your built server dies with SyntaxError: Cannot use import statement outside a module.

Step 2 — Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

The pair that trips people up is module/moduleResolution: "Node16" — the SDK’s subpath imports (note the .js extensions in the import statements below, even from .ts files) only resolve correctly under Node16-style resolution.

Step 3 — Write the server

Create src/index.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const NOTES_FILE = path.join(
  path.dirname(fileURLToPath(import.meta.url)),
  "notes.json"
);

type Note = { id: number; text: string; tags: string[]; created: string };

async function loadNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await readFile(NOTES_FILE, "utf-8"));
  } catch {
    return [];
  }
}

async function saveNotes(notes: Note[]): Promise<void> {
  await writeFile(NOTES_FILE, JSON.stringify(notes, null, 2));
}

const server = new McpServer({ name: "dev-notes", version: "1.0.0" });

server.registerTool(
  "add_note",
  {
    title: "Add note",
    description:
      "Save a development note. Use when the user wants to remember a decision, a todo, or a code snippet for later.",
    inputSchema: {
      text: z.string().describe("The note text to save"),
      tags: z.array(z.string()).optional().describe("Optional tags"),
    },
  },
  async ({ text, tags }) => {
    const notes = await loadNotes();
    const note: Note = {
      id: notes.length + 1,
      text,
      tags: tags ?? [],
      created: new Date().toISOString(),
    };
    notes.push(note);
    await saveNotes(notes);
    return { content: [{ type: "text", text: `Saved note #${note.id}.` }] };
  }
);

server.registerTool(
  "search_notes",
  {
    title: "Search notes",
    description: "Search saved notes by keyword or tag. Returns matches, newest first.",
    inputSchema: {
      query: z.string().describe("Keyword or tag to search for"),
    },
  },
  async ({ query }) => {
    const q = query.toLowerCase();
    const hits = (await loadNotes())
      .filter(
        (n) =>
          n.text.toLowerCase().includes(q) ||
          n.tags.some((t) => t.toLowerCase() === q)
      )
      .reverse();
    const text = hits.length
      ? hits.map((n) => `#${n.id} [${n.tags.join(", ")}] ${n.text}`).join("\n")
      : `No notes matching "${query}".`;
    return { content: [{ type: "text", text }] };
  }
);

server.registerResource(
  "recent-notes",
  "notes://recent",
  {
    title: "Recent notes",
    description: "The ten most recent dev notes, as JSON",
    mimeType: "application/json",
  },
  async (uri) => {
    const notes = (await loadNotes()).slice(-10);
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(notes, null, 2),
        },
      ],
    };
  }
);

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("dev-notes MCP server running on stdio");
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

The shape to internalise: registerTool(name, config, handler). The inputSchema is a plain object of zod validators — the SDK converts it to JSON Schema for the client and gives your handler typed, validated arguments. As in Python, the description is what the model reads when deciding whether to call you; write it like documentation.

And the single most important line in the file is the logging one:

console.error("dev-notes MCP server running on stdio");

console.log writes to stdout — the same channel carrying JSON-RPC. One console.log and the client reads your log line as a protocol message, fails to parse it, and the session breaks. console.error goes to stderr, which clients capture into their log files. This is the #1 TypeScript MCP bug; you will see its exact symptoms in Troubleshooting below.

Step 4 — Build and smoke-test in the Inspector

npm run build

That emits build/index.js. Test it in isolation with the MCP Inspector before touching any client config — no install needed:

npx @modelcontextprotocol/inspector node build/index.js

The Inspector opens in your browser as a bare-bones MCP client. Connect, then: Tools tab → run add_note and search_notes; Resources tab → read notes://recent. When all three respond, the server is correct, and any remaining problem is client configuration.

Step 5 — Connect to Claude Desktop and Claude Code

Claude Desktop — edit the config (macOS ~/Library/Application Support/Claude/claude_desktop_config.json, Windows %AppData%\Claude\claude_desktop_config.json):

{
  "mcpServers": {
    "dev-notes-ts": {
      "command": "node",
      "args": ["/Users/you/projects/dev-notes-ts/build/index.js"]
    }
  }
}

Windows, with JSON-escaped backslashes:

{
  "mcpServers": {
    "dev-notes-ts": {
      "command": "node",
      "args": ["C:\\Users\\you\\projects\\dev-notes-ts\\build\\index.js"]
    }
  }
}

Absolute path to build/index.js, always — the client’s working directory is not your project. If node itself isn’t found (version managers like nvm confuse GUI apps), use the absolute path from which node too. Fully restart Claude Desktop (quit from menu bar/tray), and the tools appear.

Claude Code is one line:

claude mcp add --transport stdio dev-notes-ts -- node /Users/you/projects/dev-notes-ts/build/index.js

Verify with claude mcp list, or /mcp inside a session. Configs for Cursor, VS Code, ChatGPT, Windsurf, and Gemini CLI are in the cross-client connection guide — same server, seven clients.

Troubleshooting

Unexpected tokenis not valid JSON in the client logs The signature of a stdout write corrupting the protocol stream — a console.log, or a dependency printing a banner. Grep your code for console.log and replace with console.error. Every byte your server writes to stdout must be JSON-RPC; everything else goes to stderr.

SyntaxError: Cannot use import statement outside a module Node ran your build as CommonJS. Add "type": "module" to package.json and rebuild. If you can’t switch the package, set module/moduleResolution accordingly — but for MCP servers, ESM is the path of least resistance.

Error [ERR_MODULE_NOT_FOUND]: Cannot find module Three usual suspects: you forgot npm run build (no build/index.js exists); the path in the client config is relative or wrong — it must be absolute; or an import in your source lacks the .js extension, which Node16 resolution requires even in TypeScript files.

spawn node ENOENT (Windows especially) The client can’t find node because GUI apps don’t inherit your shell PATH — this hits nvm/fnm users hardest. Use the absolute path from which node / where.exe node as the command. On Windows, remember JSON needs \\ in every path, and .cmd shims like npx must be launched via "command": "cmd", "args": ["/c", "npx", ...].

MCP error -32000: Connection closed The process exited before the handshake finished. Run the config command by hand — node /abs/path/build/index.js — and read the stack trace on stderr. If it runs fine by hand but dies in Docker, you forgot docker run -i: with stdin closed, a stdio server exits immediately.

Next step

You now have the same server in two languages — time to point every client at it. The next page is the cross-client configuration reference: Claude, Cursor, VS Code, ChatGPT, Windsurf, and Gemini CLI, with copy-paste configs for each.

newsletter

One practical MCP guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.