1. 存量 OpenAPI 转 MCP 服务到底难在哪
如果你手上有一批跑了很久的 REST 接口,现在想让 Claude、Cursor 或者自研 Agent 直接调用它们,最直接的想法就是把这些接口包装成 MCP 工具。MCP 是 Anthropic 提出的模型上下文协议,它让模型能以标准方式发现和调用外部工具,而 Spring AI 从 1.0 开始就把 MCP 客户端和服务端的自动配置做进了框架里,这对 Java 团队来说是个好消息。
但真动手时你会发现几个绕不开的坎。第一,存量接口是 OpenAPI 描述的,参数是 query、path、body 混着来,而 MCP 工具要求一份 JSON Schema 作为 inputSchema,两者结构对不上。第二,MCP 的 SSE 传输是长连接加 POST 回传的双向通道,Spring MVC 的阻塞模型处理起来别扭,得用 WebFlux 的响应式流。第三,也是最容易被忽略的,存量接口往往带鉴权,客户端调 MCP 工具时怎么把 token 透传到后端 REST 接口,官方默认的传输实现没帮你做这件事。
我试过直接拿官方WebFluxSseServerTransportProvider跑,工具能列出来,但一调用就 401,因为工具执行时拿不到客户端连接时带的请求头。所以这篇文章的核心思路是:不改存量接口一行代码,通过自定义ToolCallbackProvider把 REST 接口描述成 MCP 工具,同时重写传输层,把 SSE 建连时的请求头存下来,在工具真正触发时塞回去。整条链路跑通后,你在客户端问一句“北京时间”,服务端就会去调你原来的/time/city接口。
这套方案适合谁?适合已经有 Spring Boot 后端、接口用 OpenAPI 或 Swagger 管理、想低成本接入 Agent 生态的团队。你不需要重写业务逻辑,只需要加一个适配层。下面从环境准备开始,一步步把配置和代码贴出来。
2. 用 TaoToken 准备模型与 Key 的前置工作
在跑通 MCP 链路之前,客户端那边需要一个能调用的模型。MCP 客户端负责把工具列表发给模型,模型决定调哪个工具,所以模型服务是必需的。这里我用 TaoToken 来做模型接入,它的接口兼容 OpenAI 的 chat completions 格式,Spring AI 的 OpenAI starter 可以直接指过去,省得改代码。
TaoToken 是一个模型聚合平台,能做什么?简单说就是把多家模型的调用统一成一个 OpenAI 兼容接口,你拿一个 Key 就能切换模型,适合做 Agent 和 MCP 这类需要频繁试不同模型的场景。适合谁?适合不想为每个模型单独对接 SDK 的开发者,尤其是 Java 侧用 Spring AI 的,配置里改个 base-url 就行。
第一步,去官网注册并拿到 API Key。地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建 Key。控制台入口在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页面是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。创建时给它起个名字,比如mcp-demo,复制出来保存好,后面配置里要用。
第二步,确认你要用的模型 ID。TaoToken 的模型列表在文档里有,常用的比如glm-4-flash、gpt-4o-mini这类。你可以在模型对话页面先试一下能不能正常返回,地址是 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite ,输入一句话看有没有响应。这一步能帮你排除 Key 本身的问题,免得后面 MCP 调不通时来回猜。
第三步,记下 API 的基础地址。TaoToken 的 API 端点是 https://taotoken.net/api ,注意这个不带 UTM 参数,配置里直接写这个。Spring AI 的 OpenAI 配置里base-url填这个,completions-path填/v1/chat/completions,具体路径以文档为准,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
如果你后面要做长期编码或者 Agent 类的持续调用,可以考虑 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它更适合高频场景。不过这篇教程里,普通按量调用就够了。
准备好 Key 和模型 ID 后,我们就可以进入服务端的配置环节了。记住三个东西:Base URL 是https://taotoken.net/api,Key 是你刚复制的,Model ID 是你选的模型名。这三个在客户端配置里会一起出现。
3. 可复制的 MCP 服务端配置与接口映射代码
这一节是重头戏,我把服务端的核心配置和代码拆开讲。整个服务端要做三件事:定义 REST 接口到 MCP 工具的映射、实现带请求头透传的 ToolCallback、重写 SSE 传输层把建连时的 header 存下来。
先看依赖。pom.xml里需要 Spring AI 的 MCP 服务端 starter 和 WebFlux:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>然后是application.yml,服务端监听 8001,MCP 的 SSE 端点和消息端点用默认值:
server: port: 8001 spring: application: name: lucifer-ai-mcp-server ai: mcp: server: name: lucifer-ai-mcp-server version: 1.0.0 type: ASYNC sse-endpoint: /sse message-endpoint: /mcp/message接下来是接口映射。核心思路是把每个 REST 接口描述成一个RestfulModel,包含名称、描述、inputSchema、url、path、httpMethod。这里用JSONSchemaUtil生成 inputSchema,参数定义成Parameter对象。下面是ParseRestful类,它把 REST 描述转成McpRestfulToolCallbackProvider:
@Component public class ParseRestful { public McpRestfulToolCallbackProvider getRestfulToolCallbackProvider() { List<McpRestfulToolCallback> toolCallbacks = new ArrayList<>(); getRestfulModels().forEach(restfulModel -> { RestfulToolDefinition def = RestfulToolDefinition.builder() .name(restfulModel.name()) .description(restfulModel.description()) .inputSchema(restfulModel.inputSchema()) .url(restfulModel.url()) .method(restfulModel.method()) .path(restfulModel.path()) .httpMethod(restfulModel.httpMethod()) .build(); toolCallbacks.add(McpRestfulToolCallback.builder() .toolDefinition(def).build()); }); return McpRestfulToolCallbackProvider.builder() .toolCallbacks(toolCallbacks.toArray(new McpRestfulToolCallback[0])) .build(); } public List<RestfulModel> getRestfulModels() { Parameter parameter = Parameter.builder() .parameteNname("timeZoneId") .description("time zone id, such as Asia/Shanghai") .required(true) .type("string") .build(); return List.of(new RestfulModel( "getCityTime", "获取指定时区的时间", JSONSchemaUtil.getInputSchema(List.of(parameter)), "http://localhost:8001", "getCiteTimeMethod", "/time/city", HttpMethod.GET)); } }然后在启动类里注册这个 Provider:
@Bean public ToolCallbackProvider mcpRestfulToolCallbackProvider(ParseRestful parseRestful) { return parseRestful.getRestfulToolCallbackProvider(); }到这里工具就能被列出来了,但调用时还拿不到请求头。关键在McpRestfulToolCallback的call方法里,用 WebClient 执行 REST 调用时把 header 带上:
public String call(String toolInput, @Nullable ToolContext toolContext) { Map<String, Object> args = JsonParser.fromJson(toolInput, new TypeReference<Map<String, Object>>() {}); String result = ""; if (HttpMethod.GET.equals(toolDefinition.httpMethod())) { StringBuilder uri = new StringBuilder().append(toolDefinition.path()).append("?"); args.forEach((k, v) -> uri.append(k).append("=").append(v).append("&")); result = WebClient.builder().build().get() .uri(toolDefinition.url() + uri) .headers(h -> this.headers.forEach(h::add)) .retrieve().bodyToMono(String.class).block(); } else if (HttpMethod.POST.equals(toolDefinition.httpMethod())) { result = WebClient.builder().build().post() .uri(toolDefinition.url()) .headers(h -> this.headers.forEach(h::add)) .bodyValue(args) .retrieve().bodyToMono(String.class).block(); } return result; }this.headers从哪来?这就是重写传输层的原因。在WebFluxSseServerTransportProvider的handleSseConnection里,SSE 建连时把请求头存进session2headers:
Map<String, String> headers = request.headers().asHttpHeaders().toSingleValueMap(); session2headers.put(sessionId, headers);然后在handleMessage里,当收到tools/call消息时,根据 sessionId 取出 header,塞给对应的 toolCallback:
if (McpSchema.METHOD_TOOLS_CALL.equals(method)) { Map<String, String> headers = this.session2headers.get(session.getId()); LinkedHashMap<String, String> params = (LinkedHashMap<String, String>) req.params(); String toolName = params.get("name"); for (McpRestfulToolCallback cb : mcpRestfulToolCallbackProvider.getToolCallbacks()) { if (toolName.equals(cb.getToolDefinition().name())) { cb.setHeaders(headers); } } }这样整条链路就通了:客户端建 SSE 时带 token,服务端存下来,工具触发时透传给 REST 接口。注意session2headers要用ConcurrentHashMap,多客户端并发时线程安全。
4. 客户端配置与 curl 验证请求的完整步骤
服务端跑起来后,客户端这边要配置 SSE 连接和模型。客户端的application.yml里,模型部分指向 TaoToken,MCP 部分配置 SSE 连接和 header:
server: port: 8002 spring: application: name: lucifer-ai-mcp-client main: allow-bean-definition-overriding: true ai: openai: api-key: ${TAOTOKEN_API_KEY} base-url: https://taotoken.net/api chat: options: model: glm-4-flash temperature: 0.7 completions-path: /v1/chat/completions mcp: client: enabled: true name: lucifer-ai-mcp-client version: 1.0.0 request-timeout: 30s type: ASYNC sse: connections: server1: url: http://localhost:8001 headers: token: lucifer toolcallback: enabled: true注意headers里的token: lucifer,这就是客户端建 SSE 时带的认证信息,服务端会把它透传到 REST 接口。api-key用环境变量注入,别硬编码。
客户端还需要重写SseWebFluxTransportAutoConfiguration,在构建 WebClient 时把配置里的 header 加进去:
WebClient.Builder webClientBuilder = webClientBuilderTemplate.clone() .baseUrl(serverParameters.getValue().url()) .defaultHeaders(headers -> { if (serverParameters.getValue().headers() != null) { serverParameters.getValue().headers().forEach(headers::add); } });启动顺序很重要:先起服务端lucifer-ai-mcp-server,再起客户端lucifer-ai-mcp-client。服务端起来后,你可以先用 curl 验证 SSE 端点是否正常:
curl -N http://localhost:8001/sse正常的话会看到类似这样的输出,说明 SSE 通道建立了:
event: endpoint data: /mcp/message?sessionId=xxxx-xxxx然后验证工具调用。客户端提供一个聊天接口,直接 curl 它:
curl "http://localhost:8002/ai/chat?message=北京时间"预期结果是模型识别出要调getCityTime工具,客户端通过 SSE 把tools/call发给服务端,服务端取出 header 里的 token,调用http://localhost:8001/time/city?timeZoneId=Asia/Shanghai,把结果返回给模型,最终你看到类似“北京时间是 2025-xx-xx xx:xx:xx”的回复。
如果你想单独验证服务端的 REST 接口,可以直接 curl:
curl "http://localhost:8001/time/city?timeZoneId=Asia/Shanghai"这一步能确认存量接口本身是通的。整个链路里,服务端日志会打印sessionId和 headers,你能看到 token 确实被透传了。如果模型没调工具,检查客户端的toolcallback.enabled是否为 true,以及模型是否支持 function calling。
5. 常见报错排查:401、local proxy failed 与 reading choices
跑这条链路时,我踩过几个典型的坑,这里按报错对照着说。
第一个是 401。现象是工具能列出来,但一调用就返回 401。原因通常是服务端透传的 header 没生效,或者客户端建 SSE 时没带 token。排查步骤:先看服务端日志里session2headers有没有存进去,如果存了但 REST 调用还是 401,检查McpRestfulToolCallback的setHeaders有没有被调用。常见错误是handleMessage里判断METHOD_TOOLS_CALL时,params强转类型不对,导致toolName取不到。另外确认客户端application.yml里headers的缩进,YAML 对缩进敏感,token要跟url同级。
第二个是local proxy failed。这个报错一般出现在客户端连不上服务端 SSE 时。先确认服务端 8001 端口在监听,curl -N http://localhost:8001/sse能出 event。如果服务端正常但客户端报这个,检查客户端配置里url是不是写成了http://localhost:8001/sse,注意这里只写到 host 和 port,sse-endpoint是单独配的,别重复。还有一种情况是 WebFlux 的WebClient被全局配置覆盖了,导致 baseUrl 丢失,检查有没有其他地方定义了WebClient.Builder的 Bean。
第三个是reading choices相关的报错,比如Error reading choices或Cannot deserialize value of type Choice。这个通常出在模型响应解析上,根因是 base-url 或 completions-path 配错了。TaoToken 的 base-url 是https://taotoken.net/api,completions-path 是/v1/chat/completions,如果你把 base-url 写成带/v1的,路径就重复了。另外确认api-key环境变量真的注入了,echo $TAOTOKEN_API_KEY看一下。如果 Key 没问题,去模型对话页面确认这个模型 ID 是可用的。
第四个是 OAuth 相关的报错。如果你用的是需要 OAuth 的模型服务,客户端配置里要额外处理 token 刷新。不过用 TaoToken 的 API Key 方式不涉及这个,如果你看到OAuth字样,先确认是不是误配了别的 provider。Spring AI 的 MCP 客户端在type: ASYNC下对认证的处理比较直接,header 透传就够了。
排查时有个通用技巧:把服务端和客户端的日志级别调到 DEBUG,logging.level.org.springframework.ai=DEBUG,这样能看到 MCP 消息的收发细节,比猜快得多。另外sessionId是串联整条链路的关键,服务端日志里搜sessionId,能快速定位是建连阶段还是调用阶段出的问题。
6. 把存量接口接进 Agent 的下一步
链路跑通后,你会发现这套适配器的扩展点很清晰。ParseRestful.getRestfulModels()里现在是手写了一个接口,实际项目里你可以从 OpenAPI 的 JSON 描述里解析出所有 path 和参数,批量生成RestfulModel。OpenAPI 的operationId可以直接当工具名,summary当描述,parameters转成 JSON Schema,这样存量接口就能一键转成 MCP 工具。
认证方面,现在是把客户端建连时的 header 全量透传,生产环境里你可能要做白名单,只透传Authorization或自定义的X-Token,避免把无关 header 带到后端。另外session2headers在会话取消时要记得清理,sink.onCancel里已经做了sessions.remove,但session2headers也要同步移除,否则长跑会内存泄漏。
如果你要接多个 MCP 服务端,客户端配置里connections下可以加server2、server3,每个配不同的 url 和 header,McpRestfulToolCallbackProvider会把所有工具聚合起来,工具名冲突时加前缀区分。模型侧用 TaoToken 的好处是切换模型只改一个model字段,不用动 MCP 配置,调试不同模型对工具调用的支持度时很方便。
最后提醒一点,MCP 工具的执行是同步阻塞的,WebClient.block()在高并发下会占线程。如果 QPS 高,考虑把call改成返回Mono,或者用type: ASYNC配合响应式链路。不过对大多数内部工具场景,现在的实现够用了。源码在两个仓库里,服务端和客户端分开,你可以直接 clone 下来改ParseRestful里的接口定义,换成自己的存量接口就能跑。