Spring AI MCP 服务端自动测试:用 TaoToken 统一 Key 跑通端到端用例
1. 为什么 MCP 服务端测试总在“手动点一下”里打转做 Spring Boot Spring AI MCP 服务端开发的朋友大概率经历过这个循环改完一个McpTool方法启动应用打开 IDE 里的 MCP 客户端插件手动填一遍参数点调用肉眼看返回字符串对不对。一次两次还行工具方法一多、参数一复杂这种“人肉回归”就成了拖慢迭代的元凶。更麻烦的是MCP 服务端的行为不只是工具方法本身还包括 SSE 端点路径、请求头透传、query 参数解析、McpTransportContext里的上下文读取这些用手点根本覆盖不全。我这次要聊的就是把 Spring AI MCP 服务端的端到端自动测试真正落地。核心思路是用spring-ai-starter-mcp-client在测试作用域里起一个真实的 MCP 客户端通过 SSE 连到随机端口启动的服务端直接调用工具并断言返回内容。这样 MCP 工具注册、请求响应、异常分支都能进 CI不用再依赖任何图形化客户端。同时模型侧调用验证这一环我用 TaoToken 统一 Key 和 API 通道来跑避免在测试代码里散落多家厂商的密钥。先说清楚这套方案适合谁如果你在用 Spring Boot 3.5.x Spring AI 1.1.0-M3 写 MCP 服务端想让mvn test就能验证工具行为那这篇就是给你准备的。核心检索词就三个spring ai、mcp 服务端、自动测试。下面从依赖配置一路写到测试报告全部可复制。2. TaoToken 前置准备统一 Key 与 API 通道在写测试之前先把模型侧调用的通道理顺。MCP 服务端测试本身是本地 SSE 调用不经过模型但端到端用例里往往要验证“模型能不能正确选中并调用这个工具”这一步就需要一个稳定的模型 API 入口。TaoToken 在这里的作用是提供统一的 Key 和兼容 OpenAI 风格的 API 通道让测试配置里只维护一份凭证。你需要先拿到一个 API Key。访问 https://taotoken.net/api-keys 创建注意这个页面是控制台里的密钥管理入口。创建后你会得到形如sk-xxxxxxxx的字符串把它放进环境变量别硬编码进代码仓库export TAOTOKEN_API_KEYsk-你的密钥Base URL 用https://taotoken.net/api这个地址不加任何查询参数。模型 ID 按你实际要验证的填比如做工具调用验证时选一个支持 function calling 的模型。三件套记牢Base URL、Key、Model ID后面测试配置里会反复用到。如果你只是想先确认通道通不通可以直接在模型对话页面 https://taotoken.net/models 里发一条消息试试确认 Key 有效再往下走。这一步花两分钟能省掉后面排查 401 的时间。对于长期跑编码 Agent 或需要反复做端到端验证的场景可以考虑 Coding Plan它更适合高频调用单纯做接入验证的话按量用 API 就够了。文档在 https://taotoken.net/doc 可以查到完整的接口说明。3. 可复制配置pom、yml 与测试抽象类这一节是全文的技术核心所有片段都可以直接抄。先看pom.xml。父工程用 Spring Boot 3.5.4Java 17Spring AI 版本锁 1.1.0-M3。关键点是 MCP 服务端 starter 用spring-ai-starter-mcp-server-webmvc而 MCP 客户端 starter 必须放在test作用域这样它只参与测试不会打进生产包。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.5.4/version relativePath/ /parent properties java.version17/java.version spring-ai.version1.1.0-M3/spring-ai.version /properties dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webmvc/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-client/artifactId scopetest/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version${spring-ai.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement接着是服务端配置application.yml。这里定义了 MCP 服务端的名称、版本、同步类型、SSE 端点路径以及开启 tool、resource、prompt、completion 四类能力。SSE 端点路径要和测试里调用的路径完全一致否则会连不上。spring: application: name: xxx-Developer-MCP ai: mcp: server: name: xxx/backend version: 1.0.0 type: SYNC instructions: xxx backend sse-endpoint: /mcp/xxx/backend/sse capabilities: tool: true resource: true prompt: true completion: true server: port: 8080测试专用配置application-test.yml单独放一份把端口交给随机端口避免和本地 8080 冲突。SpringBootTest里用webEnvironment RANDOM_PORT测试抽象类通过Value(${local.server.port})拿到真实端口。spring: application: name: test-demo ai: mcp: server: name: xxx/backend version: 1.0.0 type: SYNC instructions: xxx backend sse-endpoint: /mcp/xxx/backend/sse capabilities: tool: true resource: true prompt: true completion: true工具方法本身用McpTool注解注册参数用McpToolParam描述。下面这个天气示例演示了如何从McpSyncServerExchange里取出McpTransportContext读取请求头和 query 参数拼进返回字符串。测试断言就是拿这个拼接结果做等值比较。Service public class DemoWeatherService implements McpServerTool { McpTool(name getWeather, description Get weather information by city name) public String getWeather( McpToolParam(description 城市) String cityName, McpToolParam(description 小区) String xiaoquName, McpSyncServerExchange exchange) { McpTransportContext ctx exchange.transportContext(); StringBuilder sb new StringBuilder(); sb.append(cityName:).append(cityName); sb.append(xiaoquName:).append(xiaoquName); sb.append(header1:).append(getParamValue(ctx, header1)); sb.append(header2:).append(getParamValue(ctx, header2)); sb.append(queryParam1:).append(getParamValue(ctx, queryParam1)); sb.append(queryParam2:).append(getParamValue(ctx, queryParam2)); return sb.toString(); } }测试抽象类AbstractMcpServerTest是整个自动测试的骨架。它用HttpClientSseClientTransport构建 SSE 传输支持自定义请求头然后McpClient.sync(transport)建同步客户端initialize()握手、ping()探活、callTool()调用、close()释放。ToolRequest用建造者模式封装 URL、端口、端点、工具名、工具参数、query 参数和 header 参数测试类只管拼参数。ExtendWith(SpringExtension.class) SpringBootTest(classes {MainApp.class}, webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT) public class AbstractMcpServerTest { Value(${local.server.port}) private int port; protected McpSchema.CallToolResult invokeSseTool(ToolRequest toolRequest) { int realPort toolRequest.getPort() ! null ? toolRequest.getPort() : port; HttpClientSseClientTransport transport HttpClientSseClientTransport .builder(toolRequest.getUrl() : realPort) .sseEndpoint(toolRequest.getEndpoint()) .customizeRequest(builder - { for (Map.EntryString, String e : toolRequest.getUrlHeaderParams().entrySet()) { builder.header(e.getKey(), e.getValue()); } }).build(); McpSyncClient client McpClient.sync(transport) .requestTimeout(Duration.ofMinutes(4)) .build(); client.initialize(); client.ping(); McpSchema.CallToolResult result client.callTool( new McpSchema.CallToolRequest(toolRequest.getToolName(), toolRequest.getToolParams())); client.close(); return result; } }具体测试类继承抽象类用ActiveProfiles(test)激活测试配置构造ToolRequest时把 cityName、xiaoquName、header、query 参数都填上然后断言返回文本等于预期拼接串。这样一次mvn test就把工具注册、SSE 连接、参数透传、响应内容全验证了。ActiveProfiles(test) public class WeatherServiceTest extends AbstractMcpServerTest { Test public void testGetWeather() { ToolRequest req ToolRequest.builder() .endpoint(/mcp/xxx/backend/sse) .toolName(getWeather) .toolParam(cityName, 北京) .toolParam(xiaoquName, 小区1) .urlQueryParam(queryParam1, 21-xxxx1) .urlQueryParam(queryParam2, 21-xxxx2) .urlHeaderParam(header1, 21-11111111111) .urlHeaderParam(header2, 21-22222222222) .build(); String expected cityName:北京xiaoquName:小区1header1:21-11111111111 header2:21-22222222222queryParam1:21-xxxx1queryParam2:21-xxxx2; McpSchema.CallToolResult result invokeSseTool(req); McpSchema.Content content result.content().get(0); assertThat(((McpSchema.TextContent) content).text(), equalTo(expected)); } }4. 验证请求与成功结果跑通端到端用例配置齐了跑测试。命令很简单mvn -DtestWeatherServiceTest test如果只想跑整个测试套件直接mvn test。运行后你会看到 Spring Boot 以随机端口启动日志里打印出实际端口MCP 客户端通过 SSE 连上去握手、ping、调用、关闭一气呵成。控制台最后出现BUILD SUCCESS说明断言通过。成功的关键信号有三个一是client.initialize()没有抛异常说明 SSE 端点路径对得上二是client.ping()返回正常说明服务端存活三是callTool返回的content列表第一项是TextContent且文本和预期完全一致。如果这三步都过说明 MCP 工具注册、参数绑定、上下文透传、响应序列化整条链路是通的。模型侧调用验证怎么接进来在端到端用例里你可以额外写一个测试用 TaoToken 的 API 通道发一条带工具定义的请求断言模型返回的 tool_calls 里包含getWeather。配置上把 Base URL 设为https://taotoken.net/apiKey 从环境变量读Model ID 填你选的模型。这样测试报告里既有服务端行为断言也有模型选工具的验证覆盖更完整。测试报告方面Surefire 默认在target/surefire-reports下生成 XML 和 txt。接 CI 时把这份报告作为产物上传每次提交都能看到哪些用例过了、哪些挂了。异常分支覆盖也别忘了比如传一个不存在的工具名断言客户端抛McpError或者传空参数断言服务端返回错误提示。这些用例能把边界情况锁死。5. 本篇常见错排查401、local proxy failed 与 reading choices跑这套测试时我踩过的坑集中在几个报错上逐个说。第一个是401 Unauthorized。这个通常出现在模型侧调用验证那一步不是 MCP 本地 SSE 调用。原因一般是 TaoToken 的 Key 没放进环境变量或者放进去但测试进程没读到。排查方法在测试里打印System.getenv(TAOTOKEN_API_KEY)的前几位确认非空。另外确认 Base URL 是https://taotoken.net/api不要多加路径或参数。Key、Base URL、Model ID 三件套任何一个错都会 401。第二个是local proxy failed或连接被拒。这个多半是 SSE 端点路径写错了。服务端配的是/mcp/xxx/backend/sse测试里endpoint()也必须一模一样少一段或多一段都连不上。还有一种情况是端口拿错了SpringBootTest用了RANDOM_PORT但测试里硬编码了 8080结果连到别的进程。确认Value(${local.server.port})注入成功并且ToolRequest没覆盖端口。第三个是reading choices相关的解析错误。这个出现在模型返回体解析阶段通常是模型返回的 JSON 结构和客户端预期不一致或者返回为空。排查时先把原始响应打出来看确认choices字段存在且非空。如果用的是流式接口注意测试里要按流式方式解析别用同步解析去读。Model ID 填错也可能导致返回体结构异常换一个确认支持 function calling 的模型再试。第四个是 OAuth 相关报错。如果你在 MCP 客户端配置里启用了 OAuth 认证但测试环境没配 token握手阶段就会失败。测试场景下建议先关掉 OAuth用最简单的 SSE 直连把链路跑通再逐步加认证。CC Switch、Cline MCP、Codex 的auth.json这类配置如果出现在你的工具链里记住同样要写全 Base URL、Key、Model ID 三件套缺一不可。排障时优先看两处一是 Spring Boot 启动日志里的实际端口和 SSE 端点注册信息二是 Surefire 报告里的异常堆栈。大部分问题看这两处就能定位。6. 把测试接进日常从手动点到 CI 自动跑这套方案落地后我的习惯是把WeatherServiceTest这类用例作为模板每新增一个McpTool方法就补一个测试类参数覆盖正常值、边界值、异常值。跑mvn test成了提交前的固定动作比开 IDE 插件手点快得多也不会漏。模型侧验证那部分建议单独放一个Tag(model)的测试CI 里按需触发避免每次提交都消耗 API 额度。需要长期高频跑 Agent 验证的Coding Plan 比按量更划算只是偶尔验证接入的用 API 就行。密钥管理上CI 里用 Secret 注入TAOTOKEN_API_KEY本地用环境变量永远别写进application-test.yml。最后留一个实用技巧测试抽象类里的requestTimeout设了 4 分钟是因为有些工具方法内部会调外部服务耗时较长。如果你的工具都是纯计算可以调短到 30 秒让失败更快暴露。测试报告接进 CI 后每次 PR 都能看到 MCP 服务端行为的回归结果这才是自动测试真正的价值。