<!-- title: 造天气MCP：让AI秒查预报 -->

# 造天气MCP：用TypeScript把免费天气API打包成Claude能调用的工具

## 概述

本教程带你从零用 TypeScript + 官方 `@modelcontextprotocol/sdk` 开发一个**天气查询 MCP 服务器**，底层接 Open-Meteo（开源、免 API Key、非商用免费），上层暴露 **工具（Tool）、资源（Resource）、提示词（Prompt）三大原语**，最后注册进 Claude Code，让 Claude 真正具备"查城市天气、给出穿衣建议"的能力。适合已写过几行 JS、想搞懂 MCP 到底是什么、并能独立交付一个可运行 MCP 服务的中级开发者。学完你将掌握：MCP 协议核心模型、stdio 传输、Zod 参数校验、城市名到经纬度的地理编码、WMO 天气码翻译、MCP Inspector 调试、`.mcp.json` 与 `claude mcp add` 两种接入方式。

## 前置条件

- **Node.js ≥ 18**（本教程使用 LTS），可通过 `node -v` 验证
- 已安装 **Claude Code** 并能正常对话
- 了解基础 TypeScript（接口、异步 `async/await`、泛型到能看懂即可）
- 一个能联网的开发环境（Open-Meteo 需访问公网，但**无需注册、无需 Key**）
- 推荐 VS Code 作为编辑器，方便调试 TS

> 为什么选 Open-Meteo？官方明确说明"free access for non-commercial use, No API Key required"，对比同类天气 API，它在免费额度、字段丰富度（实时/逐时/逐日/空气质量/历史数据）和开源协议上都更友好，非常适合做教学样本。

## 第一步：理解 MCP 的三大原语与传输方式

在动手前，先用一张架构图把"谁和谁通信"讲清楚。MCP 是一个**客户端/服务器协议**：Claude Code（或任何 MCP Host）充当 **Client**，你写的程序充当 **Server**，双方通过某种 **Transport** 收发 JSON-RPC 消息。

```mermaid
flowchart LR
    A["Claude Code<br/>(MCP Client)"] -- "stdio / HTTP" --> B["你的 weather-mcp<br/>(MCP Server)"]
    B -- "HTTPS" --> C[("Open-Meteo API<br/>Geocoding + Forecast")]
    B -.暴露.-> T[Tools 工具]
    B -.暴露.-> R[Resources 资源]
    B -.暴露.-> P[Prompts 提示词]
    A -- "模型决策调用" --> T
```

三大原语各自定位不同：

| 原语 | 作用 | 何时触发 | 典型例子 |
|------|------|----------|----------|
| **Tool（工具）** | 模型可以"执行"的动作，有副作用或返回动态数据 | 模型自主决定调用 | `get_forecast(city)` |
| **Resource（资源）** | 模型可以"读取"的数据，类似只读文件 | 用户/模型按 URI 拉取 | `weather://supported-cities` |
| **Prompt（提示词）** | 预制的对话模板，带可填参数 | 用户从模板列表挑选 | "穿衣建议"模板 |

本教程三种都会实现，让你直观感受它们的差异。**传输方式（Transport）** 我们选 `stdio`——服务器作为一个本地子进程启动，靠标准输入/输出收发消息，最简单、最适合本地工具。

## 第二步：搭建 TypeScript 项目骨架

```bash
mkdir weather-mcp && cd weather-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
```

把 `package.json` 的入口与脚本补成下面这样（注意 `type: module`，因为 SDK 用 ESM）：

```json
{
  "name": "weather-mcp",
  "version": "1.0.0",
  "type": "module",
  "bin": { "weather-mcp": "dist/index.js" },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.29.0",
    "zod": "^3.23.8"
  }
}
```

