TUTORIAL • 20 MIN READ

Building a Custom MCP Server

Build your own MCP server from scratch. Complete guide covering TypeScript setup, tool implementation, testing, security, and publishing to share with others.

Updated recently

WHAT YOU'LL BUILD

  • A working MCP server with multiple tools
  • Proper TypeScript setup with the MCP SDK
  • Input validation and error handling
  • Tests for your server functionality
  • Documentation and publishing setup

Quick Note

This is a blog post overview. For the complete interactive tutorial with more detail, visit /build-mcp-server.

Why Build a Custom MCP Server?

While there are already 70+ MCP servers available, you might want to build your own for:

  • Proprietary APIs: Integrate internal company tools or private services
  • Specialized workflows: Create domain-specific tools for your industry
  • Missing integrations: Connect to services that don't have MCP servers yet
  • Custom business logic: Implement unique operations specific to your needs
  • Learning: Understand MCP internals and contribute to the ecosystem

Example: Weather API Server

We'll build a simple weather MCP server that demonstrates core concepts. This server will fetch weather data from an API and expose it to AI assistants.

1. Project Setup

Initialize Project

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

# Initialize npm project
npm init -y

# Install dependencies
npm install @modelcontextprotocol/sdk zod axios
npm install -D typescript @types/node tsx

# Initialize TypeScript
npx tsc --init

2. Configure TypeScript

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

3. Implement the Server

src/index.ts

#!/usr/bin/env node

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 axios from "axios";
import { z } from "zod";

// Environment variable validation
const API_KEY = process.env.WEATHER_API_KEY;
if (!API_KEY) {
  throw new Error("WEATHER_API_KEY environment variable is required");
}

// Tool input schemas
const GetWeatherSchema = z.object({
  location: z.string().describe("City name, e.g., 'San Francisco' or 'London'"),
  units: z.enum(["metric", "imperial"]).default("metric").describe("Temperature units"),
});

const GetForecastSchema = z.object({
  location: z.string().describe("City name"),
  days: z.number().min(1).max(7).default(3).describe("Number of days (1-7)"),
});

// Server implementation
class WeatherServer {
  private server: Server;

  constructor() {
    this.server = new Server(
      {
        name: "weather-server",
        version: "1.0.0",
      },
      {
        capabilities: {
          tools: {},
        },
      }
    );

    this.setupToolHandlers();

    // Error handling
    this.server.onerror = (error) => console.error("[MCP Error]", error);
    process.on("SIGINT", async () => {
      await this.server.close();
      process.exit(0);
    });
  }

  private setupToolHandlers() {
    // List available tools
    this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        {
          name: "get_current_weather",
          description: "Get current weather for a location",
          inputSchema: {
            type: "object",
            properties: {
              location: {
                type: "string",
                description: "City name, e.g., 'San Francisco' or 'London'",
              },
              units: {
                type: "string",
                enum: ["metric", "imperial"],
                description: "Temperature units (default: metric)",
                default: "metric",
              },
            },
            required: ["location"],
          },
        },
        {
          name: "get_forecast",
          description: "Get weather forecast for multiple days",
          inputSchema: {
            type: "object",
            properties: {
              location: {
                type: "string",
                description: "City name",
              },
              days: {
                type: "number",
                description: "Number of days (1-7, default: 3)",
                minimum: 1,
                maximum: 7,
                default: 3,
              },
            },
            required: ["location"],
          },
        },
      ],
    }));

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

      try {
        if (name === "get_current_weather") {
          const { location, units } = GetWeatherSchema.parse(args);
          return await this.getCurrentWeather(location, units);
        } else if (name === "get_forecast") {
          const { location, days } = GetForecastSchema.parse(args);
          return await this.getForecast(location, days);
        } else {
          throw new Error(`Unknown tool: ${name}`);
        }
      } catch (error) {
        if (error instanceof z.ZodError) {
          throw new Error(`Invalid arguments: ${error.message}`);
        }
        throw error;
      }
    });
  }

  private async getCurrentWeather(location: string, units: string) {
    try {
      const response = await axios.get(
        `https://api.openweathermap.org/data/2.5/weather`,
        {
          params: {
            q: location,
            appid: API_KEY,
            units: units,
          },
        }
      );

      const data = response.data;
      const tempUnit = units === "metric" ? "°C" : "°F";

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                location: data.name,
                temperature: `${data.main.temp}${tempUnit}`,
                feels_like: `${data.main.feels_like}${tempUnit}`,
                conditions: data.weather[0].description,
                humidity: `${data.main.humidity}%`,
                wind_speed: `${data.wind.speed} m/s`,
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (error: any) {
      if (error.response?.status === 404) {
        throw new Error(`Location not found: ${location}`);
      }
      throw new Error(`Weather API error: ${error.message}`);
    }
  }

  private async getForecast(location: string, days: number) {
    try {
      const response = await axios.get(
        `https://api.openweathermap.org/data/2.5/forecast`,
        {
          params: {
            q: location,
            appid: API_KEY,
            units: "metric",
            cnt: days * 8, // API returns 3-hour intervals
          },
        }
      );

      const forecasts = response.data.list.map((item: any) => ({
        datetime: item.dt_txt,
        temperature: `${item.main.temp}°C`,
        conditions: item.weather[0].description,
      }));

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(
              {
                location: response.data.city.name,
                forecast: forecasts,
              },
              null,
              2
            ),
          },
        ],
      };
    } catch (error: any) {
      throw new Error(`Forecast API error: ${error.message}`);
    }
  }

  async run() {
    const transport = new StdioServerTransport();
    await this.server.connect(transport);
    console.error("Weather MCP server running on stdio");
  }
}

