最近我在做一个企业知识助手项目。这个 Agent 有三条主要链路:普通对话走 Chat,企业制度和产品文档查询走 RAG,计算和业务数据查询走 Tool Calling。
项目刚开始时,工具就是普通的 Python 函数,再通过 LangChain 的 StructuredTool 注册给模型使用。这种方式很适合快速验证,但随着工具越来越多,我开始考虑一个问题:如果工具不再和 Agent 放在同一个进程里,Agent 应该怎样发现并调用它们?
这次我把原来的乘法工具和年假查询工具封装成了 MCP Server,然后依次补上 MCP Client、LangChain 适配层、Agent 编排和分层测试。本文记录一下完整过程,也说说中间遇到的版本兼容、超时和异常处理问题。
一、MCP 解决的是什么问题
MCP 是 Model Context Protocol 的缩写,可以把它理解成 Agent 与外部能力之间的一套标准接口。
在没有 MCP 时,每接入一种外部工具,都可能需要自己处理函数定义、参数说明、调用方式和结果格式。工具与框架之间容易形成直接绑定。
有了 MCP 后,工具服务可以通过统一协议对外提供能力:
- Server 负责声明并执行工具;
- Client 负责连接 Server、发现工具和发起调用;
- Agent 负责根据用户问题决定是否使用工具。
MCP 不会直接让模型变得更聪明,它主要解决的是工具如何被标准化暴露和调用的问题。
在企业知识助手项目中,完整链路如下:
text
用户问题
-> UnifiedAgent 进行意图路由
-> ToolAgent 让模型生成工具调用请求
-> LangChain StructuredTool
-> MCP 适配层
-> MCP Client
-> stdio 子进程
-> MCP Server
-> Python 业务函数
-> 工具结果返回给模型
-> 模型生成最终回答
这里使用的是本地 stdio 传输。Agent 启动一个 MCP Server 子进程,双方通过标准输入和标准输出交换协议消息。
二、把已有业务函数封装成 MCP 工具
我的项目里原本已经有两个普通 Python 函数:
multiply_numbers:计算两个数的乘积;get_leave_balance:根据员工编号查询剩余年假。
接入 MCP 时,我没有把业务逻辑复制到 Server 中,而是让 MCP 工具继续调用原来的函数:
python
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from app.tools import get_leave_balance as query_leave_balance
from app.tools import multiply_numbers as calculate_product
mcp = MCPServer(
name="knowledge-agent-tools",
instructions="提供企业知识助手所需的业务查询和计算工具。",
)
@mcp.tool(name="multiply_numbers")
def multiply_numbers_tool(a: float, b: float) -> float:
"""计算两个数的乘积。"""
return calculate_product(a, b)
@mcp.tool(name="get_leave_balance")
def get_leave_balance_tool(employee_id: str) -> int:
"""根据员工编号查询剩余年假天数。"""
try:
return query_leave_balance(employee_id)
except (KeyError, ValueError) as error:
raise ToolError(str(error.args[0])) from error
if __name__ == "__main__":
mcp.run("stdio")
这样做有两个好处。
第一,业务函数不依赖 MCP。即使以后更换协议或 Agent 框架,原来的业务代码仍然可以继续使用。
第二,MCP Server 的职责比较清楚:注册工具、声明参数和返回值、转换协议层错误,而不是承载全部业务逻辑。
这里还有一个容易踩坑的地方:使用 stdio 时,标准输出是协议通信通道,因此不要在 Server 中随意用 print() 输出调试信息,否则可能干扰协议消息。需要记录日志时,应使用合适的日志配置,并避免把普通调试文本写进协议输出。
三、MCP Client
Client 最基本的两个能力是:
python
tools = await client.list_tools()
result = await client.call_tool(tool_name, arguments)
但如果只封装这两个方法,异常会直接以底层库的各种形式向上传递。调用方可能同时面对文件不存在、子进程退出、连接中断和超时等不同异常,不利于 Agent 做统一降级。
因此我在项目中定义了自己的异常边界:
python
class MCPUnavailableError(RuntimeError):
"""MCP 服务无法启动、连接中断或通信失败。"""
class MCPTimeoutError(MCPUnavailableError):
"""MCP 服务在规定时间内没有完成操作。"""
Client 调用时再统一转换:
python
async def list_tools(self):
async def operation():
async with Client(
self.server_parameters,
read_timeout_seconds=self.read_timeout_seconds,
) as client:
return await client.list_tools()
try:
return await asyncio.wait_for(
operation(),
timeout=self.read_timeout_seconds,
)
except TimeoutError as error:
raise MCPTimeoutError("MCP服务响应超时") from error
except Exception as error:
raise MCPUnavailableError("MCP服务不可用") from error
这里额外使用 asyncio.wait_for,是因为我希望超时能够覆盖一次操作的完整过程,包括启动子进程、建立连接、初始化协议和真正发送请求。
如果只依赖某个底层读取超时,它不一定能覆盖初始化之前的所有等待阶段。对于 Agent 来说,无论卡在握手还是工具执行,最终表现都是这次请求迟迟没有完成,因此需要在自己的边界再加一层总超时。
四、LangChain 适配层
MCP Client 返回的是 MCP 的工具描述和调用结果,而现有 ToolAgent 使用的是 LangChain StructuredTool。两边的数据结构并不完全相同,所以中间需要一层转换。
我使用的是 MCP Python SDK 2.1.1。准备使用的 langchain-mcp-adapters 0.3.2 限制 mcp<2.0,与当前项目版本不兼容。如果为了使用适配器而降级 MCP,很多逻辑要重新写,于是单独写了这层比较薄的转换代码。
这层适配主要完成三件事:
text
MCP Tool 元数据 -> StructuredTool 元数据
MCP inputSchema -> StructuredTool args_schema
MCP CallToolResult -> Python 返回值或工具异常
核心代码可以简化为:
python
def build_langchain_tool(client, mcp_tool):
tool_name = mcp_tool.name
def invoke_tool(**arguments):
return asyncio.run(call_mcp_tool(client, tool_name, arguments))
async def ainvoke_tool(**arguments):
return await call_mcp_tool(client, tool_name, arguments)
return StructuredTool(
name=tool_name,
description=mcp_tool.description or "",
args_schema=mcp_tool.input_schema,
func=invoke_tool,
coroutine=ainvoke_tool,
metadata={"source": "mcp"},
)
同时提供了同步和异步入口。当前 ToolAgent 是同步调用,所以使用 func;以后如果 Agent 改为异步执行,可以直接使用 coroutine。
需要注意,asyncio.run() 不能在一个已经运行的事件循环中再次调用。当前同步脚本没有这个问题,但 Web 异步服务或异步测试应该走 ainvoke,不能继续套同步入口。
这个适配层只处理协议和框架之间的差异,不包含业务逻辑。以后官方适配器支持当前版本时,可以替换这一层,而不需要改动 MCP Server 和 Agent 主流程。
五、业务错误和系统错误区分
查询员工 E999 时,返回"员工不存在"属于可预期的业务失败。它可以安全地告诉模型,再由模型组织成用户能看懂的回答。
但如果 MCP Server 无法启动、连接突然断开或内部出现未知异常,就不应该把完整 Traceback、文件路径或底层连接细节交给模型。
因此我把错误分成了两类:
text
可预期业务错误
-> MCP ToolError
-> CallToolResult.is_error=True
-> MCPToolExecutionError
-> ToolMessage(status="error")
-> 模型生成安全的业务提示
服务或通信错误
-> MCPUnavailableError / MCPTimeoutError
-> Agent 统一降级
-> 不向用户泄露内部异常细节
这种区分对测试也很重要。如果查询不到员工就让整个 Agent 崩溃,说明业务错误没有在正确的层级被处理;如果服务启动失败却仍然显示"员工不存在",又会掩盖真正的系统故障。
六、MCP 测试
1. MCP Server 契约测试
首先直接连接真实 Server,检查:
- 是否能发现预期工具;
- 工具名称和描述是否正确;
- 参数 Schema 是否包含必填字段和正确类型;
- 正常参数是否返回文本与结构化结果;
- 非法参数、未知工具和业务错误是否按协议返回。
Schema 检查很有必要。工具虽然存在,但如果 employee_id 没被声明为必填字符串,模型生成的参数就可能与业务函数不匹配。
2. MCP Client 故障测试
然后主动制造异常:
python
def test_client_converts_unresponsive_server_to_timeout_error():
parameters = StdioServerParameters(
command=sys.executable,
args=["-c", "import time; time.sleep(30)"],
)
client = MCPToolClient(parameters, read_timeout_seconds=0.2)
with pytest.raises(MCPTimeoutError):
asyncio.run(client.list_tools())
python -c 表示直接执行后面的 Python 字符串。这里启动了一个只休眠、不实现 MCP 协议的子进程,用它稳定模拟"进程存在,但服务一直不响应"的情况。
0.2 秒不是生产配置,而是测试参数。测试需要尽快触发超时,避免真的等待 30 秒。
除此之外,还覆盖了:
- 启动命令不存在;
- Server 启动后立刻退出;
- 初始化阶段不响应;
- 工具本身执行过慢。
3. 适配层单元测试
这一层使用 Fake Client,不启动真实进程,重点验证纯转换逻辑:
- MCP 工具元数据能否转换为
StructuredTool; - 同步和异步调用是否都能传递参数;
- 结构化结果和文本结果是否正确提取;
is_error=True是否转换成预期工具异常。
Fake 的价值不是替代真实测试,而是让转换逻辑可以快速、稳定地定位问题。
4. 真实 MCP 集成测试
这一层会真正启动 stdio MCP Server,再通过适配后的 StructuredTool 调用工具。它覆盖的是 Fake 测试覆盖不到的内容:子进程、协议握手、工具发现和真实结果格式。
5. ToolAgent 编排测试
这里使用 Fake 模型加真实 MCP 工具,验证 Agent 的两轮调用:
text
第一轮:模型决定调用哪个工具,并生成参数
工具阶段:ToolAgent 通过 MCP 真正执行工具
第二轮:模型读取 ToolMessage,生成最终回答
这样既不消耗真实模型 Token,又能验证 Tool Calling 与 MCP 是否正确连接。
6. 真实模型端到端质量门禁
最后只保留少量关键用例调用真实模型:
- "请使用工具计算 12 乘以 8";
- "请查询员工 E999 的剩余年假"。
断言不只检查最终回答,还检查:
python
assert result.route_decision.route is AgentRoute.TOOL
assert result.tool_result.requested_tool_names == ["multiply_numbers"]
assert result.tool_result.executed_tool_names == ["multiply_numbers"]
assert result.tool_result.tool_messages[0].content == "96.0"
assert "96" in result.final_response.content
其中 requested_tool_names 表示模型想调用什么,executed_tool_names 表示系统真正执行了什么。
两者需要分开记录。模型请求了工具,不代表权限检查后真的执行了工具;最终回答看起来正确,也不代表调用链路一定正确。
七、目前的执行结果
MCP 阶段完成后,我得到的测试结果是:
text
MCP 离线相关测试:18 passed
MCP 真实模型质量门禁:2 passed
完整非评测回归:226 passed, 22 deselected
真实模型用例通过下面的命令单独执行:
powershell
python -m pytest tests\test_mcp_unified_agent_evaluation.py -v --run-evaluation
日常回归默认排除真实模型测试:
powershell
python -m pytest -m "not evaluation" -v
这样做可以让普通回归保持快速和稳定,也能避免每次改一行代码都调用外部模型。真实模型测试不是不跑,而是在合适的阶段作为质量门禁运行。
八、一些感悟
第一,MCP Server 能启动,不等于 MCP 接入已经完成。工具发现、Schema、结果转换和异常语义都需要验证。
第二,协议异常和业务异常必须分开。否则要么把系统内部信息暴露给模型,要么把真正的服务故障伪装成普通业务提示。
第三,Fake 测试和真实测试并不冲突。Fake 负责稳定定位代码逻辑,真实 MCP 负责覆盖协议和进程边界,真实模型负责验证模型决策。
第四,最终回答正确只是最外层结果。Agent 测试还需要检查路由结果、模型请求工具、真正执行工具、工具返回消息和异常降级轨迹。
第五,适配层应尽量薄。它只负责 MCP 与 Agent 框架之间的数据结构转换,这样未来替换框架或官方适配器时,改动范围才可控。
九、目前还没有覆盖的部分
这个版本只是 MCP 的第一阶段,目前仍有一些明确的边界:
- 只测试了本地 stdio,没有覆盖 Streamable HTTP;
- 没有实现远程认证和 OAuth;
- 只覆盖 Tools,没有覆盖 Resources 和 Prompts;
- 只有一个 MCP Server,没有测试多 Server 聚合和工具重名;
- Client 当前按次建立会话,还没有做长连接和性能测试;
- 真实模型测试仍会受到外部 API 和模型输出波动影响。
这些没有完成的内容不需要藏起来。把已验证能力和当前边界说清楚,比简单写一句"完成 MCP 接入"更有意义。
总结
这次改造把项目中的本地 Python 工具变成了独立 MCP 服务,并完成了 Server、Client、LangChain 适配、ToolAgent、UnifiedAgent 和自动化质量门禁的完整链路。
对我来说,最有价值的并不是多接入了一个新名词,而是进一步理解了 Agent 测试应该关注什么:不仅要看模型最后说了什么,还要验证它为什么选择这条链路、请求了什么工具、系统实际执行了什么,以及依赖发生故障时能不能安全结束。
这也是 MCP 接入之后,测试工作真正开始的地方。