intermediate11 min readPart 2 of 3

Build an MCP Server in TypeScript (and Connect It to Claude)

Step-by-step: build a working Model Context Protocol server in TypeScript with the official SDK, expose tools with zod schemas, and wire it into Claude Desktop and Claude Code.

TechLogHub
·

Prerequisites

  • •Node.js 18+ installed
  • •Basic TypeScript
  • •Part 1 of this series (MCP concepts)

What You'll Learn

  • Scaffold an MCP server with the official TypeScript SDK
  • Define tools with zod-validated arguments
  • Run the server over stdio and debug it with the MCP Inspector
  • Register the server with Claude Desktop and Claude Code

What We're Building

A small but genuinely useful MCP server called devnotes: it lets an AI assistant save, list, and search short project notes kept in a local JSON file. It exercises the core skills — tools, validated arguments, and persistence — without drowning you in domain logic. By the end it runs inside Claude Desktop and Claude Code.

1. Scaffold the Project

mkdir devnotes-mcp && cd devnotes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist

Two package.json details matter: MCP SDK is ESM, and hosts will run your compiled entry point.

{
  "type": "module",
  "bin": { "devnotes-mcp": "dist/index.js" },
  "scripts": { "build": "tsc" }
}

2. Create the Server

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";

const NOTES_FILE = path.join(os.homedir(), ".devnotes.json");

type Note = { id: number; text: string; tag: string; createdAt: string };

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

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

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

3. Define Tools

Each tool = name + description + zod argument schema + handler. The descriptions are what the model reads — write them like documentation for a sharp colleague:

server.tool(
  "add_note",
  "Save a short project note. Use a one-word tag like 'bug', 'idea', or 'todo'.",
  {
    text: z.string().min(1).max(500).describe("The note content"),
    tag: z.string().default("general").describe("One-word category tag"),
  },
  async ({ text, tag }) => {
    const notes = await loadNotes();
    const note: Note = {
      id: (notes.at(-1)?.id ?? 0) + 1,
      text,
      tag,
      createdAt: new Date().toISOString(),
    };
    notes.push(note);
    await saveNotes(notes);
    return {
      content: [{ type: "text", text: Saved note #${note.id} [${tag}] }],
    };
  },
);

server.tool( "search_notes", "Search saved notes by text or tag. Returns matching notes newest-first.", { query: z.string().describe("Text to search for, or a tag name"), }, async ({ query }) => { const q = query.toLowerCase(); const notes = (await loadNotes()) .filter((n) => n.text.toLowerCase().includes(q) || n.tag === q) .reverse(); const body = notes.length ? notes .map((n) => #${n.id} [${n.tag}] ${n.text} (${n.createdAt.slice(0, 10)})) .join("\n") : "No notes matched."; return { content: [{ type: "text", text: body }] }; }, );

Handlers return a content array — here plain text, but images and resource links are also supported. To signal a failure the model should see, return isError: true with an explanatory message instead of throwing.

4. Connect the Transport

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("devnotes MCP server running on stdio");

One classic gotcha: never write logs to stdout in a stdio server — stdout carries the protocol. Use console.error, which goes to stderr.

Build it:

npm run build

5. Debug with the MCP Inspector

The Inspector is a local UI that connects to your server and lets you call tools by hand — the fastest feedback loop you'll get:

npx @modelcontextprotocol/inspector node dist/index.js

Open the printed URL, list tools, and try add_note. If the schema, description, or output looks wrong here, it will look wrong to the model too — fix it here first.

6. Register with Claude

Claude Code (CLI)

claude mcp add devnotes -- node /absolute/path/to/devnotes-mcp/dist/index.js

Claude Desktop

Add the server to claude_desktop_config.json (Settings → Developer → Edit Config):

{
  "mcpServers": {
    "devnotes": {
      "command": "node",
      "args": ["/absolute/path/to/devnotes-mcp/dist/index.js"]
    }
  }
}

Restart Claude Desktop, then try: "Save a note that the staging deploy needs the new env var, tag it todo" — you'll see the tool-approval prompt, and after approving, the note lands in ~/.devnotes.json.

Where to Take It

  • Point the same pattern at real systems: wrap an internal REST API, a SQLite database, or your ticketing system.
  • Add a resource (notes://all) so hosts can attach the full notes list as context without a tool call.
  • Package with a bin entry and publish to npm so teammates can run it via npx.

  • Next: the same server in Python with FastMCP, plus running it as a remote HTTP service — Part 3: MCP Servers in Python with FastMCP.

    mcptypescriptnodejsclaudeai

    Keep the references coming

    One email a week: new and trending developer tools, fresh comparisons, and what shipped. Unsubscribe in one click.