从本地工具到 MCP:给 Agent 接入独立工具服务,并补齐自动化测试

最近我在做一个企业知识助手项目。这个 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 接入之后,测试工作真正开始的地方。

相关推荐
SugarAbsinthe20 分钟前
【Agent开发实习小记】从人工逐条验证到自动化验收:新 API 接入前的效率瓶颈
人工智能·python·自动化·pytest
菩提小狗27 分钟前
每日极客日报 · 2026年08月28日
ai·开源·极客日报·it热点·技术资讯
小七的碎碎念30 分钟前
生成式AI应用落地:从原型Demo到商用交付的工程化鸿沟
人工智能·生成式ai·技术创业
liwulin050631 分钟前
【VSCODE】能在终端打印的图标
python
半夢半醒132 分钟前
查看 Oracle 数据库中的定时任务执行情况
人工智能·prompt
JY1906410633 分钟前
以毫米级精度,还原异形楼梯真实空间形态——宇绘电子
人工智能
明志数科34 分钟前
具身智能数据工程全链路解析:从真实产线采集到LeRobot适配
网络·人工智能·算法
涛思数据(TDengine)36 分钟前
从_找根因_到_搭系统_:工业 AI 实战直播(十、十一期)
大数据·数据库·人工智能·时序数据库·tdengine
2601_9666504141 分钟前
2026完美收官,2027赛逸展再扩容预售
人工智能