How to Build an MCP Server in TypeScript (Step-by-Step)
Build a working MCP server in TypeScript with the official SDK. Covers tools with Zod schemas, resources, prompts, testing, and configuration for Claude Code and Cursor.
William Wang — Founder of TokRepo & GEOScore AI. Building tools for AI developer productivity and search visibility.
Table of Contents
- Prerequisites
- Step 1 — Set Up Your Project
- Step 2 — Create a Basic MCP Server
- Step 3 — Add Structured Output with Zod
- Step 4 — Add Resources
- Step 5 — Add Prompts
- Step 6 — Tool Annotations
- Step 7 — Error Handling
- Step 8 — Complete Example: File Analyzer
- Step 9 — Test with MCP Inspector
- Step 10 — Configure in Claude Code
- Step 11 — Configure in Claude Desktop / Cursor
- Next Steps
Learn how to build a production-ready MCP server in TypeScript using the official @modelcontextprotocol/sdk, validate inputs with Zod, and configure it in Claude Code, Claude Desktop, and Cursor.
Prerequisites
- Node.js 16+ (18+ recommended)
- npm or pnpm
- Basic TypeScript knowledge
- One of: Claude Code, Claude Desktop, or Cursor
Step 1 — Set Up Your Project
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod@3
npm install -D @types/node typescript
mkdir src && touch src/index.ts
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Update package.json:
{
"type": "module",
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"start": "node build/index.js"
}
}
The current SDK version is @modelcontextprotocol/sdk v1.29.0.
Step 2 — Create a Basic MCP 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";
const server = new McpServer({
name: "my-first-server",
version: "1.0.0",
});
server.registerTool(
"add",
{
description: "Add two numbers together",
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
},
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
}),
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
Build and test:
npm run build
Step 3 — Add Structured Output with Zod
server.registerTool(
"calculate-bmi",
{
title: "BMI Calculator",
description: "Calculate Body Mass Index",
inputSchema: z.object({
weightKg: z.number().describe("Weight in kilograms"),
heightM: z.number().describe("Height in meters"),
}),
outputSchema: z.object({
bmi: z.number(),
category: z.string(),
}),
},
async ({ weightKg, heightM }) => {
const bmi = weightKg / (heightM * heightM);
let category = "Normal";
if (bmi < 18.5) category = "Underweight";
else if (bmi >= 25) category = "Overweight";
const output = { bmi: Math.round(bmi * 10) / 10, category };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
},
);
Step 4 — Add Resources
server.registerResource(
"app-config",
"config://app",
{
title: "Application Configuration",
description: "Current app settings",
mimeType: "application/json",
},
async (uri) => ({
contents: [{
uri: uri.href,
text: JSON.stringify({ theme: "dark", version: "2.0" }, null, 2),
}],
}),
);
Step 5 — Add Prompts
server.registerPrompt(
"code-review",
{
title: "Code Review",
description: "Review code for best practices",
argsSchema: z.object({
code: z.string().describe("The code to review"),
language: z.string().default("typescript"),
}),
},
({ code, language }) => ({
messages: [{
role: "user" as const,
content: {
type: "text" as const,
text: `Review this ${language} code:\n\`\`\`${language}\n${code}\n\`\`\``,
},
}],
}),
);
Step 6 — Tool Annotations
Annotations give clients metadata about tool behavior:
server.registerTool(
"delete-file",
{
description: "Delete a file from the project",
inputSchema: z.object({
path: z.string().describe("Path to file"),
}),
annotations: {
title: "Delete File",
destructiveHint: true,
idempotentHint: true,
},
},
async ({ path }) => ({
content: [{ type: "text", text: `Deleted ${path}` }],
}),
);
Step 7 — Error Handling
server.registerTool(
"fetch-data",
{
description: "Fetch data from a URL",
inputSchema: z.object({
url: z.string().url().describe("URL to fetch"),
}),
},
async ({ url }) => {
try {
const response = await fetch(url);
if (!response.ok) {
return {
content: [{ type: "text", text: `HTTP ${response.status}` }],
isError: true,
};
}
return { content: [{ type: "text", text: await response.text() }] };
} catch (error) {
return {
content: [{
type: "text",
text: `Failed: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
);
Step 8 — Complete Example: File Analyzer
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, readdir, stat } from "fs/promises";
import { join, extname } from "path";
const server = new McpServer({ name: "file-analyzer", version: "1.0.0" });
server.registerTool(
"analyze-file",
{
description: "Analyze a text file and return statistics",
inputSchema: {
filePath: z.string().describe("Absolute path to the file"),
},
},
async ({ filePath }) => {
try {
const content = await readFile(filePath, "utf-8");
const lines = content.split("\n");
const words = content.split(/\s+/).filter(Boolean);
const result = [
`File: ${filePath}`,
`Lines: ${lines.length}`,
`Words: ${words.length}`,
`Characters: ${content.length}`,
`Extension: ${extname(filePath)}`,
].join("\n");
return { content: [{ type: "text", text: result }] };
} catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
);
server.registerTool(
"list-directory",
{
description: "List files in a directory with sizes",
inputSchema: {
dirPath: z.string().describe("Absolute path to directory"),
extension: z.string().optional().describe("Filter by extension"),
},
},
async ({ dirPath, extension }) => {
let entries = await readdir(dirPath);
if (extension) entries = entries.filter((e) => e.endsWith(extension));
const details = await Promise.all(
entries.slice(0, 50).map(async (entry) => {
const stats = await stat(join(dirPath, entry));
const size = stats.isDirectory()
? "[DIR]"
: `${(stats.size / 1024).toFixed(1)} KB`;
return `${entry} (${size})`;
}),
);
return {
content: [{ type: "text", text: details.join("\n") || "Empty directory" }],
};
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("File Analyzer MCP Server running");
}
main().catch(console.error);
Step 9 — Test with MCP Inspector
npm run build
npx @modelcontextprotocol/inspector node build/index.js
Opens a web UI at http://localhost:6274 for visual debugging.
Step 10 — Configure in Claude Code
claude mcp add --transport stdio my-server \
-- node /absolute/path/to/my-mcp-server/build/index.js
Step 11 — Configure in Claude Desktop / Cursor
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/build/index.js"]
}
}
}
Cursor (.cursor/mcp.json):
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/absolute/path/to/build/index.js"]
}
}
}
FAQ
Q: Which TypeScript SDK version should I use?
A: Use @modelcontextprotocol/sdk v1.29.0 (latest stable). A v2 SDK is in pre-alpha — stick with v1.x for production.
Q: Can I use plain JavaScript instead of TypeScript? A: Yes, the SDK works with JavaScript. You lose type safety for tool schemas, but Zod validation still works at runtime.
Q: How do I publish my MCP server to npm?
A: Add "bin": { "my-server": "./build/index.js" } to package.json, add a #!/usr/bin/env node shebang to index.ts, then npm publish. Users install via npx my-server.
Next Steps
- How to Build an MCP Server in Python — same concepts in Python
- How to Build an MCP Server for Your API — wrap REST APIs
- Best MCP Servers for Claude Code — browse configs
- Skills vs MCP vs Rules — choose the right extension model