· mcp / claude-code / typescript
How to build an MCP server for Claude Code
Scaffold a TypeScript MCP server with [email protected], register it with Claude Code, and implement two real NWS weather tools — no API key, done in 30 minutes.
By Ethan
1,876 words · 10 min read
MCP is the right abstraction for connecting Claude Code to any API or data source your team owns. This tutorial builds a working server from scratch: TypeScript, @modelcontextprotocol/[email protected], two real weather tools that call the National Weather Service. No paid API, no API key, no hand-waving. By the end Claude Code can call your server on your machine.
Who this is for
Developers who already use Claude Code and want to extend it with a custom tool — fetching internal data, querying a database, or wrapping an existing API. You need Node.js 18+ and basic TypeScript familiarity. You don’t need to know anything about MCP before reading this.
What is MCP
MCP (Model Context Protocol) is an open-source protocol built on JSON-RPC 2.0 that standardizes how AI hosts (Claude Code, Cursor, VS Code Copilot) talk to external capability providers. A server exposes three things: tools (functions the model can call), resources (read-only data attached to context), and prompts (reusable instruction templates). The host spawns one MCP client per server; protocol handles capability negotiation, discovery, invocation, and error reporting without the host knowing anything about the server’s implementation. Once your server exists, any MCP-compatible host can use it — it’s the USB-C port for AI capabilities.
This tutorial covers tools only, which is where 90% of the value is.
Prerequisites
You need:
- Node.js 18+ —
node -vshould showv18or higher - pnpm —
npm i -g pnpmif you don’t have it - TypeScript — installed per-project below, not globally required
Create a project:
mkdir weather-mcp && cd weather-mcp
pnpm init
pnpm add @modelcontextprotocol/[email protected] zod@3
pnpm add -D @types/node typescript
mkdir src && touch src/index.ts
package.json — add these fields after pnpm init:
{
"type": "module",
"bin": { "weather-mcp": "./build/index.js" },
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"dev": "tsc --watch"
}
}
tsconfig.json — Node16 is required for ESM import resolution:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Failure mode: Using "module": "CommonJS" here breaks .js extension imports from the SDK. If you see ERR_REQUIRE_ESM or can’t import @modelcontextprotocol/sdk/server/mcp.js, check this first.
Scaffold the server
Paste this into src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "weather-mcp", version: "1.0.0" });
server.registerTool(
"greet",
{
description: "Greet someone by name",
inputSchema: { name: z.string().describe("Person's name") },
},
async ({ name }) => ({
content: [{ type: "text", text: `Hello, ${name}!` }],
}),
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("weather-mcp running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
Notice console.error, not console.log. This is critical: the server communicates with Claude Code over stdin/stdout. Any console.log output corrupts that channel and breaks the connection silently. Send diagnostics to stderr, always.
Build it:
pnpm build
You should see build/index.js appear. Test manually:
echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}},"id":1}' | node build/index.js
The server should respond with a JSON-RPC initialize result. If it does, the transport is wired correctly.
Register with Claude Code
Claude Code supports three scopes:
| Scope | Config location | Shared with team |
|---|---|---|
local (default) | ~/.claude.json | No |
project | .mcp.json in project root | Yes (commit it) |
user | ~/.claude.json (global) | No |
For local development, register with local scope:
claude mcp add --transport stdio weather-mcp -- node /absolute/path/to/weather-mcp/build/index.js
Order matters: all flags (--transport, --scope, --env) must come before the server name. The -- separator is required before the command that launches your server. Getting this wrong produces a misleading error about unknown arguments.
Verify registration:
claude mcp list
# weather-mcp stdio node /absolute/path/to/weather-mcp/build/index.js
For project-scoped sharing (so everyone on the team gets the server automatically), create .mcp.json in your project root:
{
"mcpServers": {
"weather-mcp": {
"command": "node",
"args": ["/absolute/path/to/weather-mcp/build/index.js"]
}
}
}
Two rules for .mcp.json: always use absolute paths (relative paths fail depending on spawn context), and commit the file to version control so teammates don’t have to register manually.
To check server status inside a Claude Code session, run /mcp. It shows every registered server, whether it connected, and the tool list it exposed.
Implement a real tool
The greet tool is a smoke test. Now replace it with two real tools: get_alerts and get_forecast, both hitting the US National Weather Service API — free, no key required.
Replace the contents of src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const NWS_API_BASE = "https://api.weather.gov";
const USER_AGENT = "weather-mcp/1.0.0 ([email protected])";
const server = new McpServer({ name: "weather-mcp", version: "1.0.0" });
async function nwsFetch(path: string): Promise<unknown> {
const res = await fetch(`${NWS_API_BASE}${path}`, {
headers: {
"User-Agent": USER_AGENT,
Accept: "application/geo+json",
},
});
if (!res.ok) throw new Error(`NWS ${res.status}: ${res.statusText}`);
return res.json();
}
function errorResult(message: string) {
return { content: [{ type: "text" as const, text: message }], isError: true };
}
server.registerTool(
"get_alerts",
{
description: "Get active weather alerts for a US state",
inputSchema: {
state: z
.string()
.length(2)
.toUpperCase()
.describe("Two-letter US state code, e.g. CA"),
},
},
async ({ state }) => {
try {
const data = (await nwsFetch(`/alerts/active/area/${state}`)) as {
features: Array<{
properties: {
event?: string;
areaDesc?: string;
severity?: string;
description?: string;
};
}>;
};
if (!data.features.length) {
return { content: [{ type: "text", text: `No active alerts for ${state}.` }] };
}
const text = data.features
.slice(0, 5)
.map((f) => {
const p = f.properties;
return [
`Event: ${p.event ?? "Unknown"}`,
`Area: ${p.areaDesc ?? "Unknown"}`,
`Severity: ${p.severity ?? "Unknown"}`,
`Details: ${(p.description ?? "").slice(0, 200)}`,
].join("\n");
})
.join("\n\n---\n\n");
return { content: [{ type: "text", text }] };
} catch (err) {
return errorResult(`Failed to fetch alerts: ${(err as Error).message}`);
}
},
);
server.registerTool(
"get_forecast",
{
description: "Get the 5-period weather forecast for a latitude/longitude",
inputSchema: {
latitude: z.number().min(-90).max(90).describe("Latitude"),
longitude: z.number().min(-180).max(180).describe("Longitude"),
},
},
async ({ latitude, longitude }) => {
try {
const pointData = (await nwsFetch(
`/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`,
)) as { properties: { forecast?: string } };
const forecastUrl = pointData.properties.forecast;
if (!forecastUrl) return errorResult("No forecast URL in NWS response.");
const path = new URL(forecastUrl).pathname;
const forecast = (await nwsFetch(path)) as {
properties: {
periods: Array<{
name: string;
temperature: number;
temperatureUnit: string;
windSpeed: string;
shortForecast: string;
}>;
};
};
const text = forecast.properties.periods
.slice(0, 5)
.map(
(p) =>
`${p.name}: ${p.temperature}°${p.temperatureUnit}, ${p.windSpeed}, ${p.shortForecast}`,
)
.join("\n");
return { content: [{ type: "text", text }] };
} catch (err) {
return errorResult(`Failed to fetch forecast: ${(err as Error).message}`);
}
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("weather-mcp running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
Three things to notice here:
isError: true on the result — not a thrown exception. When a tool call fails for a business reason (API is down, bad input, rate limit), return { content: [...], isError: true }. That signals Claude Code that the call failed but the server is healthy. The model can see the error text and retry or adjust. A thrown exception becomes a protocol-level error, which the model can’t see — it just looks like the server crashed.
Zod for input validation — z.string().length(2).toUpperCase() guarantees you never hit the NWS API with a five-letter state code. The SDK enforces the schema before your handler runs, so you don’t need a runtime check inside.
Tool count — two tools. The model considers every tool on every turn; a server with thirty tools adds latency and increases the chance it picks the wrong one. Expose the minimum useful surface.
Rebuild and Claude Code picks up the new tools immediately — no need to re-register:
pnpm build
Testing & debugging
Start a Claude Code session and check the server connected:
claude
> /mcp
● weather-mcp connected
Tools: get_alerts, get_forecast
Then ask it to use a tool:
> What are the active weather alerts in California?
I'll check the current weather alerts for California using the weather-mcp server.
[Tool call: get_alerts({"state": "CA"})]
There are currently 2 active alerts for California:
Event: Wind Advisory
Area: Inland Empire, San Bernardino County Mountains
Severity: Moderate
Details: WIND ADVISORY IN EFFECT FROM 6 PM THIS EVENING THROUGH 6 AM PDT THURSDAY...
---
Event: Small Craft Advisory
Area: Santa Monica Bay
Severity: Minor
Details: SMALL CRAFT ADVISORY IN EFFECT THROUGH FRIDAY 6 PM PDT...
If the server isn’t showing up in /mcp, check two things first: whether the build path in the registration command is absolute, and whether there’s a console.log in your code that you missed. Both produce silent failures.
Two error types — and they behave differently:
| Type | What the model sees | When it happens |
|---|---|---|
Protocol error (JSON-RPC error field) | Nothing — looks like a server crash | Unknown tool, invalid schema, unhandled exception |
Tool execution error (isError: true) | The error text you returned | API failure, bad input, business logic error |
If Claude Code says “I was unable to call the tool” without any useful message, that’s a protocol error — look in ~/.claude/logs/ for the raw JSON-RPC trace.
MCP Inspector is the fastest way to debug tool schemas before connecting Claude Code:
npx @modelcontextprotocol/inspector node build/index.js
It opens a browser UI where you can call tools directly, inspect the schema Claude Code will see, and watch the JSON-RPC messages in real time.
Common pitfalls checklist:
console.loganywhere in server code → corrupts stdio → silent connection failureoneOf/allOf/anyOfininputSchema→ rejected by Claude Code (not supported in MCP tool schemas)- Tool name containing characters outside
^[a-zA-Z0-9_-]{1,64}$→ server ignored with a warning - Server name
workspace→ reserved; Claude Code skips it silently - Relative path in
.mcp.json→ fails depending on spawn context; always use absolute paths - Tool output over 10,000 tokens → warning displayed; default cap is 25,000 tokens (set
MAX_MCP_OUTPUT_TOKENSto raise it)
Where to go next
The official reference servers repo (85,500+ stars) has reference implementations for Filesystem, Git, Fetch, Memory, and more — maintained by the MCP steering group as educational examples. Reading them is faster than reading the spec.
Anthropic maintains a community registry for third-party MCP servers.
Two spec-level things worth knowing for when you outgrow this tutorial:
Resources — for attaching read-only data to context rather than calling a function. Useful for exposing documentation, configuration, or database schema to the model without making it an active tool call.
Streamable HTTP transport — for remote servers your team shares over a network. The pattern is the same as stdio; you swap StdioServerTransport for an HTTP transport instance. OAuth 2.0 is built into the protocol for auth.
SDK v2 alpha (@modelcontextprotocol/[email protected]) is available but not production-stable. The v1 surface in this tutorial is what Claude Code ships against today.
If you’re building on top of Claude Code itself — not just extending it — the claude-code-vs-codex comparison and the best AI coding CLI roundup are useful context for where it fits in the landscape.