news 2026/9/13 7:08:42

MCP协议实战:从零构建AI Agent工具链

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MCP协议实战:从零构建AI Agent工具链

先聊个最近都绕不开的场景。你手上有一个大模型应用,希望它能像真正的助手一样去查资料、读文件、调接口,而不再只是“对话框里聊天”。这时候你就需要做 AI Agent 开发,而 Agent 一旦要干活,第一个要解决的就是工具链怎么接。过去我接工具时,不同服务有完全不同的 API、鉴权方式、参数格式,每次接入都得写一堆胶水代码,改一处坏十处。直到 MCP 出现,这套流程才真正有了统一答案。

MCP(Model Context Protocol)最初由 Anthropic 提出来,现在基本成了 AI Agent 接入外部工具的主流协议之一。它把“模型与工具、数据源之间的通信方式”标准化:工具方开发一个 MCP Server,Agent 侧只需要按协议连接,就能动态发现工具、调用函数。这篇文章不打算堆概念,我直接用一次完整的开发过程来说透 MCP 协议在做的事:从零写一个支持多个工具的 MCP Server,再把它接到客户端,跑通一个可以自动完成“搜索文件、抓取网页、生成报告”的 AI Agent 工具链。适合刚接触 MCP、准备自己做 Agent 工具的开发者参考。

1. 还没动手前,先搞懂 MCP、Agent 和工具链的关系

1.1 没有 MCP 时,给 Agent 接工具为什么这么痛苦

在 MCP 出来之前,让大模型调用外部能力总是逃不出这几件事:先为每个服务单独封装 API,把参数转换成模型能理解的格式,再处理鉴权、错误码、限流、超时,还得维护一套 prompt 去教模型“什么情况下调哪个函数”。这些代码往往散落在各个模块里,接口风格也不统一。一个新工具上线,联调周期少说两三天,多的可能要一两周。

举个具体的例子。如果你的 Agent 需要同时支持文档搜索和网页抓取,你可能要自己设计两套 function calling 协议:一套接收 query 返回文档列表,另一套接收 URL 返回页面正文。两套协议的鉴权方式、错误格式、超时策略完全不同,模型在调用时很容易“学错”。MCP 的初衷,就是把这些差异全部收口到一层标准协议里,让 Agent 不用关心每个工具内部是怎么实现的。

1.2 MCP 的核心角色和四个原语

MCP 的架构可以理解为三个角色加四个原语。三个角色是 Host、Client 和 Server。Host 是用户实际使用的应用,比如 Claude Desktop、IDE 插件或者你自己写的 Agent 程序;Client 跑在 Host 内部,负责和 Server 建立连接、维护会话;Server 是一个独立进程或服务,向外暴露某个领域的工具集,比如文件系统、数据库、设计稿导入等。

四个原语是 Tools、Resources、Prompts,以及后来补充的 Sampling,但日常开发前三个最常用。Tools 由模型控制,模型根据用户需求决定调用哪个函数;Resources 由应用控制,是模型可以读取的上下文数据,类似“给模型提供背景资料”;Prompts 由用户控制,是可以复用的提示模板。这张表能帮你快速区分:

原语控制方典型作用常见例子
Tools模型执行动作、获取结果搜索文件、调用 API、写数据库
Resources应用提供可读上下文读取项目文档、加载配置文件
Prompts用户复用固定模板生成周报、代码评审模板

初学者最容易把 Tools 和 Resources 搞混。我的理解是:Tools 是“让模型动手做事”,Resources 是“让模型有料可用”。如果一个接口只读且固定,适合设计成 Resource;如果一个接口会触发副作用,或者结果高度依赖入参,那就设计成 Tool。设计错了容易出现模型乱调工具,或上下文塞满不需要的数据。另外值得一提的是,Agent Skill 和 MCP 不是一回事:Skill 更偏向 Agent 内部的高阶能力定义,MCP 则是工具接入的标准协议,二者可以共存。

1.3 Agent 工具链的完整形态

