Skip to main content
MCP

Creating an MCP Server in TypeScript

Complete tutorial to create your own MCP Server with the TypeScript SDK: tools, resources, prompts, local testing, and Claude Code integration.

Why create your own MCP?

Community MCPs cover the most common use cases. But your tech stack has its own specifics: an internal API, a custom data format, a workflow unique to your team. Creating a custom MCP lets you connect Claude Code to exactly what you need.

This tutorial guides you from scratch to a working MCP, tested and integrated into Claude Code.

What you'll build

A "weather" MCP that exposes a tool to fetch the weather for a city. Simple, concrete, and transferable to any API. By the end of the tutorial, you'll know how to create tools, resources, and prompts.

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ installed (node --version)
  • npm or pnpm available
  • Claude Code installed and working
  • Basic TypeScript knowledge (types, async/await, imports)

Project scaffolding

Initialize the project

Create a new folder and initialize the project:

mkdir mcp-weather && cd mcp-weather
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

The @modelcontextprotocol/sdk provides everything needed to create an MCP Server. zod is used to validate tool parameters. tsx lets you run TypeScript directly.

Configure TypeScript

Create a minimal tsconfig.json file:

{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

Create the file structure

mkdir src
touch src/index.ts

Your directory tree looks like this:

mcp-weather/
├── src/
│   └── index.ts       # MCP server entry point
├── package.json
└── tsconfig.json

Configure package.json

Add the bin field and scripts to your package.json:

{
"name": "mcp-weather",
"version": "1.0.0",
"type": "module",
"bin": {
"mcp-weather": "dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts"
}
}

The type: "module" field is required to use ES module imports.

Create the basic MCP server

Open src/index.ts and start with the minimal structure:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Create the server with its metadata
const server = new McpServer({
name: "mcp-weather",
version: "1.0.0",
});

This skeleton creates an empty MCP server. It doesn't do anything yet, but it's already valid. Let's add features one by one.

Define a Tool: fetch the weather

A tool is a function that Claude Code can call. Each tool has a name, a description, a parameter schema (via zod), and a handler that returns the result.

// Simulate a weather API (replace with a real HTTP call in production)
function getWeatherData(city: string): {
city: string;
temperature: number;
condition: string;
humidity: number;
} {
// Simulated data for the tutorial
const weatherData: Record<string, { temperature: number; condition: string; humidity: number }> = {
paris: { temperature: 18, condition: "Cloudy", humidity: 65 },
lyon: { temperature: 22, condition: "Sunny", humidity: 45 },
marseille: { temperature: 26, condition: "Sunny", humidity: 55 },
lille: { temperature: 14, condition: "Rainy", humidity: 80 },
};
const normalized = city.toLowerCase().trim();
const data = weatherData[normalized];
if (!data) {
return {
city,
temperature: 20,
condition: "Data unavailable",
humidity: 50,
};
}
return { city, ...data };
}
// Register the tool on the server
server.tool(
"get-weather",
"Fetches the current weather for a given city",
{
city: z.string().describe("City name (e.g., Paris, Lyon, Marseille)"),
},
async ({ city }) => {
const weather = getWeatherData(city);
return {
content: [
{
type: "text" as const,
text: [
`Weather in ${weather.city}:`,
`- Temperature: ${weather.temperature}°C`,
`- Conditions: ${weather.condition}`,
`- Humidity: ${weather.humidity}%`,
].join("\n"),
},
],
};
}
);

The role of the description

The tool description is read by Claude to decide when to use it. Be precise and concrete. A bad description = Claude won't know when to call your tool.

Add a second tool: forecast

You can add as many tools as needed. Here's a second tool for forecasts:

server.tool(
"get-forecast",
"Fetches the weather forecast for the next few days for a city",
{
city: z.string().describe("City name"),
days: z.number().min(1).max(7).default(3).describe("Number of days (1-7)"),
},
async ({ city, days }) => {
const conditions = ["Sunny", "Cloudy", "Rainy", "Stormy", "Foggy"];
const forecast = Array.from({ length: days }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() + i + 1);
const dayStr = date.toLocaleDateString("en-US", {
weekday: "long",
day: "numeric",
month: "long",
});
const temp = Math.round(15 + Math.random() * 15);
const condition = conditions[Math.floor(Math.random() * conditions.length)];
return `${dayStr}: ${temp}°C, ${condition}`;
});
return {
content: [
{
type: "text" as const,
text: `Forecast for ${city}:\n${forecast.join("\n")}`,
},
],
};
}
);

Define a Resource

Resources are read-only data that Claude Code can consult to enrich its context. They're identified by URIs.

// Static resource: list of supported cities
server.resource(
"cities-list",
"weather://cities",
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
cities: ["Paris", "Lyon", "Marseille", "Lille"],
note: "Other cities: estimated data",
}),
},
],
})
);

Resources vs Tools

Resources provide context (read-only data). Tools perform actions (API calls, calculations, writes). Claude Code can read resources automatically at startup to better understand what your MCP offers.

Define a Prompt

Prompts are optimized interaction templates. They let Claude Code offer pre-configured workflows.

server.prompt(
"weather-report",
"Generates a complete weather report for a city",
{
city: z.string().describe("City name for the report"),
},
({ city }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: [
`Generate a complete weather report for ${city}.`,
"Use the get-weather tool for current weather",
"and get-forecast for the forecast.",
"Present everything in a clear, readable format.",
].join(" "),
},
},
],
})
);

Lifecycle: starting the server

An MCP Server's lifecycle has two key phases: initialization and shutdown.

Add the startup code at the end of src/index.ts:

// Start the server with stdio transport
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Weather server running on stdio");
}
main().catch((error: unknown) => {
console.error("Fatal error:", error);
process.exit(1);
});

stdout vs stderr

Use console.error() for your logs, never console.log(). The stdio transport uses stdout for JSON-RPC communication with Claude Code. Everything going through stdout must be valid JSON-RPC. Your debug messages must go to stderr.

Test locally

Before integrating your MCP into Claude Code, test it in isolation.

Verify the server starts

# Compile and run
npm run build
# Test that the server responds
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node dist/index.js

You should see a JSON-RPC response with your server's capabilities.

Test with the MCP inspector

The SDK provides an interactive inspection tool:

npx @modelcontextprotocol/inspector node dist/index.js

The inspector opens a web interface where you can:

  • See the list of tools, resources, and prompts
  • Call each tool with test parameters
  • Verify responses

Verify the output

Manually call a tool to verify the output format:

echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get-weather","arguments":{"city":"Paris"}}}' | node dist/index.js

The response should contain the formatted weather text.

Integrate into Claude Code

Once your MCP is tested, connect it to Claude Code.

Option 1: via the CLI

# From your MCP project folder
claude mcp add weather -- node /absolute/path/to/mcp-weather/dist/index.js

Option 2: via the .mcp.json file

Create or edit the .mcp.json file at the root of the project that will use the MCP:

{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/mcp-weather/dist/index.js"]
}
}
}

Option 3: in development with tsx

To iterate quickly without recompiling:

{
"mcpServers": {
"weather": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/mcp-weather/src/index.ts"]
}
}
}

Verify the integration

Launch Claude Code and ask: "What MCP tools are available?". You should see get-weather and get-forecast in the list. Test with: "What's the weather in Paris?"

Complete example: the final src/index.ts file

Here's the complete file for reference:

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: "mcp-weather",
version: "1.0.0",
});
// --- Simulated data ---
function getWeatherData(city: string) {
const weatherData: Record<
string,
{ temperature: number; condition: string; humidity: number }
> = {
paris: { temperature: 18, condition: "Cloudy", humidity: 65 },
lyon: { temperature: 22, condition: "Sunny", humidity: 45 },
marseille: { temperature: 26, condition: "Sunny", humidity: 55 },
lille: { temperature: 14, condition: "Rainy", humidity: 80 },
};
const normalized = city.toLowerCase().trim();
const data = weatherData[normalized];
if (!data) {
return { city, temperature: 20, condition: "Data unavailable", humidity: 50 };
}
return { city, ...data };
}
// --- Tools ---
server.tool(
"get-weather",
"Fetches the current weather for a given city",
{ city: z.string().describe("City name") },
async ({ city }) => {
const w = getWeatherData(city);
return {
content: [
{
type: "text" as const,
text: `Weather in ${w.city}:\n- Temperature: ${w.temperature}°C\n- Conditions: ${w.condition}\n- Humidity: ${w.humidity}%`,
},
],
};
}
);
server.tool(
"get-forecast",
"Fetches the weather forecast for the next few days for a city",
{
city: z.string().describe("City name"),
days: z.number().min(1).max(7).default(3).describe("Number of days (1-7)"),
},
async ({ city, days }) => {
const conditions = ["Sunny", "Cloudy", "Rainy", "Stormy"];
const lines = Array.from({ length: days }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() + i + 1);
const label = date.toLocaleDateString("en-US", {
weekday: "long",
day: "numeric",
month: "long",
});
const temp = Math.round(15 + Math.random() * 15);
return `${label}: ${temp}°C, ${conditions[Math.floor(Math.random() * conditions.length)]}`;
});
return {
content: [{ type: "text" as const, text: `Forecast for ${city}:\n${lines.join("\n")}` }],
};
}
);
// --- Resources ---
server.resource("cities-list", "weather://cities", async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify({
cities: ["Paris", "Lyon", "Marseille", "Lille"],
note: "Other cities: estimated data",
}),
},
],
}));
// --- Prompts ---
server.prompt(
"weather-report",
"Generates a complete weather report for a city",
{ city: z.string().describe("City name") },
({ city }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Generate a complete weather report for ${city}. Use get-weather for current weather and get-forecast for the forecast.`,
},
},
],
})
);
// --- Startup ---
async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP Weather server running on stdio");
}
main().catch((error: unknown) => {
console.error("Fatal error:", error);
process.exit(1);
});

npm publishing (optional)

If you want to share your MCP with the community:

Prepare the package

Add a shebang as the first line of src/index.ts:

#!/usr/bin/env node

Update package.json with the fields required for npm:

{
"name": "@your-scope/mcp-weather",
"description": "MCP Server for checking the weather",
"keywords": ["mcp", "weather", "claude-code"],
"license": "MIT",
"files": ["dist"],
"bin": {
"mcp-weather": "dist/index.js"
}
}

Build and publish

npm run build
npm publish --access public

Users will then be able to install it with:

claude mcp add weather -- npx -y @your-scope/mcp-weather

Before publishing

Remove the mock data and connect to a real API. Add robust error handling, documentation in the README, and test on multiple Node.js versions.

Common errors and solutions

ErrorLikely causeSolution
Cannot find moduleIncorrect path in .mcp.jsonUse an absolute path to dist/index.js
SyntaxError: Unexpected tokenTS file executed without tsxCompile first (npm run build) or use tsx
No tools visible in Claude CodeServer crashes at startupTest manually with the MCP inspector
stdout is not a valid JSON-RPC messageconsole.log() in the codeReplace with console.error() for logs
Tool called but no responseHandler that returns nothingMake sure the handler returns a content object

Next steps