`tsconfig.json` 关键项：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}
```

**常见问题**：若 `tsx` 运行报 `ERR_UNKNOWN_FILE_EXTENSION`，多半是没设 `"type": "module"`；若 `tsc` 报 `Cannot find module '@modelcontextprotocol/sdk/server/mcp.js'`，请确认 `moduleResolution` 为 `Bundler` 或 `NodeNext`，因为 SDK 用了显式 `.js` 后缀的子路径导出。

## 第三步：写出服务器骨架

新建 `src/index.ts`：

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

const server = new McpServer({
  name: "weather-mcp",
  version: "1.0.0",
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch((err) => {
  console.error("Server crashed:", err);
  process.exit(1);
});
```

**预期结果**：`npm run dev` 运行后，进程会**阻塞等待**输入而不退出——这是正常的，说明它正在通过 stdio 等待 Client 的 JSON-RPC 消息。直接按 `Ctrl+C` 退出即可。**千万不要用 `console.log` 打调试信息**，因为 stdout 正是协议通道，日志会污染 JSON-RPC 流，导致 Client 解析失败。调试一律走 `console.error`（stderr 不参与协议）。

## 第四步：实现"城市名 → 经纬度"地理编码工具

Open-Meteo 的预报接口要传经纬度，但用户更习惯说"北京"。我们封装一个内部函数调用 Geocoding API：

```typescript
// src/openmeteo.ts —— 把 HTTP 调用集中在一个文件，便于单测与替换
const GEO_URL = "https://geocoding-api.open-meteo.com/v1/search";
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";

export interface GeoResult {
  name: string;
  latitude: number;
  longitude: number;
  country: string;
  timezone: string;
}

export async function geocode(city: string, lang = "zh"): Promise<GeoResult[]> {
  const url = `${GEO_URL}?name=${encodeURIComponent(city)}&count=5&language=${lang}&format=json`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Geocoding HTTP ${res.status}`);
  const data = await res.json() as { results?: GeoResult[] };
  return data.results ?? [];
}
```

接口形态：`GET /v1/search?name=Beijing&count=5&language=zh&format=json`，返回 `results` 数组，含 `name / latitude / longitude / country / timezone / country_code` 等。`count` 控制返回候选数，这对"重名城市"（比如"伦敦"有英国伦敦和加拿大伦敦）很关键——我们返回多个候选让 Claude 判断。

## 第五步：实现"获取天气预报"主工具

这是整套 MCP 的核心工具。用 `server.tool()` 注册，签名是 `(name, description, inputSchema, handler)`，其中 inputSchema 用 **Zod** 描述：

```typescript
import { z } from "zod";
import { geocode } from "./openmeteo.js";