一个真正能用的 AI Agent 工具链,长成这样:底层是各种能力提供方,文件系统、数据库、HTTP 接口、设计工具;中间层是 MCP Server,把这些能力封装成协议化的工具;上层是 Agent 编排层,负责理解用户意图、把任务拆成步骤、按步骤调用合适的工具并汇总结果。

MCP 解决的是中间层到上层的连接问题。它有一套完整的发现机制:Client 连上 Server 后,先通过 list_tools 拿到所有工具的名称、描述和参数 Schema,再根据模型判断调用哪个工具。这样一来,模型与中间层之间不再是一份写死的函数列表,而是可动态发现的工具清单。我后面写的 Server 和客户端脚本,就是这套机制的完整落地。

2. 准备工作:选对 SDK,把开发环境一次装好

2.1 两种主流 SDK 怎么选

官方维护了 TypeScript 和 Python 两套 SDK,另外还有 Java、Kotlin、C# 等社区版本。我选择 Python 的原因很简单:FastMCP 高层封装太好用了,几行代码就能注册一个工具,而且文档字符串可以直接变成工具描述,对像我这样需要边写边验证的人非常友好。如果你在 Node 生态里做开发,那选 TypeScript 版更顺手,类型推导比 Python 严格,配合 VSCode 体验更好。

技术栈之外,还要看你准备把 Server 部署在哪里。本地工具链用 Python 的 stdio 模式最省事,命令行直接把进程拉起来,配置简单,也不需要考虑端口和鉴权;但如果你要把 Server 发布成远程服务给多个 Agent 共用,那部署形态就要重新考虑了,这时候 TypeScript 或 Go 构建出的单文件二进制,部署和维护都会轻松很多。我个人的选择是:日常原型和内部工具用 Python,正式对外服务再单独评估语言和部署环境,不会在一开始就锁死方案。

2.2 最小可用项目骨架

先用一个干净的目录开始。我习惯用uv init初始化项目,因为它不仅速度快,还能把虚拟环境和依赖管理一起解决。没有安装 uv 的话,用python -m venv也是可以的。

uv init mcp-toolbox cd mcp-toolbox uv add "mcp[cli]" httpx

如果你用 pip,等价命令是:

pip install "mcp[cli]" httpx

安装完成后,项目里只需要一个server.py入口。MCP SDK 自带命令行工具,所以本地调试时可以用python -m mcp.server或者自己写一小段启动代码。FastMCP 的启动方式更直接,我们下面就会用到。

这里有个细节很多人会踩坑:stdio 模式启动的 Server 会把标准输出用作协议通道,因此不能在里面写print()调试日志。想打印日志,必须写到sys.stderr或者用 logging 库。我第一次写的时候在工具函数里放了几个 print,结果客户端解析协议直接报错,排查了很久才发现是输出污染。

2.3 传输模式:stdio、SSE 和 Streamable HTTP 怎么选

MCP 支持多种传输模式,目前最常用的是 stdio 和 Streamable HTTP。stdio 模式由客户端拉起一个本地子进程,通过标准输入输出和它通信,适合跑在用户本地的工具,比如操作本地文件、执行命令行任务。它的优点是启动快、配置简单,缺点是服务无法跨机器复用。

SSE 是早期的 HTTP 方案,服务端通过 Server-Sent Events 单向推送事件,客户端再通过普通 HTTP 回传,实现上有点别扭,官方已经逐步用 Streamable HTTP 替代它。Streamable HTTP 是更现代的双向模式,支持跨机器部署,多个 Agent 可以连接同一个 Server,适合把工具链做成团队内部公共服务。

选择建议其实很简单。本地自用、和 Claude Desktop 这类桌面客户端配合,直接用 stdio 最省心,启动快、不占端口、也不需要考虑鉴权;要做团队共用的服务,让多个 Agent 连接同一个远程能力,就考虑 Streamable HTTP。一个常见的误区是“上了 HTTP 就显得更高级”,但传输模式多一层网络,就要多处理一层安全问题,本地能解决的事不必上 HTTP。后面我会给出完整的 stdio 示例,这是实际开发中覆盖最广的场景。

