Build an MCP Server with Node SDK: TypeScript Guide
TL;DR: The official MCP SDK for Node.js (@modelcontextprotocol/sdk) lets you build MCP servers in TypeScript. It is lower-level than FastMCP but gives you full control over the protocol. This guide covers installation, defining tools with Zod schemas, setting up resources, handling errors, and connecting to Claude. Includes working code you can adapt for any Node.js-compatible API.
Prerequisites
- Node.js 18+
- TypeScript 5+
- Claude Code / Cowork installed
Step 1: Project Setup
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Step 2: Create the Server
Create src/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Define your tool input schemas with Zod
const AddNumbersSchema = z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
});
const GreetUserSchema = z.object({
name: z.string().describe("The user's name"),
formal: z.boolean().optional().default(false).describe("Use formal greeting"),
});
// Create the server
const server = new Server(
{
name: "my-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Register tools list handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "add_numbers",
description: "Add two numbers together and return the result.",
inputSchema: {
type: "object",
properties: {
a: { type: "number", description: "First number" },
b: { type: "number", description: "Second number" },
},
required: ["a", "b"],
},
},
{
name: "greet_user",
description: "Generate a greeting for a user.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "The user's name" },
formal: {
type: "boolean",
description: "Use formal greeting (default: false)",
},
},
required: ["name"],
},
},
],
};
});
// Register tool call handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "add_numbers": {
const { a, b } = AddNumbersSchema.parse(args);
const result = a + b;
return {
content: [{ type: "text", text: String(result) }],
};
}
case "greet_user": {
const { name: userName, formal } = GreetUserSchema.parse(args);
const greeting = formal
? `Good day, ${userName}. How may I assist you?`
: `Hey ${userName}! What is up?`;
return {
content: [{ type: "text", text: greeting }],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Start the server
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);
});
Step 3: Add a package.json Script
{
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
Test locally:
npm run dev
The server starts and waits on stdio. You will not see output until Claude connects.
Step 4: Connect to Claude
Add to ~/.claude/mcp_servers.json:
{
"mcpServers": {
"my-node-server": {
"command": "node",
"args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
"env": {}
}
}
}
Or for development without building:
{
"mcpServers": {
"my-node-server": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/my-mcp-server/src/index.ts"],
"env": {}
}
}
}
Restart Claude. Test:
Use my-node-server to add 10 and 32.
Step 5: Real API Integration Example
import fetch from "node-fetch";
// Add to your tool list:
// { name: "get_weather", description: "...", inputSchema: {...} }
case "get_weather": {
const schema = z.object({
city: z.string().describe("City name"),
units: z.enum(["metric", "imperial"]).default("metric"),
});
const { city, units } = schema.parse(args);
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) throw new Error("WEATHER_API_KEY not set");
const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&units=${units}&appid=${apiKey}`
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
const data = await response.json() as any;
return {
content: [{
type: "text",
text: JSON.stringify({
city: data.name,
temp: data.main.temp,
feels_like: data.main.feels_like,
description: data.weather[0].description,
humidity: data.main.humidity,
}, null, 2),
}],
};
}
Pass the API key in MCP config:
{
"env": {
"WEATHER_API_KEY": "your-key-here"
}
}
FastMCP vs Node SDK: Which to Use?
| Factor | FastMCP (Python) | Node SDK (TypeScript) |
|---|---|---|
| Speed to first tool | 5 minutes | 15-20 minutes |
| Boilerplate | Minimal | More (handlers, schemas) |
| Type safety | Python type hints | Full TypeScript |
| Ecosystem | Python libraries | npm ecosystem |
| Performance | Good | Good |
| Best for | Data science, ML, scripting | Web APIs, JS ecosystem integration |
Choose FastMCP if you want the fastest path to a working server. Choose the Node SDK if your target APIs are in the npm ecosystem or you want strict TypeScript throughout.
Summary
The Node SDK gives you a solid TypeScript foundation for any MCP server. The boilerplate is heavier than FastMCP but the type safety and npm ecosystem integration make it worth it for production servers. The key pattern is: define schemas with Zod, return { content: [{ type: "text", text: ... }] } from every tool, and use isError: true for error responses.
See also: Python FastMCP version of this guide