)
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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 Key。控制台入口在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite Key 管理页面是 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。创建时给它起个名字比如mcp-demo复制出来保存好后面配置里要用。第二步确认你要用的模型 ID。TaoToken 的模型列表在文档里有常用的比如glm-4-flash、gpt-4o-mini这类。你可以在模型对话页面先试一下能不能正常返回地址是 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 输入一句话看有没有响应。这一步能帮你排除 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_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你后面要做长期编码或者 Agent 类的持续调用可以考虑 Coding Plan入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它更适合高频场景。不过这篇教程里普通按量调用就够了。准备好 Key 和模型 ID 后我们就可以进入服务端的配置环节了。记住三个东西Base URL 是https://taotoken.net/apiKey 是你刚复制的Model ID 是你选的模型名。这三个在客户端配置里会一起出现。3. 可复制的 MCP 服务端配置与接口映射代码这一节是重头戏我把服务端的核心配置和代码拆开讲。整个服务端要做三件事定义 REST 接口到 MCP 工具的映射、实现带请求头透传的 ToolCallback、重写 SSE 传输层把建连时的 header 存下来。先看依赖。pom.xml里需要 Spring AI 的 MCP 服务端 starter 和 WebFluxdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server-webflux/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency然后是application.yml服务端监听 8001MCP 的 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 描述转成McpRestfulToolCallbackProviderComponent public class ParseRestful { public McpRestfulToolCallbackProvider getRestfulToolCallbackProvider() { ListMcpRestfulToolCallback 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 ListRestfulModel 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)); } }然后在启动类里注册这个 ProviderBean public ToolCallbackProvider mcpRestfulToolCallbackProvider(ParseRestful parseRestful) { return parseRestful.getRestfulToolCallbackProvider(); }到这里工具就能被列出来了但调用时还拿不到请求头。关键在McpRestfulToolCallback的call方法里用 WebClient 执行 REST 调用时把 header 带上public String call(String toolInput, Nullable ToolContext toolContext) { MapString, Object args JsonParser.fromJson(toolInput, new TypeReferenceMapString, 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 建连时把请求头存进session2headersMapString, String headers request.headers().asHttpHeaders().toSingleValueMap(); session2headers.put(sessionId, headers);然后在handleMessage里当收到tools/call消息时根据 sessionId 取出 header塞给对应的 toolCallbackif (McpSchema.METHOD_TOOLS_CALL.equals(method)) { MapString, String headers this.session2headers.get(session.getId()); LinkedHashMapString, String params (LinkedHashMapString, 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里模型部分指向 TaoTokenMCP 部分配置 SSE 连接和 headerserver: 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?sessionIdxxxx-xxxx然后验证工具调用。客户端提供一个聊天接口直接 curl 它curl http://localhost:8002/ai/chat?message北京时间预期结果是模型识别出要调getCityTime工具客户端通过 SSE 把tools/call发给服务端服务端取出 header 里的 token调用http://localhost:8001/time/city?timeZoneIdAsia/Shanghai把结果返回给模型最终你看到类似“北京时间是 2025-xx-xx xx:xx:xx”的回复。如果你想单独验证服务端的 REST 接口可以直接 curlcurl http://localhost:8001/time/city?timeZoneIdAsia/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 和 portsse-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/apicompletions-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 透传就够了。排查时有个通用技巧把服务端和客户端的日志级别调到 DEBUGlogging.level.org.springframework.aiDEBUG这样能看到 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 和 headerMcpRestfulToolCallbackProvider会把所有工具聚合起来工具名冲突时加前缀区分。模型侧用 TaoToken 的好处是切换模型只改一个model字段不用动 MCP 配置调试不同模型对工具调用的支持度时很方便。最后提醒一点MCP 工具的执行是同步阻塞的WebClient.block()在高并发下会占线程。如果 QPS 高考虑把call改成返回Mono或者用type: ASYNC配合响应式链路。不过对大多数内部工具场景现在的实现够用了。源码在两个仓库里服务端和客户端分开你可以直接 clone 下来改ParseRestful里的接口定义换成自己的存量接口就能跑。