3. 核心实战:用 FastMCP 写一个可用的 MCP Server

3.1 第一个工具:本地文件搜索

现在开始写真正的代码。我设计的这个 Server 叫dev-toolbox,第一个工具是search_files,目标是模拟一个研发人员最常用的能力:在指定目录里根据关键词搜索文件名。

from pathlib import Path from mcp.server.fastmcp import FastMCP mcp = FastMCP( "dev-toolbox", version="0.1.0", ) @mcp.tool() def search_files(directory: str, keyword: str) -> list[str]: """搜索指定目录下文件名包含 keyword 的文件路径。 Args: directory: 要搜索的目录绝对路径。 keyword: 文件名中包含的关键词,不区分大小写。 """ root = Path(directory) if not root.exists() or not root.is_dir(): raise ValueError(f"目录不存在或不是目录: {directory}") hits: list[str] = [] dirs_to_scan = [root] while dirs_to_scan and len(hits) < 50: current = dirs_to_scan.pop() try: for child in current.iterdir(): if child.is_dir(): dirs_to_scan.append(child) elif child.is_file() and keyword.lower() in child.name.lower(): hits.append(str(child)) except PermissionError: continue return hits

这个函数做了三件关键的事。第一是显式校验目录参数,避免把不存在的路径交给模型后得到晦涩报错;第二是限制最多返回 50 个结果,防止一次调用把上下文撑爆;第三是捕获 PermissionError,避免因为某个无权限目录导致整个任务失败。这些都是很小的细节,但在真实工具链里价值很大,模型通常不知道某些路径会触发权限问题,我们必须在工具层做保护。

3.2 第二个工具:网页内容抓取

光有本地搜索还不够,一个像样的工具链最好能联网。我再加一个fetch_page工具,它负责抓取网页并返回纯文本内容,给模型做资料调研用。

import httpx @mcp.tool() async def fetch_page(url: str, timeout: float = 10.0) -> str: """抓取指定 URL 并返回网页正文,仅供资料调研。 Args: url: 完整的网页地址,必须以 http:// 或 https:// 开头。 timeout: 请求超时时间,默认 10 秒。 """ if not url.startswith(("http://", "https://")): raise ValueError("url 必须以 http:// 或 https:// 开头") headers = {"User-Agent": "dev-toolbox-mcp/0.1.0"} async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: resp = await client.get(url, headers=headers) resp.raise_for_status() text = resp.text return text[:8000]

这里有个容易被忽略的点:工具函数可以是异步的,FastMCP 使用异步事件循环调度,因此async def的函数不会阻塞其他工具的调用。第一次写的时候,我习惯地把所有函数都定义成普通同步函数,后来发现某些网络工具在 stdio 模式下阻塞事件循环,导致其他并发工具全部排队。把耗时操作改成异步确实能提升并发能力,尤其是 Agent 会并行调用多个工具的场景。

返回值截断到 8000 字符也是经验值。超出这个长度,大部分模型的上下文里会出现信息过载,而且调用结果回传也会变慢。如果你的业务确实需要完整网页,可以考虑再提供一个接受start参数的翻页式工具,而不是一次全量返回。

3.3 工具描述与 JSON Schema 的细节

FastMCP 会自动把函数签名和 docstring 转换成模型的工具描述和参数 Schema,所以 docstring 怎么写直接决定模型能不能正确调用。我在实践中得出几条原则:在 docstring 里用一句话说清楚工具“做什么”;用 Args 列表写清每个参数的含义、类型、边界条件;不要写“用于...比如...主要用于”这种废话,模型不傻,但它对歧义的容忍度很低。

