1. 先搞清楚MCP到底在解决什么问题,再决定要不要学
如果你最近在折腾AI智能体(Agent),就一定绕不开MCP这个词。我最早接触它,是在一次给Agent接入内部服务查询能力的调研里:团队当时已经重复写了三套工具封装,一套HTTP、一套WebSocket、还有一套直接让模型拼SQL,每次接到新接入需求都得从零开始搞鉴权、搞错误码、搞参数格式,极其崩溃。后来看到MCP(Model Context Protocol,模型上下文协议)的文档,才发现这就是在解决"模型要调用工具但不知道该怎么调用"这个老问题的标准化方案。
简单说,MCP是一个开放协议,作用是给AI模型提供一套统一的"插头接口"。模型可以通过它读取外部能力清单、调用具体的函数、拿回结构化结果,整个过程对模型来说都是"发现式"的:模型先看到你有什么工具,再看每个工具的输入输出长什么样,最后决定怎么调用。对服务端开发者来说,只需要按协议实现一次,就能被各种支持MCP的AI客户端即插即用。
这篇文章我想按自己的落地过程来写:从协议本身的机制、到手写一个服务端、再讲怎么接进宿主、最后讲我在生产环境里踩过的坑。适合两类人看:一是打算给业务系统接入AI能力但不知道从哪下手的后端/算法工程师,二是想深入理解Agent工具调用原理的产品或技术负责人。不用把所有代码抄下来,重点看它的链路设计和取舍逻辑。
1.1 没有MCP之前,Agent接入工具的混乱状态
我见过太多项目把"给模型加工具"做成一件别扭的事。最常见的是直接构造一个函数表,把所有可用能力的描述塞进System Prompt,模型自己拼JSON去调用。这个方案在小规模演示里能跑,但一旦能力多起来就会出问题。
第一个痛点是格式不统一。十个人的团队能写出十种函数描述方式,有的用英文动词短语,有的用中文长句,参数说明更是随意,模型经常在调用时传错字段。第二个痛点是认证逻辑无处安放。函数A要API Key,函数B要走内部单点登录,函数C直接读数据库,每次调用都要在工具里重新写一遍认证分支,测试和审计都很难受。第三个痛点更隐蔽——模型工具的"发现"能力被限制死了。模型只知道你塞给它的那堆函数,如果你临时加了一个新API但没更新Prompt,模型这一轮对话里永远不会知道它的存在。
不是没有人想过做统一,有人搞过OpenAPI插件,有人搞过函数注册中心,但很多方案是"中心化"的:工具能力要统一注册到一个平台上,再通过网关暴露。这在大型组织里落地阻力很大,因为每个系统的数据可能都不愿意出网。MCP选了另一条路:它不是中心化的平台,而是一个协议,让数据和工具"长在各自的系统里",AI客户端直接通过协议去访问。这样每个系统只需要实现一次协议接口,就能被任意支持方复用。
1.2 MCP的核心思路:把工具能力"驱动程序化"
MCP最妙的地方,是把"工具能力"做成了类似操作系统中"设备驱动"的概念。回想一下:操作系统不需要知道打印机内部结构,打印机厂商只要提供一个符合USB接口协议的驱动,系统就能即插即用。MCP也是这个思路,只是把"USB接口"换成了"JSON-RPC消息",把"打印机驱动"换成了你的工具服务器。
在MCP里有两个关键实体,一个是MCP Server,可以理解为一个提供单一能力的服务进程,比如"查天气的能力"、"读GitHub仓库的能力";另一个是MCP Client,它是嵌入在宿主里的连接器,宿主也就是实际跟你的Agent模型对话的软件。整个过程里还有一层叫Host,也就是宿主程序本身,它负责给模型提供上下文、展示结果、管理对话。
我画过一次三者关系,用文字描述一下就是:用户问模型"帮我查一下这个航班状态",模型所在的宿主读到了Server提供的工具列表,觉得"查航班"这个可以用,于是让Client向Server发请求,Server去航空公司接口取数据,返回结构化结果,Host把结果整理后回给模型,模型再组织成自然语言答复。这里最关键的认知是:模型不直接连接任何外部API,它只和Protocol打交道。
1.3 一次请求的完整旅程,跟着一个真实场景走一遍
为了把链路说透,我拿一个"查询国际航班实时状态"的场景做过端到端调试。这个场景特别适合理解MCP,因为它既涉及外部API调用,又有参数鉴权、结果格式化和超时处理。
当用户对模型说"帮我查一下下周一北京飞东京的航班,重点看有没有延误",宿主真正做的是:先通过Client向Server发起tools/list请求,拿到系统里全部工具的描述,模型根据描述在内部推理里选中"查询航班状态"这个函数,随后Client再发一个tools/call请求,传参包括航班号或出发到达机场与时间。Server收到请求后解析参数、调航司数据接口、把结果整理成JSON返回,Client把结果封装成一个"工具响应"丢回给模型,模型基于这个结果继续生成回复。
这个过程里有一个容易被忽略的细节:模型其实"看到"了两次信息,一次是工具描述(input schema),一次是工具返回值。这两个信息都伴随着一定的上下文长度开销,所以生产上设计工具schema时,描述不用过长,返回结果也要尽量精简。如果你发现模型总是选错工具或者乱传参数,第一时间要检查的不是模型的指令提示词,而是你的工具描述是否足够精确、返回值是否足够结构化。这部分后面我会专门讲。
2. 协议核心机制拆解:JSON-RPC、能力协商与三原语
MCP协议层并没有发明一套新的"魔法",它核心是建立在JSON-RPC 2.0之上。要理解MCP,先理解它为什么选这个基础,再理解它的生命周期设计。
2.1 为什么选JSON-RPC 2.0:简洁性带来的生态红利
JSON-RPC 2.0是一个非常轻量的远程调用协议:请求就是一个带method、params、id的JSON对象,响应就是带result或error的JSON对象,没有HTTP那样一堆报文头部的开销,也没有消息队列那样的复杂路由逻辑。MCP选中它,我理解有两大原因。
第一,它足够通用且无状态。没有状态让Server端实现变得简单,不用维护会话对象,也没那么多依赖,用任何语言写服务端,核心逻辑都是几十行的事。第二,它的消息格式已经自然映射到JSON,模型和程序都能轻易解析。一个直观的对比是:如果你用RESTful接口去做Agent工具接入,需要为每个工具定义一套独立的路由、错误码和鉴权方式;而JSON-RPC把所有能力统一成了"一个方法名+一段参数",这让工具列表的自动发现变得极其自然。
这里要顺带提一句:MCP底层传输并不强制使用HTTP,本地调试时常用stdio(标准输入输出)通道,远程部署才用SSE或流式HTTP。所以不要一听"协议"就以为必须起个Web服务,很多场景下它就是一个子进程,通过管道跟宿主对话,这反倒解决了"本地工具必须走网络"的很多安全问题。
2.2 初始化握手与能力协商:协议里最像"开机自检"的环节
MCP里所有会话都始于一次握手(handshake)。Client连上Server之后,首先发一条initialize请求,携带Client的协议版本、自身能力声明和实现信息。Server收到后返回自己的协议版本、能力集和实现信息。这个阶段最关键的两个字段是mcpVersion和capabilities。
我当时第一次读规范时,对capabilities理解得不够细,后来调试过程中才体会到它到底在协调什么。比如Client声明自己支持resources.subscribe,它表示我不仅能读取资源,还能订阅资源变更通知;Server收到后就可能开放这一能力。如果Client没有声明tools相关字段,Server在后续连接里就不响应tools/list请求,或者只在协议里报错。这就是一种能力协商,类似两个人见面先确认"你会英语还是只会中文",再决定后面用什么语言交流。
下面这个是我实际调试中打印出来的握手消息示例(脱敏后的真实结构):
# 客户端发起 initialize 请求 { "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "clientInfo": {"name": "agent-host-demo", "version": "1.0.0"}, "capabilities": { "roots": {"listChanged": True}, "sampling": {}, "experimental": {} } } }# 服务端返回 initialize 响应 { "jsonrpc": "2.0", "id": 0, "result": { "protocolVersion": "2024-11-05", "serverInfo": {"name": "my-tools-server", "version": "0.3.1"}, "capabilities": { "tools": { "listChanged": True }, "resources": { "subscribe": True }, "prompts": {} } } }握手完成后,双方还要互发一个notifications/initialized通知,表示"初始化阶段结束,可以开始正常业务消息了"。我建议初学者调试时一定要打印这个阶段的原始数据,因为你后面遇到几乎所有"Client连不上Server"或者"功能拉不出来"的问题,都出在这个握手阶段:版本协商失败、能力字段拼错、JSON里多了个逗号,都会让连接静默失效。
2.3 Tools、Resources、Prompts:协议里的三套原语
协议里最常被问到的是三套东西:Tools(工具)、Resources(资源)、Prompts(提示词模板)。很多人一开始以为它们只是"同一个东西的三种叫法",其实定位完全不同,需要分开去理解。
Tools是用来"执行动作"的接口。模型调用它是为了产生副作用或获取实时数据,比如查询数据库、调外部API、给用户发通知。Tool调用是动态的、有参数的、需要一次往返的。Resources则更像"文件",是给模型读取的上下文内容,比如一个项目里的README、一份运行配置、一张图片的链接。模型通过resources/read读取它们,但不产生外部副作用。而Prompts是给用户或模型使用的"模板",它包含预先编排好的指令和上下文,能帮助模型以某种既定方式工作。
我记得一个特别贴切的类比:Resources像"资料库",模型想了解背景就翻资料;Tools像"手和脚",模型要改变世界才动手;Prompts像"标准作业指导书",告诉模型遇到某类任务时按什么套路回答。下面这个表格可以作为设计时的参考:
| 原语 | 核心关键词 | 交互方式 | 典型例子 | 副作用 |
|---|---|---|---|---|
| Tools | 动作、函数 | 模型发起tools/call | 查航班、发邮件、执行计算 | 可以有 |
| Resources | 数据、上下文 | 模型发起resources/read | README、配置文件、日志文件 | 通常无 |
| Prompts | 模板、引导 | 用户或环境获取后注入 | 代码评审Prompt、周报Prompt | 无 |
分清这三者,你在设计Server时就不容易把"该给模型读的文件"错做成"工具调用"。我见过有人把数据库表结构塞进工具描述里,结果模型每次调用都要传一堆不相关的列,上下文开销翻倍,响应质量还差。正确的做法是表结构用Resources提供,而"执行查询并返回结果"用Tools提供。
3. 手把手实现一个MCP Server:从空目录到可用服务
理论说多了容易飘,下面进入实操环节。我用的是Python生态,用的库是社区里常用的FastMCP封装(基于官方SDK做了一层简化),拿来写Demo特别顺手。它已经把握手、工具注册、传输这些细节封装好了,你只需要关心业务函数。
3.1 十分钟拉起第一个MCP Server:先把环境配好
先做准备工作。你只需要Python 3.10以上,然后安装依赖:
# 建议在虚拟环境里操作 mkdir mcp-server-demo && cd mcp-server-demo python -m venv .venv source .venv/bin/activate # Windows下用 .venv\Scripts\activate pip install "mcp[cli]" fastmcp httpx安装完成后,创建一个server.py,下面是完整的迷你服务端代码:
from fastmcp import FastMCP mcp = FastMCP("demo-server") @mcp.tool() def add(a: int, b: int) -> int: """把两个整数相加,返回结果。""" return a + b @mcp.tool() def current_time(timezone: str = "Asia/Shanghai") -> str: """获取指定时区的当前时间,timezone 参数为 IANA 时区名。""" from datetime import datetime from zoneinfo import ZoneInfo now = datetime.now(ZoneInfo(timezone)) return now.isoformat() if __name__ == "__main__": mcp.run()跑起来特别简单:
python server.py默认情况下它会以stdio通道运行,也就是不监听端口,通过标准输入输出跟父进程通信。你直接在终端里运行时会发现"好像什么都没发生",这是正常的,因为协议等着客户端来通过管道连它。很多初学者在这一步会以为程序卡死了,实际不是。
3.2 定义带完整Schema的工具:让模型精准理解你的函数
上面的例子太简单,真实场景里工具的参数往往不是两个int,而是一个复杂对象。假设我们要做一个"查询航班状态"工具,它需要传入航班号、出发日期、乘客类型等参数。此时就不能靠类型注解硬推了,我推荐用Pydantic模型显式定义参数Schema。
from pydantic import BaseModel, Field from fastmcp import FastMCP mcp = FastMCP("flight-server") class FlightQuery(BaseModel): flight_number: str = Field(description="航班号,如 CA123") departure_date: str = Field(description="出发日期,格式 YYYY-MM-DD") passenger_type: str = Field(default="adult", description="adult/carefree/infant") @mcp.tool() def query_flight_status(query: FlightQuery) -> dict: """ 根据航班号与出发日期查询航班当前状态,包括计划起飞时间、预计到达时间、延误状态与登机口。 """ # 这里替换成你的真实航班API调用 # 演示用一条模拟数据返回 return { "flight_number": query.flight_number, "date": query.departure_date, "status": "on_time", "gate": "C18", "scheduled_departure": "09:30", "estimated_arrival": "11:50" } if __name__ == "__main__": mcp.run()代码里最关键的不是业务逻辑,而是Field里的description。这些描述最终会变成工具JSON Schema里的描述,直接决定大模型能不能正确填参。我自己调试时做过对比:字段描述具体到"格式必须是YYYY-MM-DD"时,模型传错格式的概率能降一半以上;什么都没写时,模型经常乱发挥,甚至会把日期传成"下周五"这种自然语言。
这里有个建议:工具的"函数名"要清晰但不要太长,别用一长串动宾结构。模型选择工具主要看函数名和第一个描述句子,这个句子要能回答三个问题:这个工具处理什么领域?能拿到什么类型的结果?调用时注意什么前提条件?一个反例是"process_data"这种,完全看不出用途,模型只能靠猜;正例是"query_flight_status_by_date",光看名字就八九不离十。对于有基础读者,我强烈建议你在工具实现里加上一段内部日志,打印收到的原始参数,这在后续对照"模型实际传了啥"和"服务端打算干啥"时非常有用。
3.3 错误处理与结构化返回:别把崩溃信息直接抛给模型
工具函数出异常是必然的,问题是怎么返回。如果你在Tool里直接raise一个异常,FastMCP会把它包装成协议级错误返回给客户端,模型能看到一段堆栈或错误文本。这个行为在调试期没问题,但生产中很不友好,因为模型经常拿错误堆栈去猜"到底要怎么改参数",反而越猜越乱。
更好的做法是把错误变成结果的一部分,返回一个结构化的"状态对象"。我惯用的返回模板是:
@mcp.tool() def query_flight_status(query: FlightQuery) -> dict: try: data = real_flight_api.fetch(query) except FlightNotFound: return {"success": False, "error_code": "FLIGHT_NOT_FOUND", "message": "没有找到该航班,请核对航班号与日期"} except ApiTimeout: return {"success": False, "error_code": "UPSTREAM_TIMEOUT", "message": "航司接口暂时没有响应,请稍后再试"} return {"success": True, "data": data}这样做的好处是模型拿到结果后不用猜测,它能直接根据success和error_code决策下一步:可以换参数重试、可以告诉用户查询失败,也可以转人工。这套逻辑和Web服务的错误码设计是相通的,只是注意:模型的指令系统对错误码的语义理解能力有限,所以message字段一定要写人话,而不是"0x12ABFCD"这种内部编码。
另外,返回结果里不要塞无关字段。我见过一些后端同事习惯把上游API的原样响应直接返回,里面几十个字段,模型读起来既费token又容易迷失重点。正确做法是做一层裁剪,只返回模型决策和用户展示真正需要的字段。
3.4 本机调试:用MCP Inspector观察每条消息
写完Server,怎么验证?我强烈推荐直接使用官方配套的调试工具MCP Inspector(装SDK时通常会一并带上,如果你用的是mcp[cli],它已经在你的虚拟环境里了)。它是一个本地Web面板,能让你可视化地浏览工具列表、发起调用、查看原始日志。
启动方式有两种。一种是在当前目录下执行:
mcp dev server.py这个命令会以stdio模式启动你的Server,并在浏览器里打开调试面板,面板上能看到Service正在运行、工具列表、资源列表和实时日志。另一种方式是针对远程服务,你可以直接:
mcp inspector在面板里配置远程连接地址。调试面板里我最常用的是"消息日志"页,它会原样展示每一个Client和Server之间的JSON-RPC消息。我过去排查90%的接入问题,都是靠看这几个东西:握手阶段有没有成功、工具列表拉取时是否带上了Schema、调用某个工具时模型实际传的参数是什么。看完这些,绝大多数"为什么模型不选我的工具"都能找到答案。
一个很多新手的误区是,写完工具直接丢给宿主,结果一点日志都不看,只凭"模型答得不好"来判断工具坏了。实际问题很可能是工具本身没挂上、参数校验失败,或者描述写得让模型误以为这是另一个用途。MCP Inspector的使用习惯应该像写代码时用VS Code断点一样,成为调试的默认动作。
4. 接进真实宿主:从本地配置到远程传输
MCP的价值最终体现在"接进一个正在跑的Agent宿主"里。宿主的选择很多,可能是自研的Agent框架,也可能是现成的AI客户端应用。这一节我用最常见的两种接入场景来讲配置细节和排查思路。
4.1 本地接入:理解stdio传输的配置字段
如果你的MCP Server是本地脚本,通过stdio模式接入宿主,客户端通常需要这样一段配置:
{ "mcpServers": { "flight-server": { "command": "python", "args": ["/absolute/path/to/server.py"], "env": { "FLIGHT_API_KEY": "your-key-here", "LOG_LEVEL": "DEBUG" } } } }在接入时最容易被忽视的是command和args的组合方式。如果你用uvx作为启动器,command是uvx、args是server.py的路径或包名;如果你用python,那command就是Python解释器的完整路径。尤其要注意Windows环境,command必须指向虚拟环境里的python.exe,否则宿主拉起时根本没找到依赖。
另一个大坑是环境变量传递。宿主动替你启动子进程时,不会自动继承你在终端里export的变量,必须显式写在env字段里。我碰到过最典型的一次:本地终端测试一切正常,接入宿主后工具一直报鉴权失败,排查了半天才发现是环境变量没有喂进去。所以凡涉及密钥、Token、隔离的配置,全部显式声明在env里,不要依赖"当前用户的shell环境"。
4.2 远程接入:SSE传输与访问策略
如果你的工具能力部署在一台远程服务器上,或者多个宿主需要共享同一个工具Server,那就要用远程传输。协议客户端通过HTTP与Server建立一个SSE(Server-Sent Events)连接,实现对请求和消息的接收。
这种模式在配置上通常是:
{ "mcpServers": { "flight-server": { "url": "https://tools.example.com/mcp", "headers": { "Authorization": "Bearer <token>" } } } }远程模式要注意三点。一是Server端必须实现SSE端点,并处理客户端的初始化会话;二是在headers里携带认证凭证,这需要在Server端实现授权逻辑;三是因为走HTTP,需要考虑网络超时、流式传输和断线重连。这套在功能层面和stdio没有差异,但运维复杂度显著提高,你要有日志系统、健康检查和证书管理。
我个人的建议是:本地优先。如果你的宿主运行在生产环境附近,工具进程和数据都被放在相邻部署位置上,直接用stdio子进程方式会简单很多,而且本地的工具调用天然受到宿主操作系统的权限控制,不必为网络攻击面操心。远程SSE适合那种"工具能力确实需要单独部署"或"多个团队共享一套数据源"的场景。
4.3 连上之后怎么确认真的调通了:日志与追踪
配置完成后,普通用户看不到链路内部到底发生了什么。但作为开发者,你必须在第一次接入时把链路验透。我这边的标准流程是四步:
第一,观察宿主启动日志,确认有没有出现"connection opened"或等价字样,这表示子进程或远程连接已经建立;第二,在Server端打开listChanged能力并打印tools/list请求,对比模型实际看到的工具列表是不是你想要的那几个;第三,让模型用一句话触发某个工具,比如"帮我看看CA123航班",然后立刻到Server端日志里看tools/call消息,确认入参是否合理;第四,检查返回结果是否落在模型回答里,注意模型可能不会复述原始JSON,而是组织成自然语言。
如果你在宿主界面上看不到任何工具调用痕迹,优先检查的不是模型提示词,而是宿主读的MCP配置文件的路径对不对。很多桌面宿主应用会在"用户目录下的配置文件"里寻找MCP服务器列表,而不是你的项目目录,路径配错会让一切静默失效。这类问题最折磨人,但你只要有一份能实时查看Server进程stdout日志的终端,十分钟内就能定位。
5. 进阶能力:认证鉴权、资源订阅与提示词模板
基础跑通之后,下一步是让Server能应对更真实的生产约束。这里我把自己实际做过的三个方向提炼出来:认证,资源订阅,提示词模板。
5.1 在Server端集成OAuth:谁有资格调用我的工具
当你的工具Server要暴露给内部或外部多个宿主时,必须做认证授权。MCP协议本身允许在握手阶段携带凭证,但大多数落地场景用的是标准OAuth授权码模式。
举例来说,我在内部实现过一个Git工具Server,它接公司内部代码库。流程是:宿主打开后自动向授权服务器发起跳转,用户同意授权后拿到一个短期Token,Token通过协议层传给Server,Server每次调用真实功能时带着Token去请求上游API。实现关键在Server端校验Token的有效性,并按用户身份对操作权限做分级控制。比如普通用户只能read资源,管理员才能call写类工具。
这个设计有一个直接的好处:工具能力本身不需要再造一套用户体系,直接复用组织的统一身份源。MCP里的认证可以发生在传输层(比如HTTP的Authorization头),也可以发生在消息层,细节取决于你的实现。我的建议是优先放在传输层,这样Server代码不用在每个Tool里额外校验身份,只需要在网关或入口处统一做。
5.2 Resources与订阅:让Agent能感知数据变化
工具的角色是"临时拉数据",但有些数据是持续在变的,比如某个配置文件、某个服务的最新部署状态。你当然可以每次让模型调用一个get_file_content工具,但这绕了远路,而且模型不一定会在每次回答前主动去查。MCP的Resources提供了一种更语义化的交互方式:把某些数据暴露成资源,Agent宿主动态获取到资源列表及内容,如果资源还支持subscribe,宿主还能在资源变化时收到通知。
这一个能力在实际项目中特别适合做文档和配置注入。比如你做了一个"项目运营报告生成Agent",它需要了解最新的业务指标集群配置,这些配置就以资源的形式暴露。指标每更新一次,资源Watcher就发一个notifications/resources/updated给宿主,宿主在后续对话中就能自动拿到最新值,不再依赖旧的快照。这个机制大幅减少了模型无谓的"查询动作",也避免了模型"自以为知道最新数据"的幻觉风险。
5.3 Prompts模板:把常用多步流程固化成套路
除了工具和资源,Prompts是很多人忽略但很实用的原语。它本质上是预置的提示词模板,里面可以包含指令、输入参数定义和少量上下文。宿主的用户可以直接选一个模板,填参数后生成一段完整指令。
我在实际项目中做过一个"新功能评审Prompt"的模板,模板里要求模型按照架构合理性、安全隐患、性能边界三个维度去阅读代码库里对应的资源,再输出结构化评审意见。以前这整套指令每次都要手写在对话里,现在直接通过prompts/get获取并注入。这个能力的价值在于:把你团队里最优的协作方式沉淀成可复用的协议资源,而不是让每个使用者自己临场发挥。
用好Prompts要注意一个问题:模板不要写得像"万能咒语"一样冗长。模型指令越具体越好,但太长的模板也会占用上下文,影响后续工具调用。我习惯控制在400字以内,把最关键的要求写清楚,然后用结构化输出格式约束结果。
6. 真实落地过程中的坑与经验:来自生产环境的教训
最后这部分是我最想写的。协议本身学起来不难,真正的门槛都在那些"文档没写但你一定会撞上"的细节里。
6.1 超时与长耗时任务:模型会放弃等待
MCP的常规调用是短请求:模型发来tools/call,Server处理完就返回。但真实业务里很多工具不是瞬时动作,比如发邮件、生成一份长报告、训练一个小模型、调用一个慢的第三方接口。如果你在同步函数里阻塞太久,宿主端的超时机制会先不耐烦,切断连接或直接给用户报"工具执行失败"。
我当时就踩过一次:一个工具要调用外部大模型做摘要,平均耗时40秒,而宿主默认的超时只有30秒,导致工具经常失败但日志里又查不到具体错误。这个问题的解法有三种:一是升级到支持流式响应的传输方式,先把"开始执行"的响应返回,再异步发完成通知;二是把长任务拆成"提交任务"和"查询结果"两个工具,前一个立即返回任务ID,后一个轮询或订阅结果;三是让宿主放宽对特定工具的超时限制,但这要看宿主是否支持。
我更推荐第二种方案。它把非确定性的耗时转化成了确定性的轮询问题,语义上对模型也很友好:模型看到"提交任务返回了ID",它就知道应该继续调用"查询任务状态"工具来追踪结果。很多Agent框架内置了这种多步调用能力,模型也会自己组织顺序。
6.2 工具描述和返回结构,决定了模型会不会选择你的工具
我在实战里发现一个规律:模型对工具的"选择率"和工具的Schema质量呈强相关。如果你有两个工具都能查天气,一个描述是"获取天气",另一个是"获取指定城市未来7天天气预报,可选预报开始日期,返回数组包含每日天气与穿衣建议",模型几乎总是优先尝试后者,因为它明确知道适用范围和输出形态。
另一个容易翻车的地方是返回值的稳定性。上游API偶尔会缺字段、改字段类型,导致返回结果结构不稳定。模型一旦发现这次返回["status": "ok"]、下次变成{"result": {"status": "ok"}},它后续对结果的理解就会混乱。所以我强烈建议在工具内部做一层"数据规格化",确保从工具返回的任何结果字段名和类型都是一致的,宁可多写几个映射函数也不要给模型不稳定输出。
6.3 协议版本与能力协商:不要假设对方什么都懂
MCP协议还在快速演进,不同SDK版本、不同宿主之间,对协议版本的支持程度并不一致。我在对接一个第三方宿主时就发现,它支持的mcpVersion比我当前SDK默认要求的新版本要老,结果握手时直接失败。这个问题在协议演进期会非常常见,解决方案也很机械:在initialize阶段查看宿主声明版本,如果失败,降级SDK版本或显式在配置里指定兼容版本。
能力协商这块容易踩的另一个坑是:宿主声明支持resources.subscribe,你的Server也在capabilities里声明了,但双方处理通知消息的细节不一致,导致订阅总是静默断开。排查这类问题最有效的办法,还是回到MCP Inspector的消息日志页,看到底是哪条消息被静默丢弃或格式不符。不要凭感觉推断,协议阶段的问题几乎都能在原始消息里找到答案。
6.4 工具粒度:一个"万能工具"不如几个"精准工具"
设计MCP Server时最常见的一种思维惯性,是把所有能力揉成一个工具:传一个action参数,用if-else分支区分"查天气""查股票""查新闻"。这个设计看着省事,协议层面也能工作,但它会直接伤害模型的基础性能。
原因是模型的工具选择本质上是一个排序任务:给出一堆工具名和描述,模型判断选哪个。如果工具数量少而分支多,模型就需要在推理时理解"action等于什么时才对应什么",这比"选一个已命名的工具"要难得多,还容易传错参数。我在项目里做过一次对比:把一个大工具拆分成五个小工具后,模型在评测集上的工具选择准确率明显提升,生成延迟反而没有增加,因为每个工具的schema都简单了。
我的参考原则是:工具粒度以"场景动作"为边界。比如"查询航班状态"和"查询航班价格"是两个工具,而不是一个大工具里用type=status/price区分。如果动作天然分属不同领域,比如"查天气"和"发邮件",更要明确拆开,让模型一眼就能按领域锁定候选集。
6.5 权限与审计:工具Server不是命令执行器的后门
最后说一个容易被忽略的问题。MCP Server往往运行在宿主进程的上下文里,它能调外部API、读文件、访问数据库。如果这个能力暴露给不受控的用户,相当于把系统的某个操作入口直接开到了AI面前。我见过一个团队为了演示方便,把一个"执行任意Python代码"的工具挂上,结果模型被诱导执行了危险命令,好在是在沙箱环境里,否则后果不敢想。
我的实际经验是:给每个工具定义明确的"能力边界",在工具内做参数白名单和输入校验,不允许的东西一定不要提供;Server进程用独立的低权限账号运行,不要继承宿主的高权限身份;所有tools/call记录完整的调用参数、调用用户和返回结果,便于事后审计。这一点在协议层面没有强制约束,完全靠实现者的工程素养。你不想因为图省事,把AI变成内网穿透的后门,那就要从一开始把权限边界设计清楚。
最后分享一个我验证过的组合拳:把Server设计成"协议薄、业务厚"的分层结构,协议层只负责解析JSON-RPC和转发,业务层全部走统一的服务接口;这样即便MCP协议后续升级,你只需要替换协议层,业务逻辑完全不受影响。我目前维护的几个Server都用了这种结构,每次升级SDK几乎没有改过业务代码,这比把协议逻辑和业务逻辑揉在一起能省太多事了。