# How to build your first MCP server

- Tool: MCP
- Difficulty: Beginner
- Time required: 20 min
- Compatibility: Node.js 18+, TypeScript, any MCP-compatible host
- Last updated: March 2026

## TL;DR

Build a complete MCP server in TypeScript that exposes a weather lookup tool and a city info resource. You will initialize the project, install the SDK, define tools with Zod validation, add resources, test with MCP Inspector, and connect to Claude Desktop. By the end you will have a working server that any MCP-compatible AI host can use.

## Build a Weather MCP Server Step by Step

This tutorial builds a weather lookup MCP server from scratch. You will create a server that exposes a 'get_weather' tool the AI can call to check weather conditions, plus a 'cities' resource listing supported cities. This covers the complete workflow: project setup, tool definition with input validation, resource definition, testing with the Inspector, and connecting to an AI host. The weather data is mocked so you can focus on learning MCP without needing an API key.

## Before you start

- Node.js 18+ installed
- npm package manager
- A code editor (VS Code or Cursor recommended)
- Basic TypeScript knowledge (types, async/await)

## Step-by-step guide

### 1. Initialize the project and install dependencies

Create a new directory, initialize npm with ESM support, and install the MCP SDK with Zod for schema validation. Also install tsx for running TypeScript directly during development.

```
mkdir weather-mcp-server
cd weather-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init
```

**Expected result:** A project directory with package.json, tsconfig.json, and node_modules containing the MCP SDK.

### 2. Configure package.json and tsconfig.json

Update package.json to use ESM modules and add development scripts. Update tsconfig.json for NodeNext module resolution which the MCP SDK requires. These configurations ensure TypeScript compiles correctly and Node.js treats imports as ESM.

```
// package.json — add/update these fields:
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "inspect": "npx -y @modelcontextprotocol/inspector tsx src/index.ts"
  }
}

// tsconfig.json — update compilerOptions:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}
```

**Expected result:** Project is configured for ESM with TypeScript and has scripts for dev, build, and inspector testing.

### 3. Create the server entry point with McpServer

Create src/index.ts and set up the McpServer instance. Import McpServer from the SDK, create a server with a name and version, and connect it to stdio transport. This is the skeleton that all tools and resources will be added to.

```
// 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: "weather-server",
  version: "1.0.0",
});

// Tools and resources will be added here

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP server started");
```

**Expected result:** A minimal server that starts and waits for MCP connections via stdio.

### 4. Add mock weather data and the get_weather tool

Define a mock weather database and create the get_weather tool. The tool takes a city name as input (validated with Zod), looks up the weather data, and returns a formatted response. Notice the isError flag for error cases — this tells the AI model the tool call failed so it can respond appropriately instead of treating the error message as weather data.

```
// Add before the transport connection in src/index.ts

interface WeatherData {
  temperature: number;
  condition: string;
  humidity: number;
  wind_speed: number;
}

const weatherDb: Record<string, WeatherData> = {
  "new york": { temperature: 18, condition: "Partly cloudy", humidity: 65, wind_speed: 12 },
  "london": { temperature: 12, condition: "Overcast", humidity: 80, wind_speed: 20 },
  "tokyo": { temperature: 22, condition: "Clear sky", humidity: 55, wind_speed: 8 },
  "paris": { temperature: 15, condition: "Light rain", humidity: 75, wind_speed: 15 },
  "sydney": { temperature: 25, condition: "Sunny", humidity: 45, wind_speed: 10 },
};

server.tool(
  "get_weather",
  "Get current weather conditions for a city",
  {
    city: z.string().describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
  },
  async ({ city }) => {
    const data = weatherDb[city.toLowerCase()];
    if (!data) {
      return {
        content: [{
          type: "text",
          text: `Weather data not available for "${city}". Supported cities: ${Object.keys(weatherDb).join(", ")}`,
        }],
        isError: true,
      };
    }
    return {
      content: [{
        type: "text",
        text: [
          `Weather in ${city}:`,
          `  Temperature: ${data.temperature}°C`,
          `  Condition: ${data.condition}`,
          `  Humidity: ${data.humidity}%`,
          `  Wind speed: ${data.wind_speed} km/h`,
        ].join("\n"),
      }],
    };
  }
);
```

**Expected result:** The server has a get_weather tool that returns weather data for supported cities or an error message for unsupported ones.