// Start server
const server = new WeatherServer();
server.run().catch(console.error);

4. Add Build Scripts

package.json

{
  "name": "weather-mcp-server",
  "version": "1.0.0",
  "description": "MCP server for weather data",
  "type": "module",
  "bin": {
    "weather-mcp-server": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "prepare": "npm run build"
  },
  "keywords": ["mcp", "weather", "api"],
  "author": "Your Name",
  "license": "MIT",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^0.5.0",
    "axios": "^1.6.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "tsx": "^4.7.0",
    "typescript": "^5.3.0"
  }
}

5. Test Your Server

Test Locally

# Set API key
export WEATHER_API_KEY="your_openweather_api_key"

# Build the server
npm run build

# Run in development mode
npm run dev

6. Configure in Claude Desktop

claude_desktop_config.json

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

Restart Claude Desktop and test:

"What's the current weather in Tokyo?"

Key Concepts

1. Tool Definition

Tools are the core of MCP servers. Each tool needs:

  • Name: Unique identifier (use snake_case)
  • Description: Clear explanation for the AI model
  • Input schema: JSON Schema defining parameters
  • Handler: Function that executes the tool logic

2. Input Validation

Always validate inputs using libraries like Zod:

Why Validation Matters

AI models may pass unexpected or malicious inputs. Validation prevents errors, security issues, and ensures type safety. See our security best practices guide.

3. Error Handling

Provide helpful error messages that guide the AI (and users) on how to fix issues:

// ❌ Bad error message
throw new Error("Invalid");

// ✓ Good error message
throw new Error("Location not found: '${location}'. Please provide a valid city name.");

4. Response Format

Tool responses should return content in a structured format:

return {
  content: [
    {
      type: "text",
      text: "Your response here" // Can be plain text or JSON
    }
  ]
};

Advanced Features

1. Resources (Optional)

Resources allow servers to expose data that the AI can read. Example: a documentation server might expose docs as resources.

2. Prompts (Optional)

Servers can provide pre-defined prompts to guide users:

this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
  prompts: [
    {
      name: "weather_report",
      description: "Generate a detailed weather report",
      arguments: [
        {
          name: "location",
          description: "City name",
          required: true,
        },
      ],
    },
  ],
}));

3. Sampling (Advanced)

Servers can request the client to perform LLM sampling. Useful for complex workflows where the server needs AI assistance.

Testing Your Server

Unit Tests

tests/weather.test.ts

import { describe, it, expect, beforeAll } from "vitest";
import { WeatherServer } from "../src/index";

describe("WeatherServer", () => {
  beforeAll(() => {
    process.env.WEATHER_API_KEY = "test_key";
  });

  it("should validate location parameter", async () => {
    // Test input validation
    await expect(
      server.getCurrentWeather("", "metric")
    ).rejects.toThrow("Invalid arguments");
  });

  it("should handle API errors gracefully", async () => {
    // Test error handling
    await expect(
      server.getCurrentWeather("InvalidCity123", "metric")
    ).rejects.toThrow("Location not found");
  });
});

Integration Testing

Test your server with the MCP Inspector tool (official debugging tool from Anthropic):

# Install MCP Inspector
npm install -g @modelcontextprotocol/inspector

# Run your server with inspector
npx @modelcontextprotocol/inspector node dist/index.js

Publishing Your Server

1. Prepare for Publishing

  • Add a comprehensive README.md
  • Include setup instructions and examples
  • Document all tools and their parameters
  • Add a LICENSE file (MIT is common)
  • Create a .npmignore file

2. Publish to npm

# Login to npm
npm login

# Publish package
npm publish

# Users can then install with:
# npx your-package-name

3. Submit to MCP Server Directory

Submit your server to the official directory so others can discover it:

  • Fork the MCP servers repository
  • Add your server to the directory
  • Submit a pull request

Best Practices

Security

  • Never hardcode API keys (use environment variables)
  • Validate all inputs with schemas
  • Sanitize user-provided data before using in queries
  • Implement rate limiting for expensive operations
  • Use HTTPS for external API calls

See our security guide for detailed security patterns.

Performance

  • Cache frequently accessed data
  • Set reasonable timeouts for API calls
  • Implement pagination for large datasets
  • Use connection pooling for databases

User Experience

  • Write clear, concise tool descriptions
  • Provide helpful examples in documentation
  • Return structured, easy-to-parse responses
  • Include error recovery suggestions in error messages

Real-World Examples

Database Query Server

A server that executes safe SQL queries against a database. Check the PostgreSQL MCP guide for inspiration.

CRM Integration

Connect to Salesforce, HubSpot, or other CRM systems. Tools for searching contacts, creating leads, updating deals.

Internal Tools

Expose company-specific APIs, deploy systems, or monitoring dashboards to AI assistants.

Common Pitfalls

PitfallSolution
No input validationUse Zod or similar validation library
Vague error messagesProvide specific, actionable errors
Missing documentationWrite README with examples
Blocking operationsUse async/await, set timeouts
Hardcoded credentialsUse environment variables

Next Steps

CONTINUE LEARNING

This blog post covers the essentials. For a complete, step-by-step interactive tutorial with more examples and advanced topics, visit /build-mcp-server.

Resources

Ready to Build?

Start with the full interactive tutorial or join the community on GitHub and Discord for help.