server.tool(
  "get_forecast",
  "查询某城市未来天气。输入城市名（中文或英文均可），返回实时天气、未来7天高低温、风速与天气描述。",
  {
    city: z.string().min(1).describe("城市名，例如：北京 / Tokyo / New York"),
    days: z.number().int().min(1).max(16).default(7).describe("预报天数 1-16"),
  },
  async ({ city, days }) => {
    const candidates = await geocode(city);
    if (candidates.length === 0) {
      return {
        isError: true,
        content: [{ type: "text", text: `未找到城市：${city}` }],
      };
    }
    const loc = candidates[0];
    const url =
      `${FORECAST_URL}?latitude=${loc.latitude}&longitude=${loc.longitude}` +
      `&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m` +
      `&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max` +
      `&timezone=auto&forecast_days=${days}`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`Forecast HTTP ${res.status}`);
    const f = await res.json();

    const lines: string[] = [];
    lines.push(`📍 ${loc.name}（${loc.country}）时区 ${loc.timezone}`);
    const cur = f.current;
    lines.push(
      `实时：${cur.temperature_2m}°C，湿度 ${cur.relative_humidity_2m}%，` +
        `${wmoText(cur.weather_code)}，风速 ${cur.wind_speed_10m} km/h`
    );
    lines.push("未来预报：");
    f.daily.time.forEach((d: string, i: number) => {
      lines.push(
        `  ${d} ${wmoText(f.daily.weather_code[i])} ` +
          `${f.daily.temperature_2m_min[i]}~${f.daily.temperature_2m_max[i]}°C ` +
          `降水概率 ${f.daily.precipitation_probability_max[i] ?? "-"}%`
      );
    });
    return { content: [{ type: "text", text: lines.join("\n") }] };
  }
);
```

这里有几个**工程要点**值得强调：

1. **`.describe()` 不是装饰**——MCP Client 会把这段描述连同参数描述一起塞进模型上下文，模型据此决定"什么时候调、传什么"。描述写得越精准，模型调用准确率越高，这是 MCP 与"普通函数"最大的不同。
2. **`isError: true`** 是 MCP 的标准错误约定，比抛异常更优雅：模型能读到错误文本并自行修正（比如换个城市拼写重试）。
3. **`timezone=auto`** 让服务器按当地时区返回日期，避免"查询北京时间却拿到 UTC 日期"的常见坑。
4. **字段选择**：`weather_code` 是 WMO 标准码（见下一步翻译表），`precipitation_probability_max` 是当天最大降水概率。

## 第六步：用 WMO 天气码表把数字翻译成人话

`weather_code` 是 0–99 的整数，直接给模型看它也能猜，但给用户看就是天书。建一张映射表，既服务工具返回，也方便扩展：

```typescript
// src/wmo.ts
export function wmoText(code: number): string {
  const map: Record<number, string> = {
    0: "晴", 1: "多云", 2: "局部多云", 3: "阴",
    45: "雾", 48: "雾凇",
    51: "毛毛雨", 53: "毛毛雨", 55: "强毛毛雨",
    61: "小雨", 63: "中雨", 65: "大雨",
    71: "小雪", 73: "中雪", 75: "大雪",
    80: "阵雨", 81: "中阵雨", 82: "强阵雨",
    95: "雷暴", 96: "雷暴伴冰雹", 99: "强雷暴伴冰雹",
  };
  return map[code] ?? `未知天气码 ${code}`;
}
```

把"原始数字"转成"语义化中文"是提升 MCP 工具质量的关键技巧——模型和人类都更易理解"中雨"而非 `63`。

## 第七步：补齐 Resource 与 Prompt 两个原语

为了让本教程完整覆盖三大原语，再加两个"非工具"能力：

**Resource（资源）**——暴露一个支持城市清单，模型可按 URI 主动读取：

```typescript
server.resource(
  "supported-cities",
  "weather://supported-cities",
  { description: "本服务内置常飞城市清单与经纬度", mimeType: "application/json" },
  () => ({
    contents: [
      {
        uri: "weather://supported-cities",
        mimeType: "application/json",
        text: JSON.stringify(
          [
            { name: "北京", lat: 39.9042, lon: 116.4074 },
            { name: "上海", lat: 31.2304, lon: 121.4737 },
            { name: "深圳", lat: 22.5431, lon: 114.0579 },
          ],
          null,
          2
        ),
      },
    ],
  })
);
```

**Prompt（提示词模板）**——一个"穿衣建议"模板，带 `city` 参数：

```typescript
server.prompt(
  "outfit-advice",
  "根据目标城市天气给出穿衣建议",
  { city: z.string().describe("目标城市，如：北京") },
  ({ city }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text:
            `请先用 get_forecast 工具查询「${city}」今天的实时天气，` +
            `然后基于温度、降水概率、风速，给出今天出门的具体穿衣与带伞建议。` +
            `分"上衣 / 下装 / 配件"三栏输出。`,
        },
      },
    ],
  })
);
```

三者并存的意义：Tool 是"动作"，Resource 是"参考资料"，Prompt 是"现成剧本"。一个设计良好的 MCP 服务器应当根据信息类型选择正确的原语，而不是把所有东西都塞进工具。

## 第八步：用 MCP Inspector 可视化调试

写完不接入 Claude 前先用官方 **MCP Inspector** 跑一遍，它是带 UI 的调试器，能直接点按钮调工具、看返回。

```bash
npx @modelcontextprotocol/inspector npm run dev
```

执行后会启动一个本地 Web 服务（通常 `http://localhost:6274`），浏览器打开即可：

