Home / Blog / MCP Server Tutorial

MCP Server Tutorial: Build Your First Server Step-by-Step (2025)

The Model Context Protocol (MCP) is quickly becoming the standard for connecting AI models to external tools and data sources. Adopted by Anthropic, OpenAI, and Google, MCP lets you build servers that any compatible AI client can use.

In this hands-on tutorial, you'll build a working MCP server from scratch. We'll start simple and progressively add features until you have a fully functional server that works with Claude Desktop, ChatGPT, and other MCP clients.

What You'll Build

By the end of this tutorial, you'll have an MCP server that:

We'll build a simple weather server as our example—it's straightforward enough to understand but demonstrates all the core MCP concepts.

Prerequisites

Before starting, make sure you have:

Basic familiarity with TypeScript and command-line tools is helpful but not required.

Understanding MCP Concepts

Before we code, let's understand the three main things an MCP server can expose:

Tools

Tools are functions that the AI can call to perform actions. Each tool has a name, description, input schema (what parameters it accepts), and a handler function that executes the action.

Example: A get_weather tool that accepts a city name and returns weather data.

Resources

Resources provide data that the AI can read. Unlike tools, resources don't perform actions—they just return information. Resources have a URI scheme and can be static or dynamic.

Example: A weather://cities resource that returns a list of supported cities.

Prompts

Prompts are pre-defined templates that help the AI use your server effectively. They're optional but can improve the user experience.

Example: A prompt template for "Get weather forecast for the next week."

🔌 Transports

MCP supports multiple transport protocols. stdio (standard input/output) is used for local servers like Claude Desktop. HTTP (Streamable HTTP or SSE) is used for remote servers like ChatGPT apps. We'll cover both in this tutorial.

Project Setup

Let's create a new project and install dependencies:

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

# Initialize npm project
npm init -y

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

Create a tsconfig.json for TypeScript:

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

Update your package.json:

{
  "name": "mcp-weather-server",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mcp-weather": "./build/index.js"
  },
  "scripts": {
    "build": "tsc && chmod +x build/index.js",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.20.0",
    "zod": "^3.25.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0"
  }
}

Create the source directory:

mkdir src
touch src/index.ts

Creating Your First Tool

Now let's write our MCP server. Open src/index.ts and add:

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

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

// Mock weather data (in a real app, call an API)
const weatherData: Record<string, { temp: number; conditions: string }> = {
  "london": { temp: 12, conditions: "Cloudy" },
  "paris": { temp: 15, conditions: "Sunny" },
  "tokyo": { temp: 22, conditions: "Clear" },
  "new york": { temp: 18, conditions: "Partly cloudy" },
};

// Register the get_weather tool
server.tool(
  "get_weather",                              // Tool name
  "Get current weather for a city",           // Description
  {                                           // Input schema using Zod
    city: z.string().describe("City name (e.g., London, Paris, Tokyo)")
  },
  async ({ city }) => {                       // Handler function
    const cityLower = city.toLowerCase();
    const weather = weatherData[cityLower];
    
    if (!weather) {
      return {
        content: [{
          type: "text",
          text: `Weather data not available for "${city}". Available cities: ${Object.keys(weatherData).join(", ")}`
        }]
      };
    }
    
    return {
      content: [{
        type: "text",
        text: `Weather in ${city}: ${weather.temp}°C, ${weather.conditions}`
      }]
    };
  }
);

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

main().catch(console.error);
⚠️ Important: Logging

For stdio-based servers, always use console.error() for logging, not console.log(). Standard output (stdout) is reserved for MCP protocol messages. Writing to stdout will corrupt the communication.

Build the server:

npm run build

Adding Resources

Let's add a resource that lists available cities. Add this before main():

// Register a resource for listing cities
server.resource(
  "cities",                                   // Resource name
  "weather://cities",                         // Resource URI
  async () => ({                             // Handler
    contents: [{
      uri: "weather://cities",
      mimeType: "application/json",
      text: JSON.stringify({
        cities: Object.keys(weatherData),
        description: "List of cities with available weather data"
      }, null, 2)
    }]
  })
);

Now let's add a dynamic resource that provides detailed info for each city:

// Register a resource template for individual cities
server.resource(
  "city-weather",
  "weather://city/{name}",
  async (uri) => {
    // Extract city name from URI
    const match = uri.href.match(/weather:\/\/city\/(.+)/);
    const cityName = match ? decodeURIComponent(match[1]).toLowerCase() : "";
    
    const weather = weatherData[cityName];
    
    if (!weather) {
      return {
        contents: [{
          uri: uri.href,
          mimeType: "text/plain",
          text: `No data for city: ${cityName}`
        }]
      };
    }
    
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify({
          city: cityName,
          temperature: weather.temp,
          conditions: weather.conditions,
          unit: "celsius"
        }, null, 2)
      }]
    };
  }
);

