How to Build Your Own MCP Server (Step-by-Step)

No existing MCP server covers your internal tool? Build your own. This tutorial walks through building a fully functional MCP server with the TypeScript SDK — from project setup to testing with Claude Desktop.

What You Will Build

In this tutorial you will build a simple MCP server that exposes a weather lookup tool. By the end, Claude (or any MCP host) will be able to call get_weather and receive real weather data. The same patterns apply to any internal API, database, or service you want to expose.

Prerequisites:

  • Node.js 18 or later
  • TypeScript familiarity (basics are enough)
  • An MCP-compatible host for testing (Claude Desktop works well)

Step 1: Initialize the Project

Create a new directory and initialize a Node.js project:

mkdir my-weather-mcp
cd my-weather-mcp
npm init -y

Install the MCP SDK and required dependencies:

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true
  },
  "include": ["src/**/*"]
}

Add a build script to package.json:

{
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  },
  "bin": {
    "my-weather-mcp": "./build/index.js"
  }
}

Step 2: Create the Server

Create src/index.ts. Start with the server scaffolding:

#!/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 { z } from "zod";

const server = new Server(
  {
    name: "my-weather-mcp",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

Step 3: Define Your Tools

Tools are the functions AI can call. Each tool has a name, description, and input schema. The description is critical — the AI uses it to decide when to call the tool.

// Define the input schema for our tool
const GetWeatherSchema = z.object({
  city: z.string().describe("The city name to get weather for"),
  units: z
    .enum(["celsius", "fahrenheit"])
    .optional()
    .default("celsius")
    .describe("Temperature units"),
});

// Register the list of available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "get_weather",
        description:
          "Get the current weather conditions for a city. " +
          "Returns temperature, conditions, humidity, and wind speed.",
        inputSchema: {
          type: "object",
          properties: {
            city: {
              type: "string",
              description: "The city name to get weather for",
            },
            units: {
              type: "string",
              enum: ["celsius", "fahrenheit"],
              description: "Temperature units (default: celsius)",
            },
          },
          required: ["city"],
        },
      },
    ],
  };
});

Step 4: Implement the Tool Handler

Now register a handler for when the AI calls the tool. This is where your actual business logic lives — calling an API, querying a database, reading a file, etc.

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "get_weather") {
    // Parse and validate the input
    const input = GetWeatherSchema.parse(request.params.arguments);

    // Call the actual weather API (using open-meteo — no API key needed)
    const geoRes = await fetch(
      `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(input.city)}&count=1`
    );
    const geoData = await geoRes.json();

    if (!geoData.results?.length) {
      return {
        content: [{ type: "text", text: `City "${input.city}" not found.` }],
        isError: true,
      };
    }

    const { latitude, longitude, name, country } = geoData.results[0];
    const tempUnit = input.units === "fahrenheit" ? "fahrenheit" : "celsius";
    const weatherRes = await fetch(
      `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&temperature_unit=${tempUnit}`
    );
    const weatherData = await weatherRes.json();
    const current = weatherData.current_weather;

    const conditions = current.weathercode <= 1 ? "Clear" :
      current.weathercode <= 3 ? "Partly Cloudy" :
      current.weathercode <= 67 ? "Rainy" : "Snowy";

    const unitSymbol = input.units === "fahrenheit" ? "F" : "C";

    return {
      content: [
        {
          type: "text",
          text: `Weather in ${name}, ${country}:\n` +
            `Temperature: ${current.temperature}°${unitSymbol}\n` +
            `Conditions: ${conditions}\n` +
            `Wind Speed: ${current.windspeed} km/h`,
        },
      ],
    };
  }

  return {
    content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
    isError: true,
  };
});

Step 5: Connect the Transport

MCP servers communicate over stdio (standard input/output) by default. Add the transport connection at the bottom of your file:

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

main().catch(console.error);

Note that console.error is used for logging rather than console.log — stdout is reserved for the MCP protocol, so any debug output must go to stderr.

Step 6: Build and Test

Build the TypeScript:

npm run build

Test it manually by running it and piping in a test message:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node build/index.js

You should see a JSON response listing your get_weather tool.

Step 7: Connect to Claude Desktop

Add your server to the Claude Desktop config:

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

Restart Claude Desktop. You should see your server listed in the connections panel. Ask Claude: "What is the weather in Tokyo?" — it will call your get_weather tool and return live data.

Step 8: Add Resources (Optional)

Beyond tools (callable functions), MCP servers can also expose resources — data that the AI can read. Resources are useful for things like configuration files, documentation, or live data feeds:

// Enable resources capability
const server = new Server(
  { name: "my-weather-mcp", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

// List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "weather://forecast/weekly",
      name: "Weekly Forecast",
      description: "7-day weather forecast for the configured default city",
      mimeType: "text/plain",
    },
  ],
}));

Publishing Your Server

Once your server is working, consider publishing it to npm so others can use it:

npm publish --access public

Add a shebang line at the top of your built file (#!/usr/bin/env node) so it can be run with npx. Then anyone can install it with:

npx -y my-weather-mcp

If you publish a useful MCP server, submit it to MCP Hub to get it listed in the directory. Browse existing servers for inspiration, and see what gaps exist in the ecosystem that your expertise could fill. For more on the MCP ecosystem, read the complete MCP server guide.

This site uses cookies from Google for advertising and analytics. Learn more