### 5. Add a compare_weather tool for multi-city comparison

Add a second tool that compares weather between two cities. This demonstrates tools with multiple parameters and more complex output formatting. The AI model can use this tool when users ask questions like 'Is it warmer in Tokyo or London?'

```
server.tool(
  "compare_weather",
  "Compare weather between two cities",
  {
    city1: z.string().describe("First city name"),
    city2: z.string().describe("Second city name"),
  },
  async ({ city1, city2 }) => {
    const data1 = weatherDb[city1.toLowerCase()];
    const data2 = weatherDb[city2.toLowerCase()];

    if (!data1 || !data2) {
      const missing = [!data1 && city1, !data2 && city2].filter(Boolean);
      return {
        content: [{ type: "text", text: `Weather data not available for: ${missing.join(", ")}` }],
        isError: true,
      };
    }

    const warmer = data1.temperature > data2.temperature ? city1 : city2;
    const diff = Math.abs(data1.temperature - data2.temperature);

    return {
      content: [{
        type: "text",
        text: [
          `${city1}: ${data1.temperature}°C, ${data1.condition}`,
          `${city2}: ${data2.temperature}°C, ${data2.condition}`,
          ``,
          `${warmer} is ${diff}°C warmer.`,
        ].join("\n"),
      }],
    };
  }
);
```

**Expected result:** The server now has two tools. The AI can check individual city weather or compare two cities.

### 6. Add a cities resource for context

Add a resource that lists all supported cities with their current conditions. This resource can be loaded by the host application to give the AI context about what cities are available, without the AI needing to call a tool first.

```
server.resource(
  "supported-cities",
  "weather://cities",
  { description: "List of all supported cities with current conditions" },
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(
        Object.entries(weatherDb).map(([city, data]) => ({
          city: city.charAt(0).toUpperCase() + city.slice(1),
          temperature: `${data.temperature}°C`,
          condition: data.condition,
        })),
        null,
        2
      ),
    }],
  })
);
```

**Expected result:** A weather://cities resource is available that returns a JSON list of supported cities.

### 7. Test the server with MCP Inspector

Run the MCP Inspector to test your server interactively. The Inspector connects to your server and lets you call tools, read resources, and see the raw JSON-RPC messages. This is the most important testing step — always verify your server works in the Inspector before connecting it to an AI host.

```
npx -y @modelcontextprotocol/inspector tsx src/index.ts
```

**Expected result:** The MCP Inspector opens in your browser showing your tools and resources. Tool calls return weather data correctly.

### 8. Connect to Claude Desktop

Add your server to Claude Desktop's configuration file. On macOS, edit ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows, edit %APPDATA%\Claude\claude_desktop_config.json. After saving, restart Claude Desktop. Your weather tools will appear in the tool list.

```
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "weather": {
      "command": "npx",
      "args": ["-y", "tsx", "/absolute/path/to/weather-mcp-server/src/index.ts"]
    }
  }
}
```

**Expected result:** After restarting Claude Desktop, you can ask 'What is the weather in Tokyo?' and Claude will call your get_weather tool.

## Complete code example

File: `src/index.ts`