1. 左侧选择 **Transport: STDIO**，Command 填 `npm`，Args 填 `run dev`，点 Connect；
2. 中间会列出所有 **Tools / Resources / Prompts**；
3. 选中 `get_forecast`，右侧填入 `city=北京`、`days=3`，点 **Run Tool**；
4. 下方直接展示返回的多行文本，方便核对 WMO 翻译、温度单位是否正确。

**预期结果**：能在不经过任何 LLM 的情况下，纯手动验证工具的输入输出，这是排查"接口 bug"与"模型调用 bug"的分界线——Inspector 里对了，就是工具没问题；Claude 里没调对，多半是描述写得不好。

## 第九步：注册进 Claude Code

MCP 服务器要被 Claude Code 识别，有两种等价方式。

**方式 A：CLI 命令（推荐快速接入）**

```bash
# 项目级：写入当前项目的 .mcp.json
claude mcp add weather-mcp -- npm run dev

# 用户级：写入 ~/.claude.json，所有项目可用
claude mcp add weather-mcp -s user -- npm run dev
```

**方式 B：手写 `.mcp.json`（推荐纳入版本控制）**

在项目根目录创建 `.mcp.json`：

```json
{
  "mcpServers": {
    "weather-mcp": {
      "type": "stdio",
      "command": "npm",
      "args": ["run", "dev"],
      "env": {}
    }
  }
}
```

```mermaid
sequenceDiagram
    participant U as 用户
    participant CC as Claude Code
    participant S as weather-mcp(子进程)
    participant API as Open-Meteo
    U->>CC: "明天去北京出差，该穿什么？"
    CC->>CC: 决策：调用 get_forecast
    CC->>S: JSON-RPC: tools/call(city=北京)
    S->>API: GET /v1/search?name=北京
    API-->>S: lat/lon
    S->>API: GET /v1/forecast?...
    API-->>S: 天气 JSON
    S-->>CC: 多行中文天气文本
    CC-->>U: 自然语言穿衣建议
```

注册后验证：

```bash
claude mcp list            # 列出已注册服务器
claude mcp get weather-mcp # 查看某服务器详情
```

进入 Claude Code 对话，输入 `/mcp` 可看到 `weather-mcp` 的连接状态与暴露的工具数量。首次使用 Claude 会请求授权（出于安全，新工具默认需用户确认）。

**常见问题排查表**：

| 现象 | 原因 | 解决 |
|------|------|------|
| `/mcp` 显示 `failed to connect` | 子进程启动失败 | 终端单独跑 `npm run dev` 看是否报错 |
| 连上了但工具没出现 | `server.tool` 注册在 `connect` 之后 | 所有注册必须在 `server.connect()` **之前**完成 |
| 调用返回乱码/协议错误 | 用了 `console.log` 打日志 | 全部改 `console.error` |
| `Cannot find module .../mcp.js` | moduleResolution 不对 | `tsconfig` 设为 `Bundler` 或 `NodeNext` |
| 城市查不到 | Open-Meteo 地理库未收录小地名 | 用更大范围的地名，或直接传经纬度 |

## 实战案例：让 Claude 当你的出差气象参谋

把前面的 `openmeteo.ts`、`wmo.ts`、`index.ts` 三文件组合（注意各文件用 `export` 串联，`import` 时带 `.js` 后缀），`npm run build` 后，在 Claude Code 里这样对话：

```
你：我下周三要从深圳飞哈尔滨，帮我看看这两座城市未来7天天气，
    并告诉我哪几天需要重点防寒、行李该塞什么。

Claude（内部自动）：
  1. 调 get_forecast(city="深圳", days=7)
  2. 调 get_forecast(city="哈尔滨", days=7)
  3. 读取 weather://supported-cities 确认城市
  4. 综合两地温差，给出分层穿衣 + 行李清单
```

样例输出（节选）：