类型注解也至关重要。你写directory: str,SDK 就会生成一个 string 类型的参数;你如果写directory不带注解,或者写成str = "",生成的 Schema 可能会变成 optional,模型就可能在调用时漏传。参数校验我建议放在函数入口,而不是依赖 Schema 完成。因为模型再聪明,也可能生成越界值,工具层必须做到“来什么都能处理或明确报错”。

3.4 用客户端脚本验证工具是否可用

写完之后,先别急着接 GUI 客户端。我强烈建议先写一个十几行的验证脚本,直接通过 SDK 连接本地 Server,确认工具能被列出、能被调用。这一步能把“Server 有问题”和“客户端配置有问题”隔离清楚,排障效率高很多。

import asyncio from mcp import ClientSession from mcp.client.stdio import stdio_client, StdioServerParameters async def main() -> None: params = StdioServerParameters( command="python", args=["server.py"], cwd=None, ) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print("可用工具:", [t.name for t in tools.tools]) result = await session.call_tool("search_files", { "directory": "./docs", "keyword": "MCP" }) print("调用结果:", result.content) if __name__ == "__main__": asyncio.run(main())

这个脚本就是一个标准 MCP Client 的最小实现。它会启动python server.py子进程,完成握手、列出工具、调用工具三个动作,这三个动作覆盖了 MCP 客户端最核心的生命周期。如果这个脚本能跑通,说明 Server 本身没问题,后面接任何客户端都只是配置层面的活了。把这个问题想清楚,你才知道排查方向该往哪边使劲,而不是在客户端里反复改配置猜原因。

4. 把 Agent 接上工具链:客户端配置与联动调试

4.1 在 Claude Desktop 里注册 MCP Server

最直观的验证方式,是把 Server 挂到一个能直接和用户对话的客户端里。以 Claude Desktop 为例,它会在启动时读取claude_desktop_config.json,里面配置了所有 mcpServers。macOS 下这个文件在~/Library/Application Support/Claude/,Windows 在%APPDATA%\Claude\。没有文件就手动建一个。

{ "mcpServers": { "dev-toolbox": { "command": "python", "args": [ "/absolute/path/to/server.py" ], "env": {} } } }

注意这里必须用绝对路径,环境变量也要按需传入。填完后重启 Claude Desktop,界面的工具区域会出现一个新图标,点开就能看到search_filesfetch_page。这时可以直接输入一句自然语言,比如“在 docs 目录里搜索所有和 MCP 有关的文件”,如果 Agent 正确调用工具并返回结果,说明整条链路已经通了。

如果工具没出来,不要先去改配置,先在终端手动执行一次python /absolute/path/to/server.py,看看能不能正常启动、有没有 import 报错。MCP 的 Server 启动失败时,GUI 客户端往往只显示一个笼统的错误,真正的日志被吞掉了,手动启动是定位问题最快的方式。

4.2 对接你自己的 Agent 框架

如果你不是用现成客户端,而是自研 Agent 框架,连接 MCP 的步骤和上面的验证脚本基本一致:创建 ClientSession,调用 initialize 完成握手,然后循环执行“请求工具列表、根据用户意图让模型选择工具、调用工具并把结果回传给模型”的过程。这个循环就是 Agent 的核心调度逻辑,也是所谓 AI Agent 搭建示例里最常见的一段骨架。

很多人问过我和 LangGraph 这类框架怎么配合。LangGraph 解决的是 Agent 的状态流转和任务编排,MCP 解决的是工具接入协议,两者完全可以结合:用 LangGraph 定义工作流节点,在每个节点里通过 MCP Client 调用工具,工具执行结果作为下一轮的上下文。说起来复杂,落地时其实就是一个普通异步函数封装了 MCP 调用,并不需要为协议本身做额外改造。

4.3 多个 Server 协同时的编排思路

工具链大了以后,你不会把所有工具塞进同一个 Server。更常见的做法是按领域拆成多个 Server:一个管文件检索,一个管网页抓取,一个管设计稿导出,一个管数据库查询。每个 Server 只做一类事,工具描述写清楚所属领域,模型在调用时才有条件做“工具选择”,而不是被几十个混杂的工具搞晕。

