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 tsupUpdate 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.jsTest with the MCP Inspector:
npx @modelcontextprotocol/inspector node dist/index.jsThe 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 publicNow 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.