☰
MCP实战:从零跑通Agent统一接入外部系统的标准协议
2026/10/19 4:33:31 网站建设 项目流程

做 Agent 开发这段时间,我最大的体会不是模型能力跟不上,而是“接外部系统”这件事太碎了。今天要聊的 MCP——模型上下文协议(Model Context Protocol),就是为这件事而生的:它给 Agent 和外部系统之间定了一套标准接口,让 Agent 能以标准方式接入文件、数据库、HTTP API,乃至另一台机器上的服务。这篇文章不打算在协议层面做长篇大论,而是以“我从零跑通一个 MCP 服务器”为主线,把协议架构、最小实现、接入过程和踩坑点讲透。适合正在写 Agent、却被各种第三方工具接口绑死的开发者,也适合想搞清楚 MCP 到底解决什么问题、值不值得现在上手的人。

1. MCP 之前的日子:Agent 接外部系统为什么这么痛苦

1.1 没有统一协议时,Agent 集成到底是种什么体验

我最早做的一个模拟项目里,需要让 Agent 查询订单状态、检查库存、发送通知。听起来不复杂,做起来却很磨人。每个工具函数都得自己处理参数解析、鉴权、错误返回和结果格式化;换一个 Agent 框架,这些函数又要按新框架的规范重构一遍。你以为是复制粘贴五分钟的事,实际上可能是重新阅读对方文档半天。

更麻烦的是多系统并联。一个订单系统给 JSON,一个库存系统给 XML,一个通知服务又要走消息队列。Agent 的核心逻辑没写多少,兼容层倒是堆了一大坨。那时候我给自己定了条规矩:凡是能拆出去的工具,就尽量拆成独立服务。但拆出去之后,Agent 怎么发现这些服务?服务跑在什么协议上?最后还是每个项目都有自己的一套说法。

这里真正的痛点不是“能不能接”,而是“接法太散”。一个项目接三五个接口还能忍,到十几个接口齐上阵时,光处理协议差异就让人想摔键盘。MCP 想解决的,恰好就是这个“散”字。

1.2 Function Calling 已经做了不少工作,为什么还是不够

很多朋友听到这里会问:大模型平台不是早就有 Function Calling 了吗?确实,Function Calling 让模型能在对话里生成结构化参数,知道“该调用哪个函数、传什么参数”,这一步很关键。但它只覆盖了“模型生成调用意图”这一小段链路。

我理解中的完整链路是这样的:Agent 需要知道有哪些工具可用,知道每个工具的入参格式,调用后能拿到结构化结果,结果能再回到对话上下文里。Function Calling 把“工具描述”和“参数生成”打通了,可工具从哪来、谁来执行、执行失败怎么办、跨模型平台怎么兼容,这些都不归它管。真正到工程侧,各家平台的函数定义格式还互相不统一。我今天给 A 平台写一套函数声明,明天换到 B 平台,又得重新适配。

这也是 MCP 出现的核心原因:把“函数发现、函数描述、函数执行、结果返回”完整地做成一个协议,并且不绑定任何一家模型厂商。说得直白一点,MCP 更像是给 Agent 世界定了一个“通用电器插头”,各个外部系统只要按照插头标准生产,任何支持这个标准的 Agent 都能直接使用。

从这个角度来看,MCP 和 Function Calling 并不对立。模型侧的 Function Calling 机制可以继续用来生成工具调用参数,MCP 负责的是更外围、更工程化的部分——怎么注册工具、怎么发现工具、怎么执行工具、怎么把结果送回对话。两者各管一段,配合起来刚好补齐了整条链路。

下表是我自己在迁移前后的直观对比:

环节没有 MCP 时使用 MCP 后
工具注册每个框架各自写一套在 Server 端统一暴露
工具发现靠硬编码或人工配置initialize 后动态拉取
跨模型迁移工具声明基本要重写同一 Server 可对接多个客户端
错误处理各系统五花八门协议层统一规范

2. MCP 的架构拆解:Host、Client、Server 是怎么协作的

2.1 三个角色和一条标准连接

MCP 采用的是客户端-服务器模型,但这里说的“客户端”和“服务器”,不是传统意义上前端后端的部署关系,而是进程内部的一种角色分工。

先说三个角色。Host(宿主)指运行 Agent 的应用程序,它负责整个对话流程、上下文管理,也负责最终把工具调用结果交给模型。Client(客户端)在 Host 内部,负责和 MCP Server 建立会话、发送请求、接收响应,相当于 Host 的“通讯兵”。Server(服务器)暴露能力,它可以是一个本地子进程,也可以是一台远程服务。

