CuratedMCP
Back to Learn

Building Your First MCP Server

A 60-minute walkthrough: from project init to published, certified, and earning. TypeScript with full source.

·12 min read·
  • Tutorial
  • MCP
  • Publishing

This is a hands-on walkthrough for building a production-grade MCP server in TypeScript and getting it certified on CuratedMCP. Sixty minutes from npm init to a published server.

We'll build a server that exposes a single tool: “give me the current weather for a city.” It's small enough to finish quickly, structurally similar to anything more complex you'll build later.

Prerequisites (5 min)

  • Node.js 20+ and npm
  • An OpenWeatherMap API key (free tier: 60 calls/min)
  • Claude Desktop, Cursor, or another MCP-aware client to test against
  • A GitHub account (for publishing the source)

Step 1: project setup (5 min)

mkdir mcp-weather-server
cd mcp-weather-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsup

Update package.json:

{
  "name": "@your-org/mcp-weather",
  "version": "0.1.0",
  "type": "module",
  "bin": { "mcp-weather": "dist/index.js" },
  "files": ["dist"],
  "scripts": {
    "build": "tsup src/index.ts --format esm --target node20 --shims",
    "dev": "tsx src/index.ts"
  }
}

Create a minimal tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Step 2: the server skeleton (10 min)

Create src/index.ts. The MCP SDK gives you a server, transports, and request handlers. We'll use stdio transport — what Claude Desktop and Cursor expect.

#!/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 API_KEY = process.env.OPENWEATHER_API_KEY;
if (!API_KEY) {
  console.error("OPENWEATHER_API_KEY env var is required");
  process.exit(1);
}

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

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "get_weather",
      description: "Get current weather for a city",
      inputSchema: {
        type: "object",
        properties: {
          city: { type: "string", description: "City name, e.g. 'San Francisco'" },
        },
        required: ["city"],
      },
    },
  ],
}));

const ArgsSchema = z.object({ city: z.string().min(1).max(100) });

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

  if (name !== "get_weather") {
    throw new Error(`Unknown tool: ${name}`);
  }

  const { city } = ArgsSchema.parse(args);
  const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric`;
  const res = await fetch(url);

  if (!res.ok) {
    return {
      content: [{ type: "text", text: `Weather lookup failed: ${res.status}` }],
      isError: true,
    };
  }

  const data = await res.json() as { weather: Array<{description: string}>; main: {temp: number; humidity: number} };
  const summary = `${city}: ${data.weather[0].description}, ${data.main.temp}°C, humidity ${data.main.humidity}%`;

  return { content: [{ type: "text", text: summary }] };
});

const transport = new StdioServerTransport();
await server.connect(transport);

Notice the patterns we already followed:

  • Fail fast on missing credentials, not silently
  • Validate input with Zod (don't trust the LLM)
  • Cap the city length (don't trust the LLM, part 2)
  • Return structured errors with isError: true
  • Don't log sensitive data to stderr

Step 3: build and test locally (10 min)

npm run build
chmod +x dist/index.js

Test with the MCP Inspector:

npx @modelcontextprotocol/inspector node dist/index.js

The inspector opens a browser UI. Set the env var OPENWEATHER_API_KEY in the server config, then call get_weather with { "city": "San Francisco" }. You should see a weather summary.

Step 4: connect to Claude Desktop (5 min)

Edit ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "weather": {
      "command": "node",
      "args": ["/absolute/path/to/your/dist/index.js"],
      "env": { "OPENWEATHER_API_KEY": "your-key-here" }
    }
  }
}

Restart Claude Desktop. Ask “What's the weather in Paris?” and watch the tool get invoked.

Step 5: publish to npm (5 min)

npm login
npm publish --access public

Now anyone can install it with npx @your-org/mcp-weather.

Step 6: submit to CuratedMCP (10 min)

Visit /publish. You'll need:

  • Server name: “Weather”
  • Tagline: “Current weather lookups via OpenWeatherMap”
  • Description: a short markdown writeup
  • Install command: npx -y @your-org/mcp-weather
  • Required env vars: OPENWEATHER_API_KEY
  • Risk level: 🟣 Network egress (it hits an external API)
  • GitHub URL of your repo
  • If charging: pricing tier and Stripe Connect setup

Submission takes 5 minutes. Our review takes up to 48 hours. We'll test the install, validate the schema, and check basic security patterns. If it passes, you're live.

Step 7: production hardening (10 min)

Before you actually trust this in production, add:

Rate limiting

OpenWeatherMap caps you at 60 calls/min on free tier. Add a simple token bucket so a misbehaving agent can't burn your quota:

let lastReset = Date.now();
let calls = 0;
function checkRate() {
  if (Date.now() - lastReset > 60_000) {
    calls = 0;
    lastReset = Date.now();
  }
  if (calls >= 50) throw new Error("Rate limit reached, try again in a minute");
  calls++;
}

Better error messages

Distinguish “city not found” (user error) from “API down” (your problem). The LLM uses these signals to decide whether to retry or apologize.

Tests

Even one integration test that exercises the full request → response cycle catches regressions. We require maintained tests for Sovereign Certification.

What you've built

At this point you have:

  • A working MCP server published to npm
  • It works in Claude Desktop, Cursor, and any other MCP client
  • You've submitted it for Sovereign Certification
  • If you priced it, you'll start earning rev-share when subscribers come in

From here, the path forward is what your server does, not how it's plumbed. Add tools. Improve error handling. Maintain it. Publish updates. Earn.

If you ship something good and you're stuck on certification, email [email protected] — we respond within 48 hours.