我推荐一个简单可执行的划分原则:同一个 Server 里的工具应该共享一套鉴权和数据源,且互相之间有业务关联;如果没有关联,就拆出去。比如文件搜索和网页抓取是两个完全独立的数据源,理论上可以拆成两个 Server,但为了演示方便我先压在了一个 Server 里。真实项目里,拆大于合,维护和排查都轻松得多,毕竟一个 Server 挂掉不应该拖垮整条工具链。

5. 生产级工具链的隐藏功课:安全、重试与可观测性

5.1 安全边界:权限最小化与工具准入

工具链一旦接入生产环境,安全就是第一优先级。模型只是个“调用者”,它没有安全意识,我们必须把危险操作挡在工具层之外。最基础的一条:不要让工具直接暴露“执行任意命令”的能力。我见过有人图省事写了一个execute_command工具,模型在任何不确定的情况下都会倾向使用它,危险程度极高。如果确实需要执行命令,也必须在工具内部做白名单,比如只允许运行terraform plan这类固定命令。

涉及文件读写的工具要考虑路径白名单。前面search_files允许传任意目录,这在生产环境是不够的,应该限制只能访问某个工作根目录,或者对路径做归一化后检查前缀。网络请求工具同理,可以限定协议只能是 http/https,并可以增加域名白名单策略,防止模型因为 prompt 注入被诱导去访问恶意地址。安全不是上线后补的,必须在工具设计阶段就定好边界。

5.2 超时、重试与错误返回规范

Agent 调用工具不是一次 HTTP 请求那么简单,它是一个多轮会话,任何一个环节超时都可能让整个任务卡死。客户端侧要给工具调用设置超时,不能默认无限等待;Server 侧处理耗时任务时,要能提前返回进度或者直接返回超时错误,避免占用连接太久。

重试也要分场景。幂等工具可以放心重试,比如“读取文件内容”失败后重试三到五次没有风险;非幂等工具,比如“创建订单”“发送消息”,重试前必须想清楚是否会造成重复执行。工具返回错误时,尽量不要直接抛异常让客户端看到一堆 traceback,更合适的做法是 catch 后返回结构化错误信息,比如{"error": "文件不存在", "path": "/xxx"},模型才能根据错误信息调整参数重新尝试。

5.3 日志与链路追踪怎么做

MCP 的 stdio 模式不能污染标准输出,但日志仍然很重要。最简单的方式是用 Python logging 输出到 stderr,或者写到独立的日志文件。这样既能保留调试信息,又不破坏协议通道。开启 debug 模式时,可以用python -m mcp.server --verbose看服务端日志,或者直接在 FastMCP 里配置 logging 等级。

生产环境建议给每个工具调用补上 trace_id 或 request_id,把一次 Agent 任务里的多次工具调用串起来。比如在 Server 入口生成一个随机 id,客户端调用工具时通过参数传入,日志里就带上这个 id。这样排障时,你可以把模型思考链路、工具入参出参、错误日志对上,快速定位是模型选错工具,还是工具实现有 bug。别小看这个设计,工具多了以后,没有链路信息几乎是没法排查问题的。

6. 踩坑实录:这些问题我排查了很久

6.1 常见问题速查表

整理一张表,方便以后遇到问题直接对照。这些现象绝大部分我都亲眼见过,而且每一次都至少花掉半小时起步的排查时间。

现象可能原因解决思路
客户端找不到工具Server 启动报错或配置路径错误终端手动启动 Server,看报错日志
工具调用一直超时工具内部网络请求或长任务阻塞检查是否缺少超时设置,改成异步或减少任务量
模型总是传错参数工具描述和 Schema 不清晰重写 docstring,补充参数边界和示例
结果太长被截断单次返回超过模型上下文限制限制返回长度,设计分页或摘要工具
stdio 模式报解析错误print 日志污染标准输出所有日志写 stderr
子进程不退出Server 事件循环未正确关闭入口里显式关闭 session,或加退出钩子