Rebuild the server:

npm run build

Testing with MCP Inspector

The MCP Inspector is an official tool for testing MCP servers. Run it against your server:

npx @modelcontextprotocol/inspector node build/index.js

This opens a browser interface where you can:

  1. Click "Connect" to connect to your server
  2. Go to the "Tools" tab and click "List Tools"
  3. Select get_weather, enter a city name, and click "Run"
  4. Check the "Resources" tab to see your registered resources

If everything works, you should see weather data returned for valid cities.

Connecting to Claude Desktop

To use your server with Claude Desktop, you need to configure it in Claude's settings file.

Find your config file

Create or edit the file:

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

Important: Use the absolute path to your built index.js file.

Restart Claude Desktop

After saving the config, fully restart Claude Desktop (not just close the window). You should see your server listed in the tools menu.

Test it

Ask Claude: "What's the weather in London?" Claude should use your MCP tool and return the weather data.

HTTP Transport for ChatGPT

ChatGPT apps use HTTP transport instead of stdio. Let's add support for both. Update your src/index.ts:

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

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

// ... (keep all the tool and resource definitions from before)

// Check command-line args for transport type
const useHttp = process.argv.includes("--http");
const PORT = parseInt(process.env.PORT || "8787");

async function main() {
  if (useHttp) {
    // HTTP transport for ChatGPT/remote clients
    const httpServer = http.createServer(async (req, res) => {
      const url = new URL(req.url || "/", `http://localhost:${PORT}`);
      
      // CORS headers
      res.setHeader("Access-Control-Allow-Origin", "*");
      res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
      res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
      
      if (req.method === "OPTIONS") {
        res.writeHead(204);
        res.end();
        return;
      }
      
      if (url.pathname === "/mcp") {
        const transport = new StreamableHTTPServerTransport({
          sessionIdGenerator: undefined,
          enableJsonResponse: true,
        });
        
        await server.connect(transport);
        await transport.handleRequest(req, res);
      } else {
        res.writeHead(200, { "Content-Type": "text/plain" });
        res.end("Weather MCP Server - POST to /mcp");
      }
    });
    
    httpServer.listen(PORT, () => {
      console.log(`Weather MCP server running at http://localhost:${PORT}/mcp`);
    });
  } else {
    // Stdio transport for Claude Desktop
    const transport = new StdioServerTransport();
    await server.connect(transport);
    console.error("Weather MCP server running on stdio");
  }
}

main().catch(console.error);

Now you can run the server in two modes:

# For Claude Desktop (stdio)
node build/index.js

# For ChatGPT (HTTP)
node build/index.js --http

For ChatGPT, you'll need to expose your server via HTTPS. Use ngrok for development:

ngrok http 8787

Then add your ngrok URL as a connector in ChatGPT settings.

Skip the server setup

Building MCP servers requires coding, hosting, and maintenance. With Agentappbuilder, create the same functionality through a visual interface—no code required.

Join the waitlist

Common Errors and Solutions

"Cannot find module" errors

Make sure you've run npm run build after making changes. Check that "type": "module" is in your package.json.

Server not appearing in Claude Desktop

Double-check the absolute path in your config file. Ensure Claude Desktop is fully restarted (not just window closed). Check the Developer Console in Claude for error messages.

Tools not being called

Make sure your tool descriptions are clear and specific. The AI uses these descriptions to decide when to call your tools. Vague descriptions lead to poor tool selection.

CORS errors with HTTP transport

Ensure your server sends proper CORS headers. For ChatGPT, allow https://chatgpt.com as an origin.

JSON parsing errors

If using stdio transport, remember that console.log() corrupts the protocol. Use console.error() for all logging.

Next Steps

Congratulations! You've built a working MCP server. Here's where to go from here:

Add real functionality

Replace the mock weather data with calls to a real weather API like OpenWeatherMap or WeatherAPI.

Add authentication

For production servers, implement OAuth to authenticate users and access their data securely.

Deploy to production

Host your HTTP server on Cloudflare Workers, Fly.io, Vercel, or your preferred platform.

Build a ChatGPT app

Use the OpenAI Apps SDK to add UI widgets and submit your app to the ChatGPT directory.

Further reading