ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零构建MCP天气查询工具:让AI Agent学会调用外部API

从零构建MCP天气查询工具:让AI Agent学会调用外部API

1. 从“智能体”到“工具人”:为什么我们需要MCP

最近和几个做AI应用的朋友聊天,大家都有一个共同的感受:大语言模型(LLM)本身很强大,但让它真正“干活”的时候,总感觉差点意思。比如,你让它帮你查一下明天北京的天气,它可能会给你一段非常逼真的、包含温度、湿度、风向的“虚构”回答,因为它本质上是在根据训练数据中的模式“生成”文本,而不是真的去调用一个实时的天气API。这种“一本正经地胡说八道”在需要精确、实时数据的场景下,就成了致命伤。

这就是AI Agent(智能体)要解决的核心问题之一:让AI不仅会“想”,还要会“做”。一个完整的Agent,通常需要具备感知(理解用户意图)、规划(拆解任务步骤)、行动(调用工具执行)、反思(评估结果并调整)的能力。而“行动”这一步,往往就是通过调用各种外部工具(Tools)来实现的,比如搜索引擎、数据库、计算器,或者我们今天要做的——天气API。

那么,如何让LLM知道有哪些工具可用,以及如何规范地调用它们呢?这就是Model Context Protocol(MCP)登场的背景。你可以把MCP想象成AI世界的“USB标准”或者“驱动协议”。它为工具(比如我们的天气查询服务)和AI客户端(比如Claude Desktop、Cursor等)之间定义了一套标准的通信方式。工具开发者按照MCP的规范“写驱动”(实现Server),AI客户端“插上就能用”(实现Client),无需为每个工具单独适配。

所以,写一个MCP天气查询工具,绝不仅仅是调个API那么简单。它是一次绝佳的实践,让你能亲手摸到AI Agent“行动层”的脉搏,理解工具如何被标准化地集成到AI工作流中。接下来,我们就从零开始,拆解这个过程。

2. 动手之前:厘清MCP的核心概念与我们的目标

在敲代码之前,我们必须先搞清楚几个关键概念,否则很容易在实现过程中迷失方向。MCP的架构其实非常清晰,主要包含三个角色:

  1. MCP Server(工具提供方):就是我们即将要写的这个天气查询服务。它的职责是:

    • 向Client宣告:“我这里有这些工具(比如get_weather)可用。”
    • 定义每个工具的“使用说明书”(输入参数、输出格式)。
    • 当Client发来调用请求时,执行具体的业务逻辑(调用天气API),并返回结构化的结果。
  2. MCP Client(AI客户端):比如Claude Desktop。它的职责是:

    • 发现并连接Server。
    • 获取Server提供的工具列表及其定义。
    • 在LLM需要时,代表LLM去调用合适的工具,并将结果返回给LLM用于生成最终回答。
  3. Transport(传输层):Server和Client之间如何通信。MCP支持几种方式,对于我们这个本地工具,最常用的是stdio(标准输入输出)sse(服务器发送事件)stdio模式最简单,我们的Server作为一个命令行进程启动,Client启动这个进程并通过管道(stdin/stdout)与之交换JSON格式的消息。

我们的项目目标很明确:实现一个MCP Server,它提供一个名为get_weather的工具,接收城市名作为参数,调用第三方天气API获取实时数据,并以MCP规定的格式返回给Client。

为了完成这个目标,我们需要选择合适的编程语言和MCP SDK。由于MCP协议基于JSON-RPC,理论上任何语言都能实现。但考虑到生态和便捷性,Node.js(TypeScript)Python是目前最主流的选择,官方和社区提供了成熟的SDK。这里我选择用TypeScript来演示,因为它强大的类型系统能很好地匹配MCP的协议定义,减少出错。

提示:如果你更熟悉Python,完全可以使用mcp这个Python库,整体思路和架构是完全相通的。

3. 环境搭建与项目初始化:从空白目录到类型安全的起点

好了,理论准备就绪,我们开始动手。首先确保你的开发环境已经安装了Node.js(建议版本18+)和npm/yarn/pnpm等包管理器。

# 创建一个新的项目目录并进入 mkdir mcp-weather-server cd mcp-weather-server # 初始化npm项目,一路回车或按需填写 npm init -y # 初始化TypeScript配置 npx tsc --init

