Build an MCP Server in TypeScript (SDK v2 Tutorial)
Build your first MCP server in TypeScript with the v2 SDK: setup, a tool, a resource and a prompt, testing in MCP Inspector, then connecting it to Claude Code.
By Claudekit Team Published 12 min read
An MCP server is the cleanest way to give Claude access to your own API, database or internal tool. This tutorial builds a small but real server in TypeScript with the v2 SDK: one tool, one resource and one prompt, tested in the MCP Inspector and connected to Claude Code. About 80 lines of code.
Heads-up if you’re following older tutorials: the TypeScript SDK changed significantly in 2026. Code below follows the current official quickstart and the typescript-sdk repository docs as of October 2026. Tested with @modelcontextprotocol/server 2.4.0, zod 4.6 and Node 22: the stdio server below compiled and answered tools/list, tools/call and resources/read, and the HTTP variant answered initialize.
What you’re building
A notes server that lets Claude save and list short team notes:
| MCP primitive | What ours does | Who triggers it |
|---|---|---|
Tool add_note | Saves a note | Claude, when it decides to |
Resource notes://all | Exposes all notes as text | The client or user attaches it |
Prompt summarize_notes | Template asking Claude to summarise | The user picks it, e.g. as a slash command |
New to MCP itself? Start with what MCP is, then the MCP servers for Claude guide.
SDK v1 vs v2 in one table
v1 (@modelcontextprotocol/sdk) | v2 (@modelcontextprotocol/server) | |
|---|---|---|
| Status (Oct 2026) | Legacy line, maintenance fixes | Stable, recommended |
| Node | 18+ | 20+ |
| Register a tool | server.tool(...) or registerTool | registerTool only |
| Schemas | zod 3 or 4, raw shapes | zod 4.2+, z.object() |
| stdio startup | server.connect(new StdioServerTransport()) | serveStdio(createServer) |
Migrating existing code? The SDK ships a codemod: npx @modelcontextprotocol/codemod@latest v1-to-v2 .
Step 1: Set up the project
You need Node.js 20 or later.
mkdir notes-mcp && cd notes-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src && touch src/index.ts
Make sure the installed zod is 4.2 or later (npm ls zod). Older zod 4 releases lose your .describe() text, and zod 3 isn’t supported.
Edit package.json to add:
{
"type": "module",
"bin": { "notes-mcp": "./build/index.js" },
"scripts": { "build": "tsc && chmod 755 build/index.js" },
"files": ["build"]
}
On Windows, drop && chmod 755 build/index.js from the build script.
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Step 2: Write the server
src/index.ts:
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
// Shared state lives outside the factory so every server instance sees it.
const notes: { text: string; createdAt: string }[] = [];
// serveStdio calls this whenever it needs an instance, so return a fresh one each time.
function createServer() {
const server = new McpServer({ name: "notes", version: "1.0.0" });
// Tool: Claude can call this.
server.registerTool(
"add_note",
{
description: "Save a short team note. Use when the user asks to remember or record something.",
inputSchema: z.object({
text: z.string().min(1).max(500).describe("The note text"),
}),
},
async ({ text }) => {
notes.push({ text, createdAt: new Date().toISOString() });
return { content: [{ type: "text", text: `Saved note #${notes.length}` }] };
},
);
// Resource: read-only data the client can attach.
server.registerResource(
"all-notes",
"notes://all",
{ title: "All notes", description: "Every saved note", mimeType: "text/plain" },
async (uri) => ({
contents: [
{
uri: uri.href,
text: notes.map((n, i) => `${i + 1}. [${n.createdAt}] ${n.text}`).join("\n") || "No notes yet.",
},
],
}),
);
// Prompt: a reusable template the user can pick.
server.registerPrompt(
"summarize_notes",
{
title: "Summarize notes",
description: "Summarise saved notes for a given audience",
argsSchema: z.object({
audience: z.string().describe("Who the summary is for, e.g. 'the team standup'"),
}),
},
({ audience }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Read the notes://all resource and summarise it for ${audience} in five bullets.`,
},
},
],
}),
);
return server;
}
serveStdio(createServer, {
onerror: (error) => console.error("notes MCP server error:", error),
});
console.error("notes MCP server running on stdio");
Build it:
npm run build
Three rules hidden in that code
- Never write to stdout in a stdio server. stdout carries the JSON-RPC protocol; a stray
console.logcorrupts it. Useconsole.error. - Return a fresh server from the factory.
serveStdionegotiates the protocol version with each client and may create instances as needed. Keep shared state (here,notes) outside the factory. In a real server that state would be a database or API, not memory. - Descriptions are prompts. Claude decides when to call
add_notefrom itsdescriptionand the field.describe()text. Write them like instructions.
The official quickstart notes that wiring a server by hand with server.connect(new StdioServerTransport()) still compiles but only serves 2025-era clients, which is why serveStdio is the default now.
Step 3: Test it in MCP Inspector
The MCP Inspector is a browser UI for calling your server without Claude. It needs Node 22.19 or later.
npx @modelcontextprotocol/inspector node build/index.js
Click Connect, open Tools, run add_note, then read notes://all under Resources. For a quick scripted check, the CLI mode lists tools:
npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern --method tools/list
The quickstart recommends repeating that with --protocol-era legacy so you know older clients work too.
Step 4: Connect it to Claude Code
claude mcp add --transport stdio notes -- node /ABSOLUTE/PATH/notes-mcp/build/index.js
The -- separates Claude’s own options from the server command. Choose a scope:
| Scope | Flag | Stored in | Shared |
|---|---|---|---|
| Local (default) | none | ~/.claude.json | No, this project only |
| Project | --scope project | .mcp.json in the repo | Yes, teammates approve it on first use |
| User | --scope user | ~/.claude.json | No, all your projects |
Check it with claude mcp list, or /mcp inside a session. Then try: “Remember that the staging DB moves to eu-west-1 on Friday.” Claude should call add_note. Your prompt shows up in the / menu as an MCP prompt; see the slash commands cheatsheet for how those appear.
Claude Desktop
Add the same server to claude_desktop_config.json:
{
"mcpServers": {
"notes": {
"command": "node",
"args": ["/ABSOLUTE/PATH/notes-mcp/build/index.js"]
}
}
}
Then fully quit and reopen the app. Our Claude Desktop MCP setup guide covers the config file location and logs.
Going remote: Streamable HTTP
For a server that teammates or claude.ai connect to over the network, swap stdio for HTTP. Install @modelcontextprotocol/node, then:
import { createServer } from "node:http";
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
const handler = createMcpHandler(() => {
const server = new McpServer({ name: "notes", version: "1.0.0" });
// register the same tools, resources and prompts here
return server;
});
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, "127.0.0.1");
The endpoint is http://127.0.0.1:3000/mcp; add it with claude mcp add --transport http notes http://127.0.0.1:3000/mcp. The SDK docs stress that the handler does not check Host, Origin or auth on its own, so keep those guards, and add real authentication before exposing it beyond localhost. Logging to stdout is fine in HTTP servers.
Common mistakes
console.login a stdio server: random disconnects and parse errors. Useconsole.error.- Relative paths in
claude mcp add: the client may start the server from another directory. Use absolute paths. - Forgetting to rebuild: Claude runs
build/index.js, not your.tssource. - Vague descriptions: Claude never calls a tool it doesn’t understand. Say what it does and when to use it.
- Too many tools: each one adds definitions Claude has to choose between. Start with three or fewer.
- Secrets in
.mcp.json: use--envor environment variable expansion instead of committing tokens.
Shortcuts
- Anthropic’s mcp-builder skill walks Claude through building a server for you, in TypeScript or Python.
- Look at well-built servers before designing yours: GitHub MCP server, Playwright MCP and the reference servers. Our best MCP servers list has more.
MCP servers in your Claude kit
A custom server is the “hands” of your Claude kit. Pair it with a skill that explains when to use it for your workflow, and ship both in a plugin so teammates install them together.
Frequently asked questions
Which npm package do I use to build an MCP server in TypeScript?
As of October 2026 the stable SDK line is v2, published as @modelcontextprotocol/server (with @modelcontextprotocol/client for clients). The older @modelcontextprotocol/sdk package is the v1 line and still receives fixes for a period, but new servers should start on v2.
Why does my stdio MCP server break when I add console.log?
In a stdio server, stdout carries the JSON-RPC messages between client and server. Anything else written to stdout corrupts that stream. Log with console.error, which writes to stderr, or use the SDK's logging support.
Can I still use server.tool() from older tutorials?
No. The v2 SDK removed the variadic .tool(), .prompt() and .resource() methods. Use registerTool, registerPrompt and registerResource, and wrap input schemas in z.object(). An official codemod helps migrate v1 code.
How do I connect my server to Claude Code?
Build it, then run claude mcp add --transport stdio <name> -- node /absolute/path/build/index.js. Add --scope project to write it to a shared .mcp.json, or --scope user to use it in every project. Check it with claude mcp list or /mcp.