```
📍 哈尔滨（中国）时区 Asia/Shanghai
实时：-18.4°C，湿度 76%，中雪，风速 22 km/h
未来预报：
  2026-07-10 中雪 -21~-15°C 降水概率 85%
  ...
📍 深圳（中国）时区 Asia/Shanghai
实时：31.2°C，湿度 78%，阵雨，风速 9 km/h
...
建议：深圳飞哈尔滨温差近 50°C，行李请按"洋葱穿衣"准备：
  上衣：短袖（深圳出发）+ 抓绒 + 羽绒长款（哈尔滨落地即穿）
  下装：薄裤 + 备一条加绒裤
  配件：围巾、手套、毛线帽必带；哈尔滨多日降雪，建议防水靴
```

一个"查天气 + 出建议"的需求，被拆解成两次工具调用 + 一次资源读取 + 模型综合推理，整个过程无需你手动复制经纬度、无需打开浏览器——这就是 MCP 把"外部能力"变成"模型原生能力"的价值。

## 扩展思路

掌握这套骨架后，可沿以下方向增强：

- **加历史与空气质量**：Open-Meteo 还有 `/v1/archive`（历史）和 `/v1/air-quality`（PM2.5/PM10），按同样套路新增工具即可
- **加缓存**：对同一城市 10 分钟内的查询做内存缓存，降低外部 API 压力
- **换成 Streamable HTTP 传输**：用 `@modelcontextprotocol/sdk/server/streamableHttp.js` 把服务搬到云上，供多端共享（参考 Anthropic 推荐的 Streamable HTTP 协议）
- **加 OAuth**：若接的是需鉴权 API，用 MCP 的 `OAuthProvider` 包装令牌刷新
- **发布到 npm**：设好 `bin` 字段后 `npm publish`，别人 `claude mcp add weather-mcp -- npx weather-mcp` 即可一键接入

## 训练营适用性评估

- **适合营种**：AI 工程实战营、大模型应用开发营、开发者效率工具营。作为"MCP 主题日"的主干实验，配合 Skill 与 Hooks 主题形成"自定义能力三件套"。
- **教学时长**：建议 **半天（3–4 小时）**。前 1 小时讲协议与原语，中间 1.5 小时带练三文件实现，最后 1 小时做 Inspector 调试 + Claude Code 接入 + 自由扩展。
- **难度**：⭐⭐⭐（中）。门槛在 TypeScript 工程化与 Zod 描述意识，而非协议本身。
- **可量化产出**：学员下课带走一个能跑、能查全球天气、能在自己 Claude Code 里随时调用的 MCP 服务，以及可复用的"API→MCP"封装模板。
- **延伸练习**：让学员把任意一个自己喜欢的免费 API（汇率、地震、星座、GitHub Trending）按同样三原语模板封装成 MCP，第二节课做互评与接入演示。

## 参考来源

1. [MCP TypeScript SDK 官方文档（v1）](https://ts.sdk.modelcontextprotocol.io/) — `@modelcontextprotocol/sdk` 的 McpServer、StdioServerTransport、tool/resource/prompt 注册 API 官方说明
2. [@modelcontextprotocol/sdk - npm](https://www.npmjs.com/package/@modelcontextprotocol/sdk) — 最新版本（1.29.x）与安装方式，含 ESM 子路径导入约定
3. [Build an MCP Server with TypeScript: 2026 Tutorial (DEV Community)](https://dev.to/jangwook_kim_e31e7291ad98/build-an-mcp-server-with-typescript-2026-tutorial-1ipk) — "MCP 月下载超 9700 万、超 1 万个公开服务器"的行业数据来源，及 Zod 校验实战范例
4. [Open-Meteo 官方站点与文档](https://open-meteo.com/en/docs) — 免 API Key、非商用免费的开源天气 API，Geocoding 与 Forecast 接口参数说明
5. [Model Context Protocol 官网](https://modelcontextprotocol.io/) — MCP 协议规范、SDK 列表与 Streamable HTTP 等传输方式总览

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
