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的架构其实非常清晰,主要包含三个角色:
MCP Server(工具提供方):就是我们即将要写的这个天气查询服务。它的职责是:
- 向Client宣告:“我这里有这些工具(比如
get_weather)可用。” - 定义每个工具的“使用说明书”(输入参数、输出格式)。
- 当Client发来调用请求时,执行具体的业务逻辑(调用天气API),并返回结构化的结果。
- 向Client宣告:“我这里有这些工具(比如
MCP Client(AI客户端):比如Claude Desktop。它的职责是:
- 发现并连接Server。
- 获取Server提供的工具列表及其定义。
- 在LLM需要时,代表LLM去调用合适的工具,并将结果返回给LLM用于生成最终回答。
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两件最重要的事:
- 当Client询问“你有什么工具?”时,如何回答。这通过处理
listTools请求实现。 - 当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提供商 | 免费额度/限制 | 主要特点 | 适用场景 |
|---|---|---|---|
| OpenWeatherMap | 60次/分钟,1000次/天 | 历史悠久,数据全面,文档详尽 | 个人项目、学习演示 |
| WeatherAPI | 100万次/月 | 免费额度极大,无需信用卡 | 需要大量调用的测试、原型 |
| 和风天气 | 开发者免费版有限额 | 中文支持好,国内访问快 | 主要面向国内用户的项目 |
| 心知天气 | 有免费套餐 | 国内服务,稳定可靠 | 商业项目初期的国内数据源 |
为了演示的通用性,我们选择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_在这里" } } } }配置详解与避坑指南:
command: 启动Server的命令。我们的是node。args: 传递给命令的参数。这里必须是编译后的JS文件的绝对路径。使用相对路径(如./dist/index.js)大概率会失败,因为Claude Desktop的工作目录不确定。- 如何获取绝对路径?在终端中进入你的项目目录,运行
pwd(Linux/macOS)或cd(Windows)获取当前绝对路径,然后拼接上/dist/index.js。
- 如何获取绝对路径?在终端中进入你的项目目录,运行
env: 设置环境变量。我们将API Key直接写在这里。注意:这不如.env文件安全,但对于本地测试是方便的。更安全的方式是让Server从系统的环境变量中读取,但这需要你预先在系统层面设置好。
注意:修改配置文件后,必须完全重启Claude Desktop应用(退出再重新打开),配置才会生效。
重启Claude Desktop后,当你新建一个对话时,Claude应该就能识别到我们注册的weather服务器了。你可以通过以下方式验证:
- 在输入框里,Claude可能会自动提示它可用的工具。
- 或者,你可以直接问:“你能查天气吗?” Claude应该会回复它有一个
get_weather工具,并询问你要查哪个城市。
进行测试:
- 输入:“请帮我查一下北京的天气。”
- Claude会理解你的意图,在后台调用我们的
get_weather工具,参数city设为"Beijing"。 - 我们的Server收到请求,调用OpenWeatherMap API。
- Server将格式化后的天气文本返回给Claude。
- 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协议还支持更丰富的类型,比如image或resource。虽然天气数据不适合图片,但我们可以考虑返回部分结构化数据,方便Client进行二次处理。不过,对于LLM来说,清晰的文本通常就是最好的输入。
7.5 日志与监控在生产环境中,需要记录工具的使用情况、API调用成功率、耗时等。可以在Server中集成简单的日志库(如winston或pino),将日志输出到文件或标准错误。
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工具开发的缩影:先跑通核心流程,再围绕稳定性、性能和用户体验不断打磨。