日常开发里,我们多数时候是写 Server 端,把自己的数据能力变成 MCP 工具暴露出去;Host 端的工作往往由成熟的 Agent 框架或桌面客户端替我们承担。所以入门阶段,最重要的就是搞清楚“Server 要暴露什么、用什么格式暴露”。

2.2 一次调用从发起到底层传输,到底发生了什么

MCP 底层使用 JSON-RPC 2.0 协议,传输层常见有两种:stdio 和 HTTP+SSE。

stdio 适合本地场景:Host 用子进程方式拉起 Server 进程,双方通过标准输入输出交换 JSON 消息。HTTP+SSE 适合远程场景:Server 部署在某台服务器上,客户端通过 HTTP 请求调用。

一次完整调用的链路是这样的:

  1. 客户端连接 Server,发送initialize请求做握手协商,确认双方都认识哪个版本的协议;
  2. 协商完成后,客户端发送tools/list,拿到 Server 暴露出来的全部工具列表;
  3. 模型在对话中按需生成调用意图;
  4. 客户端发送tools/call,带上工具名和参数;
  5. Server 执行对应函数,返回结构化结果;
  6. 结果回到客户端,进一步注入上下文,供模型组织最终回复。

很多刚接触的人会误以为initialize就是在“传业务参数”,其实不是。它更像是两个进程在互相确认“我们开始对话吧”,业务参数要到具体调用工具时才会出现。这个认知先建立起来,后面看抓包或日志会轻松很多。

2.3 三个原语:Tools、Resources、Prompts

MCP 定义了三种能力原语,理解它们可以少走弯路:

  • Tools(工具):可执行的函数,模型来决定“要不要调用、什么时候调用”。这是最常用的一类,比如查询天气、查数据库、发通知。
  • Resources(资源):可读取的数据对象,比如一个配置文件的路径、一张数据库表。它更像是“只读的接口”,Agent 需要时可以拉取内容,但不会触发副作用。
  • Prompts(提示词模板):预置好的提示词模板,方便 Host 侧复用一套标准交互流程。

从入门角度来说,把 Tools 吃透就能覆盖绝大多数日常场景。Resources 和 Prompts 更像是锦上添花,等你在项目里发现“只读数据”和“固定交互流程”变成了重复劳动,再回头研究它们也不迟。

3. 从零实现一个 MCP 服务器:天气查询 Demo

3.1 准备环境与初始化项目

我建议用 Python 版本起步,原因是生态比较成熟,调试也直观。官方 SDK 的包名称是mcp,安装前确认 Python 版本在 3.10 以上,避免一些类型语法兼容问题。

mkdir mcp-weather-demo cd mcp-weather-demo python -m venv .venv source .venv/bin/activate pip install mcp

SDK 更新速度比较快,不同版本之间 API 会有微调。我自己写这篇文章时用的是某个稳定版本,如果你拿到的版本对不上,直接以官方文档为准,核心思路不会变。

3.2 实现一个最小可用的天气查询工具

新建weather_server.py,核心逻辑分三块:定义 Server、声明工具列表、实现工具调用。

import asyncio from mcp.server import Server from mcp.server.models import InitializationOptions from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent server = Server("weather-demo") @server.list_tools() async def list_tools(): return [ Tool( name="get_weather", description="查询指定城市当前天气的文本描述。", inputSchema={ "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如:示例市" } }, "required": ["city"] } ) ] @server.call_tool() async def call_tool(name: str, arguments: dict): if name != "get_weather": raise ValueError(f"未知工具: {name}") city = arguments["city"] # 真实项目里这里应该调用天气服务, # 演示阶段先返回固定文本,重点是跑通协议流程。 return [TextContent( type="text", text=f"{city} 当前天气:晴,24 摄氏度,东南风 2 级。" )] async def main(): async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, server.create_initialization_options() ) if __name__ == "__main__": asyncio.run(main())

这段代码看着短,但包含了一个 MCP Server 的完整要素。list_tools负责告诉外部“我有哪些工具、参数长什么样”;call_tool负责真正干活,并把结果包装成协议要求的文本内容返回。

有个容易忽略的细节:Tool.inputSchema的字段描述要写清楚。这不是给人看的,是给模型看的。模型会根据描述判断“何时调用、参数怎么填”,描述含糊会导致模型把城市名填到别的字段里。

3.3 让工具更贴近实战:加参数校验和错误返回

只返回固定文本肯定不够。接真实外部服务时,要考虑参数不合法、上游超时、权限不足等情况。这里演示一种比较容易落地的错误返回方式:工具执行出错时,不是抛出异常让连接崩掉,而是把错误信息作为结果内容返回,这样模型还能基于错误信息组织一轮话术,而不是整个会话中断。

