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.
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
Model Context Protocol (MCP)
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 distTwo 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 build5. 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.jsOpen 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.jsClaude 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
notes://all) so hosts can attach the full notes list as context without a tool call.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.

