You have the MCP client side figured out. Now let us build the server side. Goal: a minimal Node.js MCP server exposing one tool, consumable by any MCP host. Under 50 lines of TypeScript.
What we are building
An MCP server with a single tool get_weather that takes a city name and returns a fake forecast. Simple, but covers every concept you need to extend to real integrations.
Setup
mkdir weather-mcp && cd weather-mcp
npm init -y
npm i @modelcontextprotocol/sdk zod
npm i -D typescript tsx @types/node
npx tsc --init
Open package.json and add:
"bin": { "weather-mcp": "./dist/index.js" },
"scripts": { "build": "tsc", "dev": "tsx src/index.ts" }
The server
Create 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 { z } from 'zod';
const server = new Server({ name: 'weather-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
const GetWeatherSchema = z.object({ city: z.string().describe('City name, e.g. "Berlin"') });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: 'get_weather',
description: 'Get the current weather for a given city',
inputSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
}],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'get_weather') throw new Error('Unknown tool');
const { city } = GetWeatherSchema.parse(request.params.arguments);
// TODO: call a real weather API. For now, fake it.
const temp = 15 + Math.floor(Math.random() * 20);
return { content: [{ type: 'text', text: `${city}: ${temp}°C, partly cloudy` }] };
});
await server.connect(new StdioServerTransport());
Build and test
npm run build
chmod +x dist/index.js
Smoke-test it with the MCP inspector:
npx @modelcontextprotocol/inspector node dist/index.js
Opens a browser UI. List Tools → you should see get_weather. Call it with { "city": "Berlin" } → you get a fake forecast back.
Use it in Claude Desktop
Edit claude_desktop_config.json:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/weather-mcp/dist/index.js"]
}
}
}
Restart Claude, click the hammer icon — get_weather shows up. Ask Claude “what is the weather in Tokyo?” and it calls your tool.
Making it real
Swap the fake body for an actual HTTP call, e.g. openweathermap.org. Two additions:
- Read API key from
process.env.OPENWEATHER_API_KEY. Pass it viaenvin the client config. - Call
fetch(), parse JSON, return a formattedcontentblock.
Adding more tools
Each tool is an entry in ListToolsRequestSchema plus a branch in CallToolRequestSchema. For larger servers, factor each tool into its own module with (args: z.infer<T>) => Promise<string>.
Publishing
Two options:
npm publish— users runnpx your-package. Easiest for JS-based servers.- Docker image on GHCR — language-agnostic. Users run
docker run. What GitHub, Playwright etc. do.
Submit it to Loadout
Once your server is on npm or GHCR, submit it to the directory. Free review, typically live within 24 hours. You pick up users from Claude Desktop, Cursor and Windsurf folks searching for exactly what you built.