@server.call_tool() async def call_tool(name: str, arguments: dict): if name != "get_weather": return [TextContent( type="text", text=f"错误:未知工具 {name}" )] city = arguments.get("city", "").strip() if not city: return [TextContent( type="text", text="错误:city 参数不能为空" )] try: weather_text = await fetch_weather_from_api(city) return [TextContent(type="text", text=weather_text)] except Exception as exc: return [TextContent( type="text", text=f"查询失败:{exc}" )]

fetch_weather_from_api这里就不展开写了,实际接一个天气服务商即可。值得说的是把异常兜住这一层:在真实项目里,上游 API 大概率不是永远稳定,与其让异常冒泡到协议层,不如转成一段温和的错误文本。这个习惯能帮你在后续调试里省很多时间。

3.4 用官方调试面板验证 Server 是否正常

写完后,先把 Server 跑起来。官方提供了专门的调试面板,图形化查看工具列表、手动传参调用。我最常用的流程是:

  1. 在项目目录里启动调试面板,并指定要加载的 Server 入口文件;
  2. 调试面板里能看到当前 Server 暴露的所有工具;
  3. 直接填写参数,手动调用get_weather,看返回内容;
  4. 再人为传一个空参数,确认错误返回也符合预期。

这一环节可以帮你在对接 Agent 之前,先把 Server 本身测到位。否则你分不清到底是协议写错了,还是后续 Agent 配置有问题。我第一次调试时就踩过这种亏:Server 还没跑起来,就在 Agent 端反复找配置原因,浪费了大半天。

4. 让 Agent 真正调用起来:客户端会话与工具发现

4.1 写一个最小客户端,验证完整链路

Server 写完,自然要接一个客户端跑通全流程。这里我用 Python SDK 写一个最小客户端,通过 stdio 拉起刚才的 Server 进程:

import asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client server_params = StdioServerParameters( command="python", args=["weather_server.py"] ) async def main(): async with stdio_client(server_params) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: await session.initialize() tools = await session.list_tools() print("可用工具:", [t.name for t in tools]) result = await session.call_tool( "get_weather", {"city": "示例市"} ) print("返回结果:", result) asyncio.run(main())

跑完之后,你会看到终端先打印出工具列表,再打印出调用结果。这条链路虽然简单,但它和真实 Agent 场景走的路径是完全一样的:握手、发现工具、调用工具、返回结果。区别只在于真实场景里,中间会有模型参与决定用哪个工具,而这里是我们手动指定。

4.2 工具描述写得好不好,直接影响 Agent 的调用质量

同样一个查询天气工具,两种描述方式差别很大。

低质量描述:

{ "description": "查询天气" }

稍微好一点的描述:

{ "description": "根据城市名查询当前天气,返回温度、天气现象、风向风力。城市名必须是中文,例如:示例市。如果用户没有提供城市名,先询问用户所在城市。" }

第二种描述把适用条件、参数格式、边界行为全写清楚了。模型拿到这样的描述后,误用概率会小很多。我的经验是:每个工具的 description 都应该包含三件事——这个工具做什么、什么时候该用、什么时候不该用。

还有一个常见误区是盲目堆砌工具数量。有些项目把几十个工具一股脑塞给 Agent,结果模型在tools/list返回列表过长时,反而会忽略掉关键工具。就像给一个人发了一份五百条的菜单,他大概率只看得见前几页。在工具数量膨胀之前,先用“合并同类项”的方式精简,比继续加工具更有效。

4.3 接入主流 Agent 框架时的三个注意点

现在主流的 Agent 框架大多已经内置了 MCP 客户端,配置起来比从前写兼容层简单太多。但我实测下来,仍有几个容易被信息淹没的细节:

第一,工具命名空间冲突。不同 MCP Server 可能都暴露了同一个工具名,比如一个叫get_user,另一个也叫get_user。接入多个 Server 时,要做好改名或加前缀的准备,否则模型会困惑到底调哪个。

第二,超时设置。默认会话如果遇到上游服务响应慢,可能整个调用链跟着变慢。很多框架允许你给不同的 Server 设置不同的超时时间,建议按业务重要性分开配置。

第三,初始化失败不会给出明确报错。如果 Server 启动即崩溃,Agent 端常常只会显示“无法连接”,这时候别急着调 Agent 配置,先切回第 3.4 节说的调试面板,确认 Server 本身能跑。

5. 我在实测中踩过的坑:传输方式、超时与上下文纠缠

5.1 stdio 还是 HTTP,别一上来就追求远程化