```typescript
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

interface WeatherData {
  temperature: number;
  condition: string;
  humidity: number;
  wind_speed: number;
}

const weatherDb: Record<string, WeatherData> = {
  "new york": { temperature: 18, condition: "Partly cloudy", humidity: 65, wind_speed: 12 },
  "london": { temperature: 12, condition: "Overcast", humidity: 80, wind_speed: 20 },
  "tokyo": { temperature: 22, condition: "Clear sky", humidity: 55, wind_speed: 8 },
  "paris": { temperature: 15, condition: "Light rain", humidity: 75, wind_speed: 15 },
  "sydney": { temperature: 25, condition: "Sunny", humidity: 45, wind_speed: 10 },
};

const server = new McpServer({ name: "weather-server", version: "1.0.0" });

server.tool(
  "get_weather", "Get current weather for a city",
  { city: z.string().describe("City name") },
  async ({ city }) => {
    const data = weatherDb[city.toLowerCase()];
    if (!data) {
      return {
        content: [{ type: "text", text: `Not available for "${city}". Try: ${Object.keys(weatherDb).join(", ")}` }],
        isError: true,
      };
    }
    return {
      content: [{ type: "text", text: `${city}: ${data.temperature}°C, ${data.condition}, ${data.humidity}% humidity, ${data.wind_speed} km/h wind` }],
    };
  }
);

server.tool(
  "compare_weather", "Compare weather between two cities",
  { city1: z.string().describe("First city"), city2: z.string().describe("Second city") },
  async ({ city1, city2 }) => {
    const d1 = weatherDb[city1.toLowerCase()];
    const d2 = weatherDb[city2.toLowerCase()];
    if (!d1 || !d2) {
      return { content: [{ type: "text", text: "One or both cities not found" }], isError: true };
    }
    const warmer = d1.temperature > d2.temperature ? city1 : city2;
    return {
      content: [{ type: "text", text: `${city1}: ${d1.temperature}°C (${d1.condition})\n${city2}: ${d2.temperature}°C (${d2.condition})\n${warmer} is warmer by ${Math.abs(d1.temperature - d2.temperature)}°C` }],
    };
  }
);

server.resource(
  "supported-cities", "weather://cities",
  { description: "List of supported cities" },
  async (uri) => ({
    contents: [{
      uri: uri.href, mimeType: "application/json",
      text: JSON.stringify(Object.entries(weatherDb).map(([city, d]) => ({
        city, temperature: d.temperature, condition: d.condition,
      }))),
    }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather server started");
```

## Common mistakes

- **Not using absolute paths in AI host configuration** — undefined Fix: Claude Desktop and Cursor require absolute paths to your server file. Use /Users/you/projects/weather-mcp-server/src/index.ts, not ./src/index.ts. Relative paths fail because the host's working directory is not your project.
- **Forgetting to restart the AI host after config changes** — undefined Fix: Claude Desktop and Cursor cache MCP server configurations at startup. After editing the config file, you must fully restart the application. Just closing and reopening a chat is not enough.
- **Not setting isError: true for error responses** — undefined Fix: Without isError: true, the AI model treats error messages as valid tool output. It might say 'The weather data shows City not found' instead of acknowledging the error. Always set isError: true for failure cases.
- **Returning data in non-text format** — undefined Fix: MCP tool responses must use the content array format with type: 'text' (or 'image'). Returning a plain string or JSON object directly causes protocol errors. Always wrap responses in { content: [{ type: 'text', text: '...' }] }.

## Best practices

- Always test with MCP Inspector before connecting to an AI host — it shows raw protocol messages for debugging
- Use isError: true in tool responses to signal failures to the AI model
- Validate all inputs with Zod schemas and provide .describe() for every parameter
- Use absolute paths when configuring servers in AI host config files
- Return human-readable text from tools — the AI model will present this to users
- Keep mock data during development, replace with real API calls when the server logic is solid
- Log startup and errors to stderr: console.error('Server started') — never console.log
- Add the -y flag to all npx commands in config files to avoid hanging on install prompts

## Frequently asked questions

### Can I use a real weather API instead of mock data?

Yes. Replace the weatherDb lookup with a fetch call to any weather API (OpenWeatherMap, WeatherAPI, etc.). Pass the API key via environment variables in the host configuration, not hardcoded in source code.

### Why does my server not show tools in Claude Desktop?

The most common causes are: (1) the config file path is wrong, (2) you used a relative path instead of absolute, (3) you did not restart Claude Desktop after saving the config, or (4) your server has a syntax error that prevents startup. Check Claude Desktop's logs for error details.

### Can I add more tools to this server later?

Yes. Just add more server.tool() calls before the transport connection. Each tool gets its own name, description, schema, and handler. Restart the AI host to pick up the changes.

### How do I debug tool handler errors?

Wrap your tool handler in try/catch and log errors to stderr: console.error('Error:', error). Also test with MCP Inspector first, which shows detailed error messages. For production, consider adding structured logging.

### Can RapidDev help build custom MCP servers for my company?

Yes. RapidDev builds custom MCP servers that connect your internal APIs, databases, and tools to AI assistants. This includes architecture design, implementation, testing, and deployment guidance.

---

Source: https://www.rapidevelopers.com/mcp-tutorial/how-to-build-your-first-mcp-server
© RapidDev — https://www.rapidevelopers.com/mcp-tutorial/how-to-build-your-first-mcp-server