接下来,安装我们核心的依赖包。我们将使用@modelcontextprotocol/sdk这个官方SDK来快速构建Server。

npm install @modelcontextprotocol/sdk

因为我们还需要调用外部HTTP API,所以也安装一个流行的HTTP客户端库,比如axios

npm install axios

同时,安装TypeScript相关的开发依赖,用于编译和类型检查。

npm install --save-dev typescript @types/node ts-node

现在,打开tsconfig.json文件,进行一些基本配置以确保编译顺利。以下是一个适用于本项目的简化配置:

{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules"] }

创建我们的源代码目录和入口文件:

mkdir src touch src/index.ts

至此,一个类型安全、结构清晰的MCP Server项目骨架就搭建好了。package.json里记录了依赖,tsconfig.json指导TypeScript如何编译,src/index.ts将是我们编写所有逻辑的地方。

4. 构建MCP Server骨架:连接、工具声明与生命周期

让我们开始编写src/index.ts。首先,导入必要的模块。

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 axios from 'axios';

接下来,我们初始化MCP Server实例。需要给它起个名字,并定义版本,这些信息会告知Client。

// 创建Server实例 const server = new Server( { name: 'mcp-weather-server', version: '0.1.0', }, { capabilities: { tools: {}, // 声明本Server提供工具能力 }, } );

Server创建好了,但它现在还是个“哑巴”,不知道如何与外界通信。我们需要为它创建一个“传输层”(Transport)。如前所述,我们使用最简单的stdio方式。

// 创建Stdio传输层 const transport = new StdioServerTransport();

现在,我们需要告诉Server两件最重要的事:

  1. 当Client询问“你有什么工具?”时,如何回答。这通过处理listTools请求实现。
  2. 当Client说“请使用某个工具并给我结果”时,如何执行。这通过处理callTool请求实现。

我们先处理listTools。这里我们定义了一个工具,叫get_weather

// 处理工具列表请求 server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'get_weather', description: '获取指定城市的当前天气信息。', inputSchema: { type: 'object', properties: { city: { type: 'string', description: '城市名称,例如:Beijing, Shanghai, New York', }, }, required: ['city'], }, }, ], }; });

这段代码是核心之一,我们来拆解一下:

  • name: 工具的唯一标识符,Client将通过这个名字来调用它。
  • description: 工具的详细描述。这个描述极其重要,因为LLM(如Claude)会阅读这个描述来决定在什么场景下使用这个工具。描述要清晰、准确。
  • inputSchema: 定义了工具的输入参数格式,遵循JSON Schema标准。
    • type: 'object'表示输入是一个对象。
    • properties下定义了对象有哪些属性。这里我们只有一个属性city,类型是字符串。
    • required: ['city']表示city参数是调用时必须提供的。

接下来,处理callTool请求。这是真正执行业务逻辑的地方。

// 处理工具调用请求 server.setRequestHandler(CallToolRequestSchema, async (request) => { // 1. 检查调用的工具名是否是我们提供的 if (request.params.name !== 'get_weather') { throw new Error(`Unknown tool: ${request.params.name}`); } // 2. 从请求参数中提取城市名 // 注意:SDK已经根据inputSchema做了初步校验,但这里我们仍要安全地提取 const args = request.params.arguments as { city?: string }; const city = args.city; if (!city || typeof city !== 'string') { throw new Error('The \"city\" argument (string) is required.'); } // 3. 这里是调用真实天气API的地方,我们先返回一个模拟结果 // 稍后我们会替换成真实的API调用 const mockWeatherInfo = `当前城市 ${city} 的天气模拟数据:晴,25°C,湿度50%。`; // 4. 按照MCP格式返回结果 return { content: [ { type: 'text', text: mockWeatherInfo, }, ], }; });

最后,我们需要启动Server,让它开始监听来自Client的连接。

// 启动Server,连接传输层 async function runServer() { await server.connect(transport); console.error('MCP Weather Server running on stdio...'); } runServer().catch((error) => { console.error('Server error:', error); process.exit(1); });

注意,我们将日志输出到console.error(标准错误),因为stdio传输层将stdout用于协议通信,普通的日志输出会干扰协议消息,导致连接失败。这是一个非常关键的细节。

现在,一个最基础的、能响应协议请求的MCP Server骨架就完成了。它虽然返回的是模拟数据,但已经具备了完整的MCP通信能力。我们可以先编译运行一下,看看它是否能正常启动。

# 编译TypeScript到dist目录 npx tsc # 运行编译后的JS文件 node dist/index.js

运行后,程序会挂起,等待Client通过stdin连接。你可以按Ctrl+C终止。到目前为止,我们成功搭建了MCP的“基础设施”。下一章,我们将注入灵魂——接入真实的天气API。

5. 接入真实数据源:天气API的选择、调用与错误处理

骨架有了,现在需要让它真正“活”起来,去获取真实的天气数据。这里有几个公开的天气API可供选择,各有优劣:

API提供商免费额度/限制主要特点适用场景
OpenWeatherMap60次/分钟,1000次/天历史悠久,数据全面,文档详尽个人项目、学习演示
WeatherAPI100万次/月免费额度极大,无需信用卡需要大量调用的测试、原型
和风天气开发者免费版有限额中文支持好,国内访问快主要面向国内用户的项目
心知天气有免费套餐国内服务,稳定可靠商业项目初期的国内数据源

为了演示的通用性,我们选择OpenWeatherMap。首先,你需要去其官网(openweathermap.org)注册一个免费账户,然后在控制台获取你的API Key。这个Key是调用API的凭证,务必妥善保管,不要直接硬编码在代码里提交到公开仓库。

我们将使用环境变量来管理这个敏感信息。在项目根目录创建一个.env文件:

OPENWEATHER_API_KEY=你的_Api_Key_在这里

然后安装dotenv包来在开发时加载环境变量。

npm install dotenv

现在,我们来改造src/index.ts中的工具调用处理器,用真实的API调用替换模拟数据。首先在文件顶部导入dotenv并配置。

import * as dotenv from 'dotenv'; dotenv.config(); // 加载 .env 文件中的环境变量 const OPENWEATHER_API_KEY = process.env.OPENWEATHER_API_KEY; if (!OPENWEATHER_API_KEY) { console.error('错误:未设置 OPENWEATHER_API_KEY 环境变量。请在 .env 文件中配置。'); process.exit(1); }

接下来,我们编写一个专门的函数fetchWeatherFromOpenWeather来处理API调用。这里会涉及几个重要的实践点:参数处理、错误处理和结果解析。

async function fetchWeatherFromOpenWeather(city: string): Promise<string> { // 1. 构造API请求URL // 使用“按城市名查询”的端点,units=metric表示使用摄氏度 const url = `https://api.openweathermap.org/data/2.5/weather`; try { const response = await axios.get(url, { params: { q: city, appid: OPENWEATHER_API_KEY, units: 'metric', // 公制单位,得到摄氏度 lang: 'zh_cn', // 返回中文描述 }, timeout: 10000, // 设置10秒超时,避免长时间等待 }); const data = response.data; // 2. 解析API返回的复杂JSON,提取我们需要的信息 // OpenWeatherMap返回的数据结构很丰富,我们取核心部分 const cityName = data.name; const country = data.sys.country; const temp = data.main.temp; const feelsLike = data.main.feels_like; const humidity = data.main.humidity; const description = data.weather[0].description; const windSpeed = data.wind.speed; // 3. 格式化成对人类和AI都友好的文本 const weatherText = `城市:${cityName}, ${country} 天气状况:${description} 温度:${temp}°C (体感 ${feelsLike}°C) 湿度:${humidity}% 风速:${windSpeed} m/s 数据来源:OpenWeatherMap`; return weatherText; } catch (error: any) { // 4. 细致的错误处理,给Client明确的反馈 console.error(`调用天气API失败 (城市: ${city}):`, error.message); if (axios.isAxiosError(error)) { // 如果是Axios错误(网络或HTTP错误) if (error.response) { // 服务器返回了错误状态码 const status = error.response.status; if (status === 401) { throw new Error('天气服务认证失败,请检查API Key。'); } else if (status === 404) { throw new Error(`未找到城市“${city}”,请检查城市名拼写。`); } else if (status === 429) { throw new Error('天气服务请求过于频繁,请稍后再试。'); } else { throw new Error(`天气服务返回错误 (状态码: ${status})。`); } } else if (error.request) { // 请求已发出但没有收到响应 throw new Error('无法连接到天气服务,请检查网络。'); } else { // 请求配置出错 throw new Error(`请求配置错误: ${error.message}`); } } else { // 非Axios错误 throw new Error(`获取天气数据时发生未知错误: ${error.message}`); } } }

这个函数体现了生产级代码的几个关键考虑:

  • 参数国际化:我们传递了units=‘metric’lang=‘zh_cn’,让返回的数据更符合中文用户习惯。
  • 超时控制:设置了timeout,防止网络不佳时无限期等待。
  • 结构化解析:从API返回的嵌套JSON中精准提取所需字段。
  • 友好的结果格式化:将数据组织成清晰的多行文本,便于LLM理解和用户阅读。
  • 全面的错误处理:区分了认证失败、城市未找到、限流、网络问题等不同情况,并抛出带有明确提示信息的错误。这些错误信息最终会通过MCP协议返回给Client和LLM,让用户知道问题出在哪里,而不是得到一个笼统的“调用失败”。

现在,我们修改callTool的处理器,调用这个真实的函数。

server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== 'get_weather') { throw new Error(`Unknown tool: ${request.params.name}`); } const args = request.params.arguments as { city?: string }; const city = args.city; if (!city || typeof city !== 'string') { throw new Error('The \"city\" argument (string) is required.'); } // 调用真实的天气API函数 try { const weatherText = await fetchWeatherFromOpenWeather(city); return { content: [ { type: 'text', text: weatherText, }, ], }; } catch (error: any) { // 捕获fetchWeatherFromOpenWeather抛出的错误,并返回给Client return { content: [ { type: 'text', text: `获取天气信息失败:${error.message}`, }, ], isError: true, // MCP协议中标记这是一个错误响应 }; } });

至此,一个功能完整、具备错误恢复能力的MCP天气查询工具Server就实现了。重新编译运行,它已经可以处理真实的查询了。不过,我们还需要一个Client来测试它。在下一章,我们将介绍如何配置流行的AI客户端(如Claude Desktop)来连接和使用我们这个自定义Server。

6. 集成与测试:让Claude Desktop“认识”你的工具

我们的Server已经就绪,但它现在还是一个独立的进程。如何让像Claude Desktop这样的AI客户端发现并使用它呢?这就需要通过客户端的配置来实现。

Claude Desktop为例,它支持通过配置文件来声明本地的MCP Server。配置文件通常位于以下位置:

  • macOS:~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows:%APPDATA%\Claude\claude_desktop_config.json
  • Linux:~/.config/Claude/claude_desktop_config.json

如果文件不存在,你可以创建它。我们需要在这个JSON配置文件中添加一个mcpServers字段。下面是一个配置示例:

{ "mcpServers": { "weather": { "command": "node", "args": [ "/ABSOLUTE/PATH/TO/YOUR/mcp-weather-server/dist/index.js" ], "env": { "OPENWEATHER_API_KEY": "你的_Api_Key_在这里" } } } }

配置详解与避坑指南:

  1. command: 启动Server的命令。我们的是node
  2. args: 传递给命令的参数。这里必须是编译后的JS文件的绝对路径。使用相对路径(如./dist/index.js)大概率会失败,因为Claude Desktop的工作目录不确定。
    • 如何获取绝对路径?在终端中进入你的项目目录,运行pwd(Linux/macOS)或cd(Windows)获取当前绝对路径,然后拼接上/dist/index.js
  3. env: 设置环境变量。我们将API Key直接写在这里。注意:这不如.env文件安全,但对于本地测试是方便的。更安全的方式是让Server从系统的环境变量中读取,但这需要你预先在系统层面设置好。

注意:修改配置文件后,必须完全重启Claude Desktop应用(退出再重新打开),配置才会生效。

重启Claude Desktop后,当你新建一个对话时,Claude应该就能识别到我们注册的weather服务器了。你可以通过以下方式验证:

  • 在输入框里,Claude可能会自动提示它可用的工具。
  • 或者,你可以直接问:“你能查天气吗?” Claude应该会回复它有一个get_weather工具,并询问你要查哪个城市。

进行测试:

  1. 输入:“请帮我查一下北京的天气。”
  2. Claude会理解你的意图,在后台调用我们的get_weather工具,参数city设为"Beijing"
  3. 我们的Server收到请求,调用OpenWeatherMap API。
  4. Server将格式化后的天气文本返回给Claude。
  5. Claude结合这个工具返回的结果,组织成一段自然的对话回复给你。

如果一切顺利,你将看到Claude返回了真实的北京天气信息。如果失败,请按以下步骤排查:

  • 检查Claude Desktop日志:Claude Desktop通常有输出日志的地方(如macOS的控制台应用),查看是否有关于启动MCP Server的错误信息。
  • 检查Server启动:在配置中,可以暂时在args里加上-e "console.error('Server starting...')"之类的,看日志是否有输出,确认命令是否被执行。
  • 检查API Key和环境变量:确保在配置的env里或系统的环境变量中,OPENWEATHER_API_KEY设置正确且有效。
  • 手动测试Server:你可以写一个简单的测试脚本,模拟Client通过stdio调用你的Server,来隔离问题。

7. 进阶优化与扩展思路:从“能用”到“好用”

一个基础的MCP工具已经跑通了,但要想让它更健壮、更实用,我们还可以做很多优化。

7.1 输入验证与清洗目前的工具只要求一个city字符串。但用户输入可能是“中国北京”、“Beijing, China”或“beijing”。虽然OpenWeatherMap的API有一定容错能力,但我们可以在Server端做一层预处理。

// 在调用API前,可以添加一个清洗函数 function normalizeCityInput(rawInput: string): string { // 移除多余空格,提取可能的核心城市名(简单示例) let cleaned = rawInput.trim(); // 可以添加更多规则,比如处理“北京市”->“Beijing” // 这里只是一个简单示例,实际可能需要更复杂的映射或地理编码服务 return cleaned; } // 在callTool处理器中使用 const city = normalizeCityInput(args.city);

7.2 结果缓存天气数据变化相对较慢,频繁查询同一城市会造成不必要的API调用(消耗免费额度)。可以引入一个简单的内存缓存。

const weatherCache = new Map<string, { data: string; timestamp: number }>(); const CACHE_TTL_MS = 10 * 60 * 1000; // 缓存10分钟 async function fetchWeatherWithCache(city: string): Promise<string> { const normalizedCity = normalizeCityInput(city); const cached = weatherCache.get(normalizedCity); if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) { console.error(`[Cache Hit] 返回 ${normalizedCity} 的缓存天气数据`); return cached.data; } console.error(`[Cache Miss] 查询 ${normalizedCity} 的实时天气`); const freshData = await fetchWeatherFromOpenWeather(normalizedCity); weatherCache.set(normalizedCity, { data: freshData, timestamp: Date.now() }); return freshData; }

7.3 支持更多功能一个工具可以定义多个“子功能”。我们可以扩展我们的工具,比如增加get_weather_forecast(天气预报)或get_air_quality(空气质量)。只需要在listTools中返回多个工具定义,并在callTool中根据request.params.name进行分支处理即可。

7.4 结构化输出目前我们返回的是纯文本(type: ‘text’)。MCP协议还支持更丰富的类型,比如imageresource。虽然天气数据不适合图片,但我们可以考虑返回部分结构化数据,方便Client进行二次处理。不过,对于LLM来说,清晰的文本通常就是最好的输入。

7.5 日志与监控在生产环境中,需要记录工具的使用情况、API调用成功率、耗时等。可以在Server中集成简单的日志库(如winstonpino),将日志输出到文件或标准错误。

import winston from 'winston'; const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports: [ new winston.transports.File({ filename: 'mcp-weather-server.log' }), new winston.transports.Console({ format: winston.format.simple() }) ], }); // 在工具调用和API调用处添加日志 logger.info('Tool called', { tool: 'get_weather', city: city });

通过以上这些优化,你的MCP工具就从一个小实验,进化成了一个更可靠、更高效的“生产就绪”组件。这个过程也是大多数AI Agent工具开发的缩影:先跑通核心流程,再围绕稳定性、性能和用户体验不断打磨。

返回列表