测试与调试:MCP Inspector、单元测试、集成测试
摘要:MCP Server测试与调试全攻略,使用MCP Inspector进行集成测试,编写pytest单元测试验证工具逻辑,设计端到端测试覆盖协议握手和工具调用全流程。
MCP测试与调试 单元测试集成测试与Inspector实战
我第一次写MCP Server的时候根本没写测试,全靠手动在Claude里试。有次改了个工具的参数名,本地试没问题就提交了,结果CI跑的时候发现另外三个工具全挂了。从那以后我老老实实搭了测试体系。这篇分享我用pytest做单元测试、用MCP Inspector做交互调试、用GitHub Actions做CI集成的完整方案。
单元测试 用pytest测试MCP工具
MCP工具本质上就是普通Python函数,单元测试和测普通函数没太大区别。关键点在于怎么把工具从FastMCP的注册体系里取出来单独测。
FastMCP提供了mcp._tool_manager来访问已注册的工具。通过工具名拿到可调用对象后,就能像调普通函数一样测试它。这样不需要启动整个MCP Server,测试速度快。
单元测试的重点是覆盖各种输入边界。正常输入、空输入、超长输入、特殊字符、异常情况都要测到。我习惯每个工具至少写三到五个测试用例,正例两个反例三个,把边界条件卡死。
测试异步工具时需要用pytest-asyncio插件,给测试函数加@pytest.mark.asyncio装饰器,pytest就能正确处理async函数了。
集成测试 端到端测试流程
单元测试验证单个工具的正确性,集成测试验证工具之间的协作和整个Server的运行。MCP的集成测试我分两个层面。
第一层是协议层测试。用MCP SDK的客户端能力,以stdio方式启动Server,然后通过客户端发initialize、tools/list、tools/call请求,验证完整链路。这能测到序列化反序列化、传输层、协议握手等单元测试覆盖不到的部分。
第二层是场景测试。模拟真实使用场景,比如"先查列表再查详情再更新"这样的多步操作流程,验证工具组合在一起的行为是否正确。
集成测试比单元测试慢,因为要启动Server进程,所以数量上少一些,重点测关键路径。
MCP Inspector高级调试
MCP Inspector是官方提供的交互式调试工具,基于React的Web界面。用它可以直接连上你的Server,手动调用每个工具,查看请求和响应的原始JSON,还能看到Server日志。
启动方式很简单,一行命令搞定。
npx @modelcontextprotocol/inspector python my_server.py这会启动一个本地Web服务,浏览器打开后能看到连接配置界面。选STDIO传输,填上你的Server启动命令,点Connect就连上了。
Inspector的高级用法我有几个心得。
第一是版本协商调试。Inspector可以指定客户端使用的协议版本,你能测Server对不同版本的兼容性。2025-06-18版本要求HTTP传输时带MCP-Protocol-Version头,用Inspector可以验证你的Server是否正确处理这个头。
第二是工具Schema检查。Inspector会展示每个工具的inputSchema,你能直观看到参数定义是否正确。我有次工具参数的类型注解写错了,Inspector里一眼就看出Schema不对。
第三是错误诊断。工具调用失败时Inspector会显示完整的错误响应,包括JSON-RPC的error字段。比在日志里翻找方便多了。
完整代码
下面是完整的测试套件,包含被测Server、单元测试、集成测试和CI配置。
先是被测的MCP Server。
# calc_server.py# 被测MCP Server 提供计算器工具# 依赖安装 pip install mcp pydanticfrommcp.server.fastmcpimportFastMCP mcp=FastMCP("calc-server")@mcp.tool()defadd(a:float,b:float)->str:"""加法运算 返回两数之和"""# 简单加法 但要处理边界情况result=a+breturnf"{a}+{b}={result}"@mcp.tool()defdivide(a:float,b:float)->str:"""除法运算 除数为零时返回错误提示"""# 除法必须检查除数是否为零ifb==0:return"错误 除数不能为零"result=a/breturnf"{a}/{b}={result}"@mcp.tool()defbatch_calculate(expressions:str)->str:"""批量计算 接收逗号分隔的表达式 返回每个结果"""# 按逗号分割成多个表达式items=expressions.split(",")results=[]foriteminitems:item=item.strip()# 只支持简单的a+b格式 实际项目用eval或ast.literal_evalif"+"initem:parts=item.split("+")iflen(parts)==2:val=float(parts[0].strip())+float(parts[1].strip())results.append(f"{item}={val}")else:results.append(f"{item}= 格式错误")else:results.append(f"{item}= 不支持的表达式")return"\n".join(results)@mcp.tool()deflist_tools_info()->str:"""返回所有工具的说明信息 方便调试"""infos=["add(a, b) 加法运算","divide(a, b) 除法运算 除数不能为零","batch_calculate(expressions) 批量计算 逗号分隔","list_tools_info() 查看工具说明",]return"\n".join(infos)if__name__=="__main__":mcp.run(transport="stdio")接下来是单元测试文件。
# test_unit.py# 单元测试 覆盖calc_server的每个工具# 依赖安装 pip install pytest pytest-asyncio# 运行命令 pytest test_unit.py -v --cov=calc_serverimportpytestfromcalc_serverimportmcp# ============================================================# 辅助函数 从FastMCP中取出工具的可调用对象# ============================================================asyncdefcall_tool(name:str,**kwargs)->str:"""通过工具名调用工具 返回结果字符串"""# 从工具管理器获取工具对象tool=mcp._tool_manager.get_tool(name)iftoolisNone:raiseValueError(f"工具{name}不存在")# 调用工具的run方法获取结果result=awaittool.run(kwargs)# result是CallToolResult对象 取第一个content的textreturnresult.content[0].text# ============================================================# add工具的单元测试# ============================================================@pytest.mark.asyncioasyncdeftest_add_normal():"""测试正常加法"""result=awaitcall_tool("add",a=1,b=2)assert"3"inresult@pytest.mark.asyncioasyncdeftest_add_negative():"""测试负数加法"""result=awaitcall_tool("add",a=-5,b=3)assert"-2"inresult@pytest.mark.asyncioasyncdeftest_add_float():"""测试浮点数加法"""result=awaitcall_tool("add",a=1.5,b=2.5)assert"4"inresult@pytest.mark.asyncioasyncdeftest_add_zero():"""测试加零"""result=awaitcall_tool("add",a=100,b=0)assert"100"inresult# ============================================================# divide工具的单元测试# ============================================================@pytest.mark.asyncioasyncdeftest_divide_normal():"""测试正常除法"""result=awaitcall_tool("divide",a=10,b=2)assert"5"inresult@pytest.mark.asyncioasyncdeftest_divide_by_zero():"""测试除以零 必须返回错误提示"""result=awaitcall_tool("divide",a=10,b=0)assert"错误"inresultassert"零"inresult@pytest.mark.asyncioasyncdeftest_divide_float_result():"""测试除不尽的情况"""result=awaitcall_tool("divide",a=10,b=3)# 10/3约等于3.333assert"3.33"inresult# ============================================================# batch_calculate工具的单元测试# ============================================================@pytest.mark.asyncioasyncdeftest_batch_single():"""测试批量计算单个表达式"""result=awaitcall_tool("batch_calculate",expressions="1+2")assert"1+2 = 3.0"inresult@pytest.mark.asyncioasyncdeftest_batch_multiple():"""测试批量计算多个表达式"""result=awaitcall_tool("batch_calculate",expressions="1+2,3+4,5+6")# 应该有三行结果lines=result.strip().split("\n")assertlen(lines)==3assert"3.0"inlines[0]assert"7.0"inlines[1]assert"11.0"inlines[2]@pytest.mark.asyncioasyncdeftest_batch_invalid():"""测试批量计算包含无效表达式"""result=awaitcall_tool("batch_calculate",expressions="1+2,abc")lines=result.strip().split("\n")assert"3.0"inlines[0]assert"不支持"inlines[1]or"格式错误"inlines[1]@pytest.mark.asyncioasyncdeftest_batch_empty():"""测试空输入"""result=awaitcall_tool("batch_calculate",expressions="")# 空字符串split后得到一个空字符串元素assert"不支持"inresultorresult.strip()==""# ============================================================# 工具注册验证测试# ============================================================deftest_tools_registered():"""验证所有工具都已正确注册"""# 获取所有已注册工具名tools=mcp._tool_manager.list_tools()tool_names={t.namefortintools}# 确保四个工具都在expected={"add","divide","batch_calculate","list_tools_info"}assertexpected.issubset(tool_names),f"缺少工具{expected-tool_names}"然后是集成测试文件。
# test_integration.py# 集成测试 通过MCP客户端完整测试Server# 运行命令 pytest test_integration.py -vimportpytestfrommcp.client.sessionimportClientSessionfrommcp.client.stdioimportStdioServerParameters,stdio_client@pytest.mark.asyncioasyncdeftest_full_workflow():"""端到端测试 完整的初始化到工具调用流程"""# 配置Server启动参数server_params=StdioServerParameters(command="python",args=["calc_server.py"],)# 通过stdio连接Serverasyncwithstdio_client(server_params)as(read_stream,write_stream):asyncwithClientSession(read_stream,write_stream)assession:# 第一步 初始化握手init_result=awaitsession.initialize()# 验证Server信息assertinit_result.serverInfo.name=="calc-server"# 验证协议版本assertinit_result.protocolVersionisnotNone# 第二步 获取工具列表tools_result=awaitsession.list_tools()tool_names={t.namefortintools_result.tools}assert"add"intool_namesassert"divide"intool_namesassert"batch_calculate"intool_names# 第三步 调用add工具add_result=awaitsession.call_tool("add",{"a":10,"b":20})assertlen(add_result.content)>0assert"30"inadd_result.content[0].text# 第四步 调用divide工具验证错误处理div_result=awaitsession.call_tool("divide",{"a":10,"b":0})assert"错误"indiv_result.content[0].text# 第五步 调用批量计算工具batch_result=awaitsession.call_tool("batch_calculate",{"expressions":"1+1,2+2"})text=batch_result.content[0].textassert"2.0"intextassert"4.0"intext@pytest.mark.asyncioasyncdeftest_tool_schema_validation():"""测试工具参数Schema校验"""server_params=StdioServerParameters(command="python",args=["calc_server.py"],)asyncwithstdio_client(server_params)as(read_stream,write_stream):asyncwithClientSession(read_stream,write_stream)assession:awaitsession.initialize()# 获取divide工具的Schematools=awaitsession.list_tools()divide_tool=next(tfortintools.toolsift.name=="divide")# 验证Schema包含a和b两个参数schema=divide_tool.inputSchemaassert"a"inschema.get("properties",{})assert"b"inschema.get("properties",{})# 验证两个参数都是必填assert"a"inschema.get("required",[])assert"b"inschema.get("required",[])@pytest.mark.asyncioasyncdeftest_nonexistent_tool():"""测试调用不存在的工具 应返回错误"""server_params=StdioServerParameters(command="python",args=["calc_server.py"],)asyncwithstdio_client(server_params)as(read_stream,write_stream):asyncwithClientSession(read_stream,write_stream)assession:awaitsession.initialize()# 调用不存在的工具result=awaitsession.call_tool("nonexistent",{})# 应该返回错误标记assertresult.isErrorisTrue最后是CI/CD的GitHub Actions配置。
# .github/workflows/mcp-test.yml# GitHub Actions CI配置 自动运行测试并检查覆盖率name:MCP Server Testson:push:branches:[main,develop]pull_request:branches:[main]jobs:test:runs-on:ubuntu-lateststrategy:matrix:# 测试多个Python版本确保兼容性python-version:["3.10","3.11","3.12"]steps:# 第一步 检出代码-uses:actions/checkout@v4# 第二步 安装Python-name:Set up Pythonuses:actions/setup-python@v5with:python-version:${{matrix.python-version}}# 第三步 安装依赖-name:Install dependenciesrun:|python -m pip install --upgrade pip pip install mcp pytest pytest-asyncio pytest-cov # 安装Node.js用于MCP Inspector-name:Setup Node.js for Inspectoruses:actions/setup-node@v4with:node-version:"20"# 第四步 运行单元测试并收集覆盖率-name:Run unit testsrun:|pytest test_unit.py -v --cov=calc_server --cov-report=xml --cov-report=term# 第五步 运行集成测试-name:Run integration testsrun:|pytest test_integration.py -v# 第六步 覆盖率门槛检查 低于80%则失败-name:Check coveragerun:|pytest --cov=calc_server --cov-fail-under=80# 第七步 上传覆盖率报告-name:Upload coverageuses:codecov/codecov-action@v4if:matrix.python-version == '3.12'with:file:./coverage.xml效果验证
本地运行pytest test_unit.py -v --cov=calc_server,你会看到所有测试通过,覆盖率报告显示calc_server模块覆盖率超过90%。
test_unit.py::test_add_normal PASSED test_unit.py::test_add_negative PASSED test_unit.py::test_add_float PASSED test_unit.py::test_add_zero PASSED test_unit.py::test_divide_normal PASSED test_unit.py::test_divide_by_zero PASSED test_unit.py::test_divide_float_result PASSED test_unit.py::test_batch_single PASSED test_unit.py::test_batch_multiple PASSED test_unit.py::test_batch_invalid PASSED test_unit.py::test_batch_empty PASSED test_unit.py::test_tools_registered PASSED Name Stmts Miss Cover ---------------------------------- calc_server.py 28 1 96%运行pytest test_integration.py -v,三个集成测试全部通过,说明从初始化握手到工具调用的完整链路正常。
启动Inspector调试,执行npx @modelcontextprotocol/inspector python calc_server.py,浏览器里能看到四个工具的列表,手动调用divide传入b=0能看到错误提示,调用batch_calculate传入"1+2,3+4"能看到两行结果。
常见问题与避坑
坑一,异步测试没加标记导致报错。我第一次写async测试忘了加@pytest.mark.asyncio,pytest直接把协程对象当返回值,测试全过但实际啥也没测。解决办法是在pytest.ini或pyproject.toml里配置asyncio_mode = auto,这样所有async测试函数自动识别,不用手动加装饰器。
坑二,集成测试里Server启动路径写错。StdioServerParameters里的args用的是相对路径,如果测试不是从项目根目录跑就找不到Server文件。解决办法是用__file__拼接绝对路径,args=[str(Path(__file__).parent / "calc_server.py")]。
坑三,工具内部状态污染测试。我的batch_calculate用了模块级变量存中间结果,第一个测试改了状态,第二个测试就受影响。解决办法是每个测试用fixture做隔离,或者在setup和teardown里重置状态。更好的做法是让工具变成无状态的,所有数据通过参数传入。
坑四,Inspector连不上Server。最常见的原因是Server启动命令写错了。Inspector里要填完整的命令,比如python /absolute/path/to/server.py,不能只填文件名。另一个原因是Server有语法错误启动就崩溃,Inspector会报连接失败但没有具体错误。先在终端手动跑一遍Server确认能正常启动。
坑五,覆盖率虚高。有次我的覆盖率显示95%但实际很多异常分支没测到。原因是pytest-cov默认按行统计,一行写多个条件时只算一行。解决办法是加--cov-branch开启分支覆盖率统计,这样if-else的每个分支都会被检查。
小结
MCP测试体系分三层。单元测试覆盖单个工具的各种输入边界,速度快数量多。集成测试覆盖完整协议链路和工具组合场景,速度慢数量少但价值高。Inspector做交互式调试,人肉验证和探索性测试用它最方便。
覆盖率是底线指标,建议设80%的门槛,配合分支覆盖率统计防止虚高。CI里跑多版本Python矩阵,及早发现兼容性问题。
测试这件事前期投入大但长期回报高。每修一个bug就补一个测试用例,时间久了测试套件就是你的安全网,改代码再也不用心惊胆战。
相关推荐
- MCP Inspector工具详解:可视化的Server调试利器
- 工具开发实战:参数校验、错误处理与异步工具
- 性能优化:连接池、缓存、批量处理
