Skip to main content
Back to Guides

Building Your First MCP Server

Ready to extend Claude's capabilities with your own code? Learn how to build a custom tool server from scratch using TypeScript.

Alex Rivera
Updated January 14, 2025
15 min read

The true power of the Model Context Protocol is its extensibility. In this comprehensive tutorial, we'll build a complete MCP server that allows an AI model to interact with a weather API, demonstrating all the key concepts you need to create your own custom tools.

1. Prerequisites

Before we begin, make sure you have the following installed:

  • Node.js 18+: Download from nodejs.org
  • npm or yarn: Comes with Node.js
  • TypeScript knowledge: Basic familiarity with TypeScript syntax
  • A code editor: VS Code recommended for TypeScript support

Verify Your Setup

node --version  # Should be 18.0.0 or higher
npm --version   # Should be 8.0.0 or higher

2. Project Setup

Let's create a new directory and initialize our project with all the necessary dependencies:

# Create project directory
mkdir weather-mcp-server
cd weather-mcp-server

# Initialize Node.js project
npm init -y

# Install dependencies
npm install @modelcontextprotocol/sdk zod

# Install dev dependencies
npm install -D typescript @types/node tsx

# Initialize TypeScript
npx tsc --init

Configure TypeScript

Update your tsconfig.json with these settings:

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

Update package.json

Add these scripts and set the module type:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts",
    "inspect": "npx @modelcontextprotocol/inspector tsx src/index.ts"
  }
}

3. Basic Server Structure

Create src/index.ts with the basic server boilerplate:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// Initialize the server with metadata
const server = new Server(
  {
    name: "weather-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);

// Tool and resource handlers will go here...

// Start the server
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Weather MCP Server running on stdio");
}

main().catch(console.error);

Understanding the Structure

  • Server metadata: The name and version identify your server to clients
  • Capabilities: Declare what features your server supports (tools, resources, prompts)
  • StdioServerTransport: Handles communication over standard input/output

4. Defining Tools

Tools are functions that the AI can call. We need to tell the AI what tools are available and what arguments they accept. Add this handler:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

// Define input schemas using Zod
const GetWeatherSchema = z.object({
  city: z.string().describe("The name of the city"),
  units: z.enum(["celsius", "fahrenheit"]).optional()
    .describe("Temperature units (default: celsius)"),
});

const GetForecastSchema = z.object({
  city: z.string().describe("The name of the city"),
  days: z.number().min(1).max(7).describe("Number of days (1-7)"),
});

// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_weather",
        description: "Get current weather conditions for a city including temperature, humidity, and conditions",
        inputSchema: zodToJsonSchema(GetWeatherSchema),
      },
      {
        name: "get_forecast",
        description: "Get weather forecast for the next several days",
        inputSchema: zodToJsonSchema(GetForecastSchema),
      },
    ],
  };
});

Tool Definition Best Practices

  • Use clear, descriptive names (snake_case is conventional)
  • Write detailed descriptions—the AI uses these to decide when to call your tool
  • Use Zod for type-safe schema definitions
  • Include descriptions for each parameter

5. Handling Tool Execution

Now implement the logic that runs when the AI calls your tools:

// Simulated weather data (in production, call a real API)
async function fetchWeather(city: string, units: string = "celsius") {
  // Simulate API call
  const temp = Math.round(Math.random() * 30 + 5);
  const conditions = ["Sunny", "Cloudy", "Rainy", "Partly Cloudy"][
    Math.floor(Math.random() * 4)
  ];
  
  return {
    city,
    temperature: units === "fahrenheit" ? Math.round(temp * 9/5 + 32) : temp,
    units: units === "fahrenheit" ? "°F" : "°C",
    conditions,
    humidity: Math.round(Math.random() * 60 + 30),
    wind: Math.round(Math.random() * 20 + 5),
  };
}

async function fetchForecast(city: string, days: number) {
  const forecast = [];
  for (let i = 0; i < days; i++) {
    const date = new Date();
    date.setDate(date.getDate() + i);
    forecast.push({
      date: date.toISOString().split("T")[0],
      high: Math.round(Math.random() * 15 + 20),
      low: Math.round(Math.random() * 10 + 10),
      conditions: ["Sunny", "Cloudy", "Rainy"][Math.floor(Math.random() * 3)],
    });
  }
  return { city, forecast };
}

// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  switch (name) {
    case "get_weather": {
      const { city, units } = GetWeatherSchema.parse(args);
      const weather = await fetchWeather(city, units);
      
      return {
        content: [
          {
            type: "text",
            text: `Current weather in ${weather.city}:
• Temperature: ${weather.temperature}${weather.units}
• Conditions: ${weather.conditions}
• Humidity: ${weather.humidity}%
• Wind: ${weather.wind} km/h`,
          },
        ],
      };
    }

    case "get_forecast": {
      const { city, days } = GetForecastSchema.parse(args);
      const data = await fetchForecast(city, days);
      
      const forecastText = data.forecast
        .map(day => `${day.date}: ${day.conditions}, ${day.high}°C / ${day.low}°C`)
        .join("\n");
      
      return {
        content: [
          {
            type: "text",
            text: `${days}-day forecast for ${city}:\n${forecastText}`,
          },
        ],
      };
    }

    default:
      throw new Error(`Unknown tool: ${name}`);
  }
});

6. Adding Resources

Resources allow your server to expose data that the AI can read. Let's add a resource that shows supported cities:

const SUPPORTED_CITIES = [
  "New York", "London", "Tokyo", "Paris", "Sydney",
  "Berlin", "Toronto", "Singapore", "Dubai", "Mumbai"
];

// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: "weather://supported-cities",
        name: "Supported Cities",
        description: "List of cities with weather data available",
        mimeType: "application/json",
      },
      {
        uri: "weather://api-status",
        name: "API Status",
        description: "Current status of the weather API",
        mimeType: "application/json",
      },
    ],
  };
});

// Handle resource reads
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;

  switch (uri) {
    case "weather://supported-cities":
      return {
        contents: [
          {
            uri,
            mimeType: "application/json",
            text: JSON.stringify({ cities: SUPPORTED_CITIES }, null, 2),
          },
        ],
      };

    case "weather://api-status":
      return {
        contents: [
          {
            uri,
            mimeType: "application/json",
            text: JSON.stringify({
              status: "operational",
              lastUpdated: new Date().toISOString(),
              requestsRemaining: 1000,
            }, null, 2),
          },
        ],
      };

    default:
      throw new Error(`Unknown resource: ${uri}`);
  }
});

7. Error Handling

Proper error handling is crucial for a good user experience:

import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";

// In your tool handler, wrap operations in try-catch
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  try {
    switch (name) {
      case "get_weather": {
        const parsed = GetWeatherSchema.safeParse(args);
        if (!parsed.success) {
          throw new McpError(
            ErrorCode.InvalidParams,
            `Invalid parameters: ${parsed.error.message}`
          );
        }
        
        const { city, units } = parsed.data;
        
        if (!SUPPORTED_CITIES.includes(city)) {
          throw new McpError(
            ErrorCode.InvalidParams,
            `City "${city}" is not supported. Use the supported-cities resource to see available cities.`
          );
        }
        
        const weather = await fetchWeather(city, units);
        // ... return result
      }
      // ... other cases
    }
  } catch (error) {
    if (error instanceof McpError) {
      throw error;
    }
    throw new McpError(
      ErrorCode.InternalError,
      `Unexpected error: ${error instanceof Error ? error.message : "Unknown"}`
    );
  }
});

Logging Best Practice

Since MCP uses stdio for communication, use console.error() for logging instead of console.log(). This writes to stderr and won't interfere with the JSON-RPC protocol.

8. Testing Your Server

Use the MCP Inspector to test your server interactively:

npm run inspect

This opens a web interface where you can:

  • See all available tools and resources
  • Test tool invocations with custom arguments
  • View raw JSON-RPC messages
  • Debug errors in real-time

Pro Tip: The Inspector

Always test with the Inspector before connecting to Claude. It's much easier to debug issues in the Inspector's UI than through Claude's interface.

Connect to Claude Desktop

Once tested, add your server to Claude Desktop's config:

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

9. Deployment and Distribution

To share your server with others, publish it to npm:

Update package.json

{
  "name": "@yourname/weather-mcp-server",
  "version": "1.0.0",
  "description": "MCP server for weather data",
  "main": "dist/index.js",
  "bin": {
    "weather-mcp-server": "dist/index.js"
  },
  "files": ["dist"],
  "keywords": ["mcp", "weather", "ai", "claude"]
}

Publish

npm run build
npm publish --access public

Users can then run your server with:

npx @yourname/weather-mcp-server

Conclusion

Congratulations! You've built a complete MCP server with tools, resources, and proper error handling. This pattern can be extended to connect to databases, send emails, control smart home devices, or integrate with any API. The possibilities are endless.

Needs Review

Last updated

January 10, 2025

376 days ago

This content may be outdated

This content may contain outdated information. Please verify details before use.