Skip to content
Claudekit.ai

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 primitiveWhat ours doesWho triggers it
Tool add_noteSaves a noteClaude, when it decides to
Resource notes://allExposes all notes as textThe client or user attaches it
Prompt summarize_notesTemplate asking Claude to summariseThe 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 fixesStable, recommended
Node18+20+
Register a toolserver.tool(...) or registerToolregisterTool only
Schemaszod 3 or 4, raw shapeszod 4.2+, z.object()
stdio startupserver.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

  1. Never write to stdout in a stdio server. stdout carries the JSON-RPC protocol; a stray console.log corrupts it. Use console.error.
  2. Return a fresh server from the factory. serveStdio negotiates 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.
  3. Descriptions are prompts. Claude decides when to call add_note from its description and 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:

ScopeFlagStored inShared
Local (default)none~/.claude.jsonNo, this project only
Project--scope project.mcp.json in the repoYes, teammates approve it on first use
User--scope user~/.claude.jsonNo, 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.log in a stdio server: random disconnects and parse errors. Use console.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 .ts source.
  • 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 --env or environment variable expansion instead of committing tokens.

Shortcuts

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.

Get the weekly Claude digest

New skills, MCP servers, prompts and Claude updates — one short email a week. No spam, unsubscribe anytime.

The newsletter is launching soon. Follow the RSS feed in the meantime.