回头看,这张表里绝大多数问题都不是 MCP 协议本身难搞,而是工程习惯问题。工具描述写得稀烂、日志乱打、路径不校验,这些在任何系统里都会出问题,MCP 只是把它们暴露得更明显而已。

6.2 两个真实案例复盘

第一个案例是工具列表加载失败。现象是 Claude Desktop 重启后工具图标始终没有出现,我手动运行 server.py 却一切正常。后来发现配置文件里的 args 写的是相对路径,而 GUI 应用的工作目录未必是项目目录,相对路径解析失败,换成绝对路径问题立刻解决。这个案例说明,环境差异必须靠自己手动复现,不能假设 GUI 的工作目录和终端一致。

第二个案例是模型调用参数总是出错。search_files需要传 directory 和 keyword,但模型老是只传 keyword,或者把 directory 写成文件名。我最初的 docstring 写得太含糊,SDK 生成的 Schema 里参数描述为空。把 Args 改写清楚、并给 keyword 加了一个示例后,调用成功率从五成不到升到接近九成。工具描述真的是模型调用质量的分水岭,每次觉得模型“变笨”了,先回去看你的工具描述。

6.3 生态里值得参考的项目

MCP 生态已经相当丰富。像 Figma MCP 可以读取设计稿结构和图层信息,Blender MCP 能控制三维场景导出,蓝湖 MCP、各类数据库 MCP 都是现成案例。它们最大的参考价值不是拿来直接用,而是看它们怎么设计工具粒度、描述和组织能力。打开仓库看一遍它们的工具描述,比自己闷头写强太多。

官方也维护了 mcp servers 目录,里面有很多参考实现。我建议写 Server 之前,先看几个热门项目的做法,重点观察它们如何处理鉴权、错误、分页,以及工具命名是否直觉化。这些细节直接决定了你的工具链能不能撑住真实业务,也决定了别人接手时能不能快速看懂。生态项目不是用来抄的,是用来对齐行业经验的。

从零写一个 MCP Server 到接进 AI Agent 工具链,整个过程比想象中简单:核心代码不超过一百行,客户端验证脚本更短,真正花时间的反而是工具描述、参数校验和错误处理这些“看不见”的细节。我个人的经验是,AI Agent 能不能稳定干活,三分靠模型,七分靠工具链的质量。MCP 的价值在于把工具接入标准化,但它不会替你解决工具设计得好不好用。

如果你刚开始接触,建议拿我这个 dev-toolbox 练手,先跑通本地文件搜索,再加网络请求,最后拆成多个 Server 挂到 Agent 上。工具链这个东西,越早动手,越能体会什么叫“牵一发动全身”。后续我还会继续整理 HTTP 模式部署、多 Agent 共享 Server 这些内容,有实际进展再回来分享。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 7:08:31

Chrome侧边栏投屏替代QtScrcpy的技术演进

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 7:07:19

如何为 GitHub 账户添加 passkey 并用附近设备完成登录

如何为 GitHub 账户添加 passkey 并用附近设备完成登录 【免费下载链接】docs The open-source repo for docs.github.com 项目地址: https://gitcode.com/GitHub_Trending/do/docs 这篇文章面向想要摆脱密码登录的 GitHub 账户使用者&#xff1a;先为自己的账户注册一个…

作者头像 李华
网站建设 2026/9/13 7:07:18

运算放大器设计实战:虚短虚断、11种经典电路与稳定性分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 7:06:01

Chrome DevTools MCP与Playwright MCP选型对比指南

最近AI Agent这块被MCP刷屏了&#xff0c;浏览器的自动化又刚好是所有Agent落地时绕不开的硬骨头。我在实际项目里同时用了Chrome DevTools MCP和Playwright MCP&#xff0c;这两者虽然都叫”浏览器MCP“&#xff0c;但定位、能力和适用场景的差距比很多人想象中要大得多。这篇…

作者头像 李华