MCP 支持两种传输方式,很多人上手就直接选择 HTTP,觉得“远程部署才像真正的外部系统”。但在本地验证阶段,这条路会明显增加复杂度:要处理端口、跨域、鉴权、日志追踪,还要保证服务进程不被意外杀掉。

我更推荐刚入门时先用 stdio。它的好处是调试直观、无网络层干扰,问题定位起来很快。等协议链路完全跑通了,再根据实际场景把 Server 拆成远程服务也不迟。

维度stdio 传输HTTP+SSE 传输
部署位置本地子进程远程服务器
调试难度低,日志直接看本地高,需要抓网络请求
典型场景陪跑开发、单机工具多端共享、生产服务
安全要求较低较高,必须做鉴权

5.2 长耗时任务会卡住整个会话

如果某个工具执行需要十几秒甚至更久,同步等待会让整个 Agent 会话僵在那里。模型那边一直在“转圈”,用户端体验很糟糕。

我现在的处理方式是:把长任务拆成“提交 + 查询”两步。工具先返回一个“任务已受理,任务编号为 xxx”,然后提供一个查询任务状态的工具,模型可以稍后轮询结果。这有点像是升级版的“不给答案给回执”,虽然多写一个工具,但对用户体验的提升非常明显。

5.3 大结果返回会反过来压垮上下文

MCP 工具返回的内容会成为上下文的一部分回到模型侧。如果某个工具返回一个三万字的文档,模型再聪明也消化不了,反而会把注意力稀释到无关细节上。

我踩过最典型的一次,是查询一张数据表时直接返回了全量数据,结果后续对话质量明显下降,而且 token 消耗很快。后来改成:先返回统计信息,比如“共查询到 128 条记录”,再提供一个分页查询工具,模型需要哪些行就再补调。这个模式在数据量大的业务场景里非常通用。

6. MCP 的安全边界:协议默认不管哪些事

6.1 工具能访问什么,协议本身不负责判断

MCP 协议解决的是“怎么把能力暴露给 Agent”,但它不负责“谁能用什么能力”。换句话说,协议默认不包含身份认证、权限控制、操作审计。如果你把一个 MCP Server 暴露到远程,等于把里面的工具直接交给了任何能连上的客户端。

同样的道理,千万不要让每个工具都默认拥有系统级权限。比如get_weather这种只读工具问题不大,但delete_order、transfer_money这类敏感操作,必须放在更严格的权限边界后面。

6.2 最小权限设计:两个实用策略

我现在的做法分两层。第一层,在 MCP Server 内部做权限判断,根据调用者的身份信息决定“这个工具能不能执行”。第二层,在 Host 侧做工具级白名单,只允许 Agent 调用真正需要的几个工具,而不是把 Server 暴露出的全部工具都放行。

举个例子,假设 Server 暴露了三个工:查询订单、取消订单、修改价格。对接客服场景时,Host 侧只把查询订单暴露给 Agent;对接运营场景时,才开放另外两个。这个控制不复杂,但能把事故面缩到足够小。

6.3 模型生成的参数,要当成不可信输入来校验

这一点容易被忽略。调用 MCP 工具的参数不是人类手写的,是模型根据对话上下文生成的。既然是生成式输出,就有生成错、生成到恶意内容的可能。字符串拼进 SQL、文件路径拼接、命令拼接,这些老问题在 Agent 时代会重新冒头。

以路径安全为例,一个读取文件的工具,必须防止../../这种目录穿越。

import os def safe_resolve(base_dir: str, user_path: str) -> str: base = os.path.abspath(base_dir) target = os.path.abspath(os.path.join(base, user_path)) if not target.startswith(base + os.sep): raise PermissionError("路径越界,拒绝访问") return target

这套写法在任何教程里都不算高级,但在 MCP 场景下特别值得强调,因为参数来源从“可信任的前端”变成了“不可完全信任的模型输出”。把模型输出当成普通用户输入同等对待,很多安全问题就能提前挡掉。


最后说一点个人的体会。MCP 目前的迭代速度很快,今天写下的 API 用法可能半年后就有变化,但协议背后的设计思想相对稳定:标准化工具描述、标准化工具发现、标准化调用链路。我建议所有做 Agent 开发的人都花半天时间亲手跑通一个最小 Server,哪怕只是查天气这种玩具项目。过程中你会对“工具描述怎么写影响模型判断”“结果返回怎么处理才不撑爆上下文”有非常直观的感觉。这些手感一旦建立,后面接真实业务系统时就不会慌。如果条件允许,尽量从本地 stdio 起步,先把链路吃透,再往远程、安全、多 Server 组合的方向扩展。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询