· 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 -v should show v18 or higher
  • pnpmnpm i -g pnpm if 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.jsonNode16 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:

ScopeConfig locationShared with team
local (default)~/.claude.jsonNo
project.mcp.json in project rootYes (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 validationz.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:

TypeWhat the model seesWhen it happens
Protocol error (JSON-RPC error field)Nothing — looks like a server crashUnknown tool, invalid schema, unhandled exception
Tool execution error (isError: true)The error text you returnedAPI 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.log anywhere in server code → corrupts stdio → silent connection failure
  • oneOf/allOf/anyOf in inputSchema → 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_TOKENS to 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.