Build Your First MCP Server: Step-by-Step TypeScript Tutorial (2026)
Complete guide to building a Model Context Protocol server in TypeScript. From zero to a working MCP server in 30 minutes. Includes tools, resources, prompts, and integration with Claude Desktop.
The Model Context Protocol (MCP) is the open standard for connecting AI models to external tools and data. Originally created by Anthropic in late 2024, MCP has been adopted by Claude, ChatGPT, Cursor, Windsurf, VS Code Copilot, JetBrains IDEs, and dozens of other AI tools. As of early 2026, there are over 8,600 community-built MCP servers — and building your own is easier than you might think.
In this tutorial, you will build a fully working MCP server from scratch using TypeScript. You will start with a minimal server, add tools, resources, and prompts, connect it to Claude Desktop, and then build a real-world notes server that demonstrates everything MCP can do. By the end, you will have a production-ready pattern you can adapt to any use case.
What Is MCP? A Quick Refresher
MCP follows a client-server architecture. An AI application (the client) connects to one or more MCP servers, each of which exposes capabilities through three primitives:
- Tools — Actions the AI can execute. A GitHub MCP server might expose
create_issueorsearch_repos. The AI model decides when to call these based on user intent. - Resources — Read-only data the AI can access. Files, database records, configuration — anything the model might need for context.
- Prompts — Reusable prompt templates the server provides. Pre-built instructions for specific workflows like code review or summarization.
Communication happens over one of two transports:
- stdio — The server runs as a local child process. This is the most common transport for desktop apps like Claude Desktop and Cursor.
- Streamable HTTP (formerly SSE) — The server runs remotely and communicates over HTTP. This is the standard for production and team deployments.
The protocol uses JSON-RPC 2.0 under the hood. Clients discover available tools, resources, and prompts dynamically — no hardcoded endpoints required.
For a deeper dive into the protocol, architecture, and ecosystem, read our What is MCP? Complete Guide.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed (
node --versionto check) - TypeScript knowledge (basic familiarity is fine)
- A text editor (VS Code, Cursor, or similar)
- Claude Desktop installed (optional, for testing — you can also use the MCP Inspector)
You do not need prior MCP experience. This tutorial starts from zero.
Step 1: Project Setup
Create a new directory and initialize the project:
mkdir my-mcp-server
cd my-mcp-server
npm init -y
Install the MCP SDK and its dependencies:
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
The @modelcontextprotocol/sdk package is the official TypeScript SDK for building MCP servers (and clients). zod is used for input validation — the SDK uses Zod schemas to define tool parameters, which means you get automatic type checking and descriptive error messages for free.
Initialize TypeScript:
npx tsc --init
Now update your tsconfig.json with these settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
Key points: module and moduleResolution must be set to Node16 (or NodeNext) because the MCP SDK uses ES module exports with subpath imports like @modelcontextprotocol/sdk/server/mcp.js.
Update package.json to add the type field and a build script:
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Create the source directory:
mkdir src
Your project structure should now look like this:
my-mcp-server/
src/
dist/ (created after build)
package.json
tsconfig.json
node_modules/
Step 2: Create a Basic MCP Server
Create src/index.ts with a minimal MCP server:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Create the server instance
const server = new McpServer({
name: "my-first-server",
version: "1.0.0",
});
// Add a simple tool
server.tool(
"get_weather",
"Get current weather for a city",
{ city: z.string().describe("City name, e.g. San Francisco") },
async ({ city }) => ({
content: [{
type: "text",
text: `Weather in ${city}: 72°F, sunny with light clouds.`
}]
})
);
// Connect to stdio transport and start the server
const transport = new StdioServerTransport();
await server.connect(transport);
Let’s break this down:
McpServeris the main class. You give it a name and version, which clients use to identify your server.server.tool()registers a tool. It takes four arguments: the tool name, a description (which the AI reads to decide when to use the tool), a Zod schema for input validation, and an async handler function.StdioServerTransportmeans the server communicates over standard input/output — the transport used by Claude Desktop, Cursor, and most local clients.server.connect(transport)starts listening for incoming JSON-RPC messages.
Build and verify it compiles:
npx tsc
If there are no errors, you have a working MCP server. It does not do much yet — let’s fix that.
Step 3: Add More Tools
A useful MCP server typically exposes multiple tools. Let’s add a tool that performs a calculation and one that generates a UUID:
import { randomUUID } from "node:crypto";
// Add below the get_weather tool
server.tool(
"calculate",
"Perform a basic math calculation",
{
operation: z.enum(["add", "subtract", "multiply", "divide"])
.describe("The math operation to perform"),
a: z.number().describe("First operand"),
b: z.number().describe("Second operand"),
},
async ({ operation, a, b }) => {
let result: number;
switch (operation) {
case "add": result = a + b; break;
case "subtract": result = a - b; break;
case "multiply": result = a * b; break;
case "divide":
if (b === 0) {
return {
content: [{ type: "text", text: "Error: Division by zero" }],
isError: true,
};
}
result = a / b;
break;
}
return {
content: [{
type: "text",
text: `${a} ${operation} ${b} = ${result!}`
}]
};
}
);
server.tool(
"generate_uuid",
"Generate a random UUID v4",
{},
async () => ({
content: [{
type: "text",
text: randomUUID()
}]
})
);
Notice a few patterns here:
- Enum validation:
z.enum(["add", "subtract", "multiply", "divide"])restricts the input to specific values. The AI sees these options and picks the right one. - Error handling: When the tool encounters an error (division by zero), it returns
isError: truein the response. This tells the AI the operation failed so it can handle it gracefully. - Empty schema: The
generate_uuidtool takes no parameters, so its schema is an empty object{}.
Step 4: Add Resources
Resources expose read-only data to the AI. Unlike tools (which perform actions), resources provide context — configuration files, database records, documentation, or any structured data.
// Static resource — always returns the same URI
server.resource(
"config",
"config://app",
async (uri) => ({
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
version: "1.0.0",
environment: "production",
features: {
darkMode: true,
notifications: true,
maxUploadSize: "10MB"
}
}, null, 2)
}]
})
);
The first argument is a display name, the second is the resource URI (which clients use to request it), and the third is the handler that returns the data.
You can also create resource templates for dynamic data. These use URI templates (RFC 6570) with parameters:
// Dynamic resource with a parameter
server.resource(
"user-profile",
"users://{userId}/profile",
async (uri, { userId }) => {
// In a real server, you would fetch this from a database
const profiles: Record<string, { name: string; role: string }> = {
"1": { name: "Alice", role: "admin" },
"2": { name: "Bob", role: "developer" },
};
const profile = profiles[userId as string];
if (!profile) {
throw new Error(`User ${userId} not found`);
}
return {
contents: [{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(profile, null, 2)
}]
};
}
);
When an AI client connects to your server, it discovers these resources and can read them to gain context before taking actions.
Step 5: Add Prompts
Prompts are reusable templates that guide AI behavior. They are useful when you want to package specific workflows — code review, data analysis, report generation — that users can invoke directly.
server.prompt(
"code_review",
"Review code for best practices and potential issues",
{
language: z.string().describe("Programming language of the code"),
code: z.string().describe("The code to review"),
},
({ language, code }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: [
`Please review the following ${language} code for:`,
"",
"1. Bugs and potential runtime errors",
"2. Security vulnerabilities",
"3. Performance issues",
"4. Code style and best practices",
"5. Suggestions for improvement",
"",
"Here is the code:",
"",
"```" + language,
code,
"```",
"",
"Provide your review in a structured format with severity levels (critical, warning, info) for each finding."
].join("\n")
}
}]
})
);
server.prompt(
"explain_error",
"Explain an error message and suggest fixes",
{
error: z.string().describe("The error message or stack trace"),
context: z.string().optional().describe("Additional context about what was happening"),
},
({ error, context }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: [
"I encountered this error:",
"",
"```",
error,
"```",
context ? `\nContext: ${context}` : "",
"",
"Please explain:",
"1. What this error means",
"2. What likely caused it",
"3. Step-by-step instructions to fix it",
"4. How to prevent it in the future",
].join("\n")
}
}]
})
);
Prompts differ from tools in an important way: tools are invoked by the AI model during a conversation, while prompts are typically selected by the user (or the client application) to start a specific workflow. Think of prompts as pre-built conversation starters.
Step 6: Connect to Claude Desktop
Now let’s connect your server to Claude Desktop.
Build the project
npx tsc
This outputs the compiled JavaScript to dist/index.js.
Configure Claude Desktop
Open your Claude Desktop configuration file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Add your server to the mcpServers object:
{
"mcpServers": {
"my-first-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"]
}
}
}
Replace /absolute/path/to/my-mcp-server/dist/index.js with the actual absolute path to your compiled server file.
Restart Claude Desktop
Close and reopen Claude Desktop. You should see a hammer icon in the chat input area. Click it to see the tools exposed by your server: get_weather, calculate, and generate_uuid.
Try asking Claude:
- “What’s the weather in Tokyo?” (it will call
get_weather) - “Calculate 1337 times 42” (it will call
calculate) - “Generate me a UUID” (it will call
generate_uuid)
If you don’t want to edit JSON by hand, use our MCP Manifest Generator to build your claude_desktop_config.json visually.
Step 7: Build a Real-World Example — A Notes Server
Let’s put everything together by building a practical MCP server: a notes/todo manager that stores data in a local JSON file. This is the kind of server you would actually use day-to-day with Claude Desktop.
Create src/index.ts with the complete notes server (replacing the previous content):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
// --- Data Layer ---
interface Note {
id: string;
title: string;
content: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
const DATA_FILE = join(process.cwd(), "notes.json");
function loadNotes(): Note[] {
if (!existsSync(DATA_FILE)) {
return [];
}
try {
const raw = readFileSync(DATA_FILE, "utf-8");
return JSON.parse(raw) as Note[];
} catch {
return [];
}
}
function saveNotes(notes: Note[]): void {
writeFileSync(DATA_FILE, JSON.stringify(notes, null, 2), "utf-8");
}
// --- MCP Server ---
const server = new McpServer({
name: "notes-server",
version: "1.0.0",
});
// Tool: Add a new note
server.tool(
"add_note",
"Create a new note with a title, content, and optional tags",
{
title: z.string().describe("Note title"),
content: z.string().describe("Note content (supports Markdown)"),
tags: z.array(z.string()).optional()
.describe("Optional tags for categorization, e.g. ['work', 'urgent']"),
},
async ({ title, content, tags }) => {
const notes = loadNotes();
const now = new Date().toISOString();
const note: Note = {
id: randomUUID(),
title,
content,
tags: tags ?? [],
createdAt: now,
updatedAt: now,
};
notes.push(note);
saveNotes(notes);
return {
content: [{
type: "text",
text: `Note created successfully.\n\nID: ${note.id}\nTitle: ${note.title}\nTags: ${note.tags.length > 0 ? note.tags.join(", ") : "none"}\nCreated: ${note.createdAt}`
}]
};
}
);
// Tool: List all notes
server.tool(
"list_notes",
"List all saved notes with their titles, tags, and dates",
{
tag: z.string().optional()
.describe("Filter by tag. If omitted, returns all notes."),
},
async ({ tag }) => {
let notes = loadNotes();
if (tag) {
notes = notes.filter(n =>
n.tags.some(t => t.toLowerCase() === tag.toLowerCase())
);
}
if (notes.length === 0) {
return {
content: [{
type: "text",
text: tag
? `No notes found with tag "${tag}".`
: "No notes found. Use add_note to create one."
}]
};
}
const summary = notes
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(n => [
`- **${n.title}**`,
` ID: ${n.id}`,
` Tags: ${n.tags.length > 0 ? n.tags.join(", ") : "none"}`,
` Updated: ${n.updatedAt}`,
].join("\n"))
.join("\n\n");
return {
content: [{
type: "text",
text: `Found ${notes.length} note(s):\n\n${summary}`
}]
};
}
);
// Tool: Search notes by keyword
server.tool(
"search_notes",
"Search notes by keyword in title or content",
{
query: z.string().describe("Search keyword or phrase"),
},
async ({ query }) => {
const notes = loadNotes();
const lower = query.toLowerCase();
const matches = notes.filter(n =>
n.title.toLowerCase().includes(lower) ||
n.content.toLowerCase().includes(lower)
);
if (matches.length === 0) {
return {
content: [{
type: "text",
text: `No notes match "${query}".`
}]
};
}
const results = matches.map(n => [
`### ${n.title}`,
`ID: ${n.id} | Tags: ${n.tags.join(", ") || "none"}`,
"",
n.content.length > 200
? n.content.substring(0, 200) + "..."
: n.content,
].join("\n")).join("\n\n---\n\n");
return {
content: [{
type: "text",
text: `Found ${matches.length} note(s) matching "${query}":\n\n${results}`
}]
};
}
);
// Tool: Delete a note
server.tool(
"delete_note",
"Delete a note by its ID",
{
id: z.string().describe("The UUID of the note to delete"),
},
async ({ id }) => {
const notes = loadNotes();
const index = notes.findIndex(n => n.id === id);
if (index === -1) {
return {
content: [{ type: "text", text: `Note with ID "${id}" not found.` }],
isError: true,
};
}
const deleted = notes.splice(index, 1)[0];
saveNotes(notes);
return {
content: [{
type: "text",
text: `Deleted note: "${deleted.title}" (${deleted.id})`
}]
};
}
);
// Resource: Read all notes as a single document
server.resource(
"all-notes",
"notes://all",
async (uri) => {
const notes = loadNotes();
const document = notes.length === 0
? "No notes yet."
: notes
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.map(n => [
`# ${n.title}`,
`Tags: ${n.tags.join(", ") || "none"} | Created: ${n.createdAt}`,
"",
n.content,
].join("\n"))
.join("\n\n---\n\n");
return {
contents: [{
uri: uri.href,
mimeType: "text/markdown",
text: document,
}]
};
}
);
// Prompt: Summarize all notes
server.prompt(
"summarize_notes",
"Generate a summary of all saved notes",
{},
() => {
const notes = loadNotes();
const noteList = notes.length === 0
? "There are no notes yet."
: notes.map(n => `- ${n.title}: ${n.content}`).join("\n");
return {
messages: [{
role: "user",
content: {
type: "text",
text: [
"Here are all my notes:",
"",
noteList,
"",
"Please provide:",
"1. A brief summary of all notes",
"2. Common themes or patterns",
"3. Any action items or follow-ups you can identify",
"4. Suggestions for organizing these notes better",
].join("\n")
}
}]
};
}
);
// --- Start the server ---
const transport = new StdioServerTransport();
await server.connect(transport);
This server is fully functional. Build it, point Claude Desktop at it, and you can manage notes entirely through conversation:
- “Add a note about the API redesign meeting — we decided to migrate to GraphQL by Q3”
- “Show me all my notes tagged with ‘work’”
- “Search my notes for anything about GraphQL”
- “Summarize all my notes”
The notes persist in a notes.json file in the working directory, so they survive server restarts.
Testing Your Server
You do not need Claude Desktop to test your MCP server. The MCP project provides an official testing tool called the MCP Inspector.
Using the MCP Inspector
Run the Inspector against your compiled server:
npx @modelcontextprotocol/inspector node dist/index.js
This opens a web UI (usually at http://localhost:5173) where you can:
- See all registered tools, resources, and prompts
- Call tools with custom inputs and see the responses
- Read resources and inspect their contents
- Test prompts with different parameters
- Monitor the JSON-RPC messages flowing between client and server
The Inspector is invaluable during development. Use it to verify your tool schemas, test edge cases, and debug issues before connecting to a real AI client.
Debugging Tips
If your server is not showing up in Claude Desktop or tools are not working:
-
Check the logs. Claude Desktop writes MCP logs to:
- macOS:
~/Library/Logs/Claude/mcp*.log - Windows:
%APPDATA%\Claude\logs\mcp*.log
- macOS:
-
Verify the path. The
argspath in your config must be absolute and must point to the compiled.jsfile, not the.tssource. -
Check
package.json. Make sure"type": "module"is present. Without it, Node.js will fail to import the MCP SDK’s ES modules. -
Test manually. You can pipe JSON-RPC messages directly to your server to test:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node dist/index.js
If the server is working, you will see a JSON-RPC response with the server’s capabilities.
Validate Your Config
Before adding your server to Claude Desktop, validate the configuration using our MCP Config Validator. It catches common issues like missing fields, invalid paths, and malformed JSON before they cause silent failures.
Common Patterns and Best Practices
After building MCP servers for a while, certain patterns emerge as particularly effective.
Error Handling
Always return isError: true when a tool operation fails. This tells the AI model the operation did not succeed, so it can retry or report the error to the user:
server.tool("risky_operation", "...", { /* schema */ }, async (params) => {
try {
const result = await doSomething(params);
return { content: [{ type: "text", text: result }] };
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${(error as Error).message}` }],
isError: true,
};
}
});
Descriptive Schemas
The AI model reads your tool descriptions and parameter descriptions to decide when and how to use your tools. Be specific:
// Bad — the AI does not know what format to use
{ date: z.string().describe("Date") }
// Good — the AI knows exactly what to provide
{ date: z.string().describe("Date in YYYY-MM-DD format, e.g. 2026-02-24") }
Keep Tools Focused
Each tool should do one thing well. Instead of a single manage_notes tool with a mode parameter, create separate add_note, list_notes, search_notes, and delete_note tools. This makes it easier for the AI to choose the right tool and reduces input validation complexity.
Use Resources for Context
If your server manages data that the AI might need to reference during a conversation, expose it as a resource. Tools are for actions; resources are for reading. When the AI needs to “look up” information before taking an action, resources are the right primitive.
Logging
Write diagnostic information to stderr (not stdout) to avoid interfering with the JSON-RPC communication on stdout:
console.error("[notes-server] Loaded 42 notes from disk");
The MCP SDK communicates over stdout. Any non-JSON-RPC output on stdout will break the protocol.
Deploying as a Remote Server
So far we have used stdio transport, which runs the server locally. For team deployments or production use, you can expose your server over HTTP using streamable HTTP transport.
Here is a minimal example using Express:
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const server = new McpServer({
name: "remote-notes-server",
version: "1.0.0",
});
// ... register tools, resources, prompts ...
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => { transport.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => {
console.error("MCP server listening on http://localhost:3000/mcp");
});
Remote servers open up several possibilities:
- Shared team servers that everyone connects to
- Authenticated access using OAuth 2.1 or API keys
- Centralized data without syncing files across machines
- Deployment on cloud platforms like Cloudflare Workers, AWS Lambda, or any Node.js host
For remote server configurations, use the URL-based format in your MCP client config:
{
"mcpServers": {
"remote-notes": {
"url": "http://localhost:3000/mcp"
}
}
}
Next Steps
You now have a solid foundation for building MCP servers. Here is where to go from here:
Expand your server. Add more tools, connect to real APIs, or integrate with databases. The notes server pattern (CRUD operations + search + resource for reading) applies to almost any data source.
Add authentication. For remote servers, implement OAuth 2.1 to control who can access your tools. The MCP specification includes a standard auth flow.
Publish to npm. If your server is useful to others, publish it as an npm package. Most community MCP servers are distributed this way — users install them via npx in their client configs.
Browse existing servers. Before building from scratch, check whether someone has already built what you need. Our MCP Server Directory lists popular servers across categories like databases, cloud providers, developer tools, and productivity apps.
Convert existing APIs. If you already have a REST API with an OpenAPI specification, you can auto-generate MCP tool definitions using the OpenAPI to MCP Converter instead of writing them manually.
Generate your config. Use the MCP Manifest Generator to build MCP client configurations visually, and the MCP Config Validator to catch errors before they cause issues.
MCP is quickly becoming the foundational layer for how AI models interact with the outside world. Whether you are building internal tools for your team, creating open-source servers for the community, or just automating your own workflow — knowing how to build MCP servers is one of the most practical skills you can develop as an AI-era developer.
Related Resources
- What is MCP? Complete Developer Guide — Full protocol overview, architecture, and ecosystem
- MCP Manifest Generator — Build MCP configs visually
- MCP Config Validator — Validate your MCP configuration files
- OpenAPI to MCP Converter — Auto-generate MCP servers from OpenAPI specs
- MCP Server Directory — Browse 8,600+ community MCP servers
- MCP SDK on GitHub — Official TypeScript SDK source code
- MCP Specification — The full protocol specification