AI applications are only really useful when they can reach things outside the model: your files, a database, GitHub, a weather service. Without a standard, every AI app writes its own integration for every service, each in its own format. That’s a lot of one-off glue code, multiplied by every app and every service.
MCP, the Model Context Protocol, is the shared standard that fixes this. You build a server for a service once, and any MCP-capable application (an editor, Claude Desktop, an agent runtime) can discover and call it. This tutorial explains the moving parts, clears up the “MCP vs API” confusion, and builds a working weather MCP server in about 80 lines of JavaScript with the official MCP TypeScript SDK and zod, backed by the free Open-Meteo APIs. Then it tests the server for real with the official MCP Inspector, including an error case and schema validation.
⚡ TL;DR – Roles: the host (the AI app) creates one client per server; the server exposes tools, resources and prompts. – Messages are JSON-RPC 2.0 over stdio (local child process) or Streamable HTTP (remote). – An API is one service’s interface. MCP is a standard layer clients use to discover (
tools/list) and call (tools/call) tools. MCP servers often wrap APIs. – Return tool failures as results withisError: true, not protocol exceptions. – A stdio server must log to stderr. Writing to stdout corrupts the protocol stream.
Before you start
- Node.js 22. The MCP Inspector needs Node 22.19 or newer.
- Basic async JavaScript and familiarity with JSON schemas.
- No API key: Open-Meteo’s geocoding and forecast APIs are free for non-commercial use (attribution: weather data by Open-Meteo.com, CC BY 4.0).
Step 1: Learn the vocabulary
- Host: the AI application itself, such as an editor or a chat app.
- Client: created by the host, one per server, each keeping a dedicated connection.
- Server: the program that provides context and capabilities. That’s what you build.
- Tools: functions the model can call, like
get_forecast. - Resources: data the application can read, like a file or a record.
- Prompts: reusable templates the user can pick.
Transport: stdio is for local servers the host starts as a child process; Streamable HTTP is for servers running elsewhere.
Step 2: Understand MCP vs API
An API (here, Open-Meteo) is one service’s interface with its own URL scheme, parameters and response format. Your code has to know all of that in advance. MCP sits one level up. A client doesn’t need to know anything ahead of time: it sends tools/list, gets back names, descriptions and input schemas, and calls any of them the same way with tools/call. The server we build is a thin wrapper around two APIs, exposed as two tools. MCP doesn’t replace APIs; it standardizes how AI apps find and use them.
Step 3: Set up the project
mkdir weather-mcp && cd weather-mcp
npm init -y
npm install @modelcontextprotocol/server zod
In package.json, set "type": "module". The video uses plain JavaScript, so there’s no build step. The server package is the SDK’s major version 2; check its README for the exact export names if you’re on a different version.
Step 4: Write the API helpers
// server.js (top)
import { McpServer, serveStdio } from '@modelcontextprotocol/server';
import { z } from 'zod';
const GEO = 'https://geocoding-api.open-meteo.com/v1/search';
const FORECAST = 'https://api.open-meteo.com/v1/forecast';
async function findCity(name) {
const r = await fetch(`${GEO}?name=${encodeURIComponent(name)}&count=1`);
const data = await r.json();
return data.results?.[0]; // undefined if no match
}
const notFound = city => ({
isError: true,
content: [{ type: 'text', text: `City not found: ${city}` }],
});
notFound is the important pattern. When a tool fails, don’t throw at the protocol level. Return a normal result with isError: true and a message the model can read, so it can see what went wrong and try something else.
Step 5: Register the tools with zod schemas
function createServer() {
const server = new McpServer({ name: 'weather', version: '1.0.0' });
server.registerTool('geocode', {
description: 'Look up a city and return its country and coordinates',
inputSchema: { city: z.string().min(1).describe('City name, e.g. London') },
}, async ({ city }) => {
const c = await findCity(city);
if (!c) return notFound(city);
return { content: [{ type: 'text', text: `${c.name}, ${c.country}: ${c.latitude}, ${c.longitude}` }] };
});
server.registerTool('get_forecast', {
description: 'Daily rain chance, rainfall and max temperature for a city',
inputSchema: {
city: z.string().min(1),
days: z.number().int().min(1).max(7).default(3).describe('Number of days, 1-7'),
},
}, async ({ city, days }) => {
const c = await findCity(city);
if (!c) return notFound(city);
const q = new URLSearchParams({
latitude: c.latitude, longitude: c.longitude, timezone: 'auto', forecast_days: days,
daily: 'precipitation_probability_max,precipitation_sum,temperature_2m_max',
});
const { daily } = await (await fetch(`${FORECAST}?${q}`)).json();
const lines = daily.time.map((d, i) =>
`${d}: rain ${daily.precipitation_probability_max[i]}%, ${daily.precipitation_sum[i]} mm, max ${daily.temperature_2m_max[i]}°C`);
return { content: [{ type: 'text', text: `${c.name}, ${c.country}\n${lines.join('\n')}` }] };
});
return server;
}
serveStdio(createServer, err => console.error(err));
console.error('weather MCP server running on stdio'); // stderr, never stdout
Why it’s shaped this way:
- Descriptions matter. The model reads them to choose a tool.
- Schemas are enforced by the SDK. Send
days: 9and it’s rejected before your handler runs. - Geocode inside
get_forecastso users can say “London” instead of typing coordinates. createServeris a factory because the transport asks for a fresh instance when it needs one.console.errorfor logs. With stdio, stdout carries the JSON-RPC messages, so any strayconsole.logbreaks the server.
Step 6: Test with the MCP Inspector
npx @modelcontextprotocol/inspector node server.js
It opens a web UI (port 6274) with a per-launch token. Connect, and the right panel shows the real protocol traffic: the initialize handshake, then tools/list when you open the Tools tab. Each tool gets a form generated from its schema, with days defaulting to 3.
Try three things:
- Happy path:
geocodefor Chennai, thenget_forecastfor Reykjavik withdays: 5. - Tool error: geocode a city that doesn’t exist. The Inspector shows a tool error, but the request itself is marked OK. The protocol worked; the tool reported the failure in its result.
- Schema validation with the CLI mode, which is handy in CI:
npx @modelcontextprotocol/inspector --cli node server.js --method tools/list
npx @modelcontextprotocol/inspector --cli node server.js \
--method tools/call --tool-name get_forecast --tool-arg city=London --tool-arg days=9
# -> validation error: too big, expected <= 7. Handler never ran.
Step 7: Add it to a client
Most clients use the mcpServers config format from the official quickstart:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/absolute/path/to/weather-mcp/server.js"]
}
}
}
Use an absolute path. The client starts the process itself, from its own working directory.
Worked example: what the model actually sees
When a host connects, its client sends tools/list and receives something like this (trimmed):
{
"tools": [
{
"name": "get_forecast",
"description": "Daily rain chance, rainfall and max temperature for a city",
"inputSchema": {
"type": "object",
"properties": {
"city": { "type": "string", "minLength": 1 },
"days": { "type": "integer", "minimum": 1, "maximum": 7, "default": 3 }
},
"required": ["city"]
}
}
]
}
The SDK turned your zod schema into JSON Schema. The host passes that list to the model. When a user asks “Will it rain in London this week?”, the model chooses get_forecast, the host sends tools/call with { "city": "London", "days": 7 }, and the text you returned is what the model reads before it answers. Nothing in that flow is specific to weather or to any one AI vendor, which is the point of the protocol.
- Client says the server disconnected immediately: something wrote to stdout. Search for
console.log. - “Cannot use import statement”: add
"type": "module"topackage.json. - Inspector won’t start: check
node -vis at least 22.19. - Tool not listed in the client: restart the client after editing its config; most read it only at startup.
- Empty forecast fields: the
dailyparameter names must match Open-Meteo’s exactly.
Troubleshooting
- The client shows no tools. Check the absolute path to your server file in the client config, restart the client fully, and run the same command in a terminal to see whether it crashes on start.
- Garbled responses or “invalid JSON-RPC” errors. On the stdio transport, stdout is the protocol. Any
console.logcorrupts it. Log withconsole.errorinstead, which goes to stderr. - A tool works in the Inspector but not in the client. The client may be launching a different Node version. Use the full path to
nodein the config. - Validation errors. The zod schema is what the model sees. Add
.describe()text to each field so the model knows the expected format, e.g. “city name in English, without country”. - Errors from the API. Return
{ isError: true, content: [{ type: 'text', text: message }] }instead of throwing, so the model can read the error and recover.
Best practices
- Keep tools small and specific, with clear descriptions and tight schemas.
- Return readable text the model can use, not raw API dumps.
- Respect upstream limits: Open-Meteo’s free tier is for non-commercial use and rate-limited.
- Add attribution where the data source’s licence requires it.
- Use Streamable HTTP with authentication when the server runs remotely.
FAQ
Do I need TypeScript?
No. The SDK works from plain JavaScript; TypeScript just adds editor help.
Tools, resources or prompts: which should I start with?
Tools. They’re what models use most. Add resources when the app needs to read data directly.
Is MCP only for Claude?
No. It’s an open protocol, and many editors and agent frameworks support it.
Official documentation
Key takeaways
- Build a server once and every MCP client can use it.
- MCP standardizes discovery and calling; the server often wraps an ordinary API.
- Use zod schemas,
isErrorresults, and stderr logging. - Test with the Inspector’s UI and CLI before connecting a real client.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.