1. 先说说我为什么折腾这个桥接器
大约两个月前,我在为一个内部平台做技术预研,团队计划把一个已有二十多个REST接口的业务后端,接进一个基于大模型的问答与操作助手。问题一开始就不在AI能力上,而是AI压根不知道这些接口长什么样,更别提稳定、安全地调用它们。
我最初的想法很朴素:逐个接口手写工具注册代码。给每个接口写方法名、入参说明、出参格式,再在调用层做参数映射。写了大概三分之一我就受不了了——这纯粹是体力活,而且极其容易漏字段。二十个接口尚且如此,后面还有几十个接口要接入,手写根本不可持续。
真正让我豁然开朗的,是我突然意识到后端这些接口本来就已经有一份完整的OpenAPI描述文档了。路径、参数、请求体、响应体、数据类型,全都写得清清楚楚。既然这份文档已经把接口“说明书”写好了,那我为什么不写一个程序,直接读取OpenAPI,把这些接口自动翻译成AI能够调用的工具呢?
这就是我做的“OpenAPI-to-MCP Bridge”这个工具的原型。它做的事可以拆成两半:解析Spring Boot服务已有的OpenAPI描述,再把每个操作动态注册到MCP(Model Context Protocol,模型上下文协议)的工具列表中。AI侧看到的是一个个语义明确的工具,后端侧仍然是那套完全没有改动的REST API。
这篇文章不打算只贴最终代码,我想把推进这个项目过程中踩过的坑、值得提前想清楚的设计、顺带想到的运维问题都过一遍。如果你是那种后端接口很多、又希望快速搭上MCP这班车的开发者,这篇会适合你。哪怕你只是对MCP有兴趣、还没想好是否要落地,也可以把它当成一份“后端API如何变成AI可调用资产”的思路参考。
2. 先把两边“长相”对齐:OpenAPI规格和MCP工具协议
2.1 Spring Boot服务是如何暴露OpenAPI的
想自动转换,第一件事是搞清楚输入长什么样。Spring Boot本身不生成OpenAPI文档,但项目里通常会用springdoc这类库,启动后访问/v3/api-docs就能拿到一份JSON,内容是OpenAPI 3.x格式。
这份JSON描述了这个服务的全部API资产:有哪些接口路径、路径上支持哪些HTTP方法、每个方法有哪些入参(path、query、header、requestBody)、响应是什么结构、哪些数据类型被复用。一个简单的新增用户接口,在OpenAPI文档里大概长这样:
{ "openapi": "3.0.1", "info": { "title": "order-service", "version": "1.0.0" }, "paths": { "/orders": { "post": { "operationId": "createOrder", "tags": ["order"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateOrderRequest" } } } }, "responses": { "200": { "description": "success", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Order" } } } } } } } }, "components": { "schemas": { "CreateOrderRequest": { "type": "object", "properties": { "productName": { "type": "string", "description": "商品名称" }, "quantity": { "type": "integer" } }, "required": ["productName", "quantity"] }, "Order": { "type": "object", "properties": { "orderId": { "type": "string" }, "status": { "type": "string" }, "createdAt": { "type": "string" } } } } } }大多数业务服务的OpenAPI文档规模都不小,几十上百个接口非常常见。如果靠人读完整份文档再手写工具定义,那这份文档的价值就只停留在“给前端看”和“给测试用”的阶段。让程序去消费它,才是它真正的价值所在。
2.2 MCP服务器到底要暴露什么给AI
MCP协议最近半年在开发者圈子里讨论度很高,它试图解决的是一个很底层的需求:让AI模型以一种统一、可控的方式调用外部工具和数据源。
站在服务端视角,它对“工具”的抽象其实不复杂。一个工具本质上是一个命名的函数描述,MCP要求你提供四样东西:
- 工具名(name):AI决定调用它时使用的标识;
- 描述(description):自然语言文本,告诉AI这个工具是做什么的、什么时候该用;
- 输入Schema(inputSchema):JSON Schema格式,规定工具需要哪些参数、参数类型、哪些必填;
- 处理函数(handler):真正执行调用、返回结果的逻辑。
当AI决定调用某个工具时,客户端会把AI生成的参数传给服务端,服务端执行handler,把结果包装成MCP规定的消息(最常见的是文本内容)返回给AI。对业务系统来说,整个过程就好像一个“参数进来、结果出去”的远程过程调用。
所以从本质上看,把REST API变成MCP工具,做的不是“逆向工程”,而是“翻译”:
OpenAPI的path + method + parameters + requestBody + responses ↓ MCP的name + description + inputSchema + handler我最初觉得这个转换会很简单,真正动手才发现,简单接口确实容易,复杂接口处处是细节。接下来我把两边字段的对应关系和边界好好梳理一遍。
2.3 OpenAPI操作与MCP工具的字段映射关系
在桥接器的转换逻辑里,我维护了这样一张映射表:
| OpenAPI元素 | MCP工具元素 | 备注 |
|---|---|---|
| operationId,或 method+path | name | 工具名必须稳定且唯一 |
| operation的summary、description、tags | description | 描述越清晰,AI选对工具的概率越高 |
| parameters(path/query/header) | inputSchema的属性 | path和query通常直接映射,header需单独归类 |
| requestBody中的schema | inputSchema里对应对象属性 | 要展开$ref,也要处理特定mediaType |
| responses中成功的schema | 返回值格式说明 | 可用于生成“返回值说明”,引导AI解析结果 |
| securityRequirement | 鉴权策略 | MCP层可以透传或替换凭证 |
有了这张映射表,后面写解析代码时思路就非常清晰了:真正要做的事情,就是遍历OpenAPI的paths,把每个HTTP操作变成一个工具规格,再为每个工具规格绑上一个能真正调用后端接口的handler。
3. 桥接器核心设计:从接口定义到工具注册
3.1 解析入口:兼容JSON和YAML两种格式
不同团队的Spring Boot服务习惯不一样,有的直接用springdoc的默认JSON输出,有的则习惯把OpenAPI文档作为YAML文件提交到仓库里维护。所以桥接器第一个模块,要能兼容两种输入。我这边做法很简单,用SnakeYAML解析YAML,用Jackson解析JSON,再统一转成我自己定义的ApiSpecModel中间模型。
很多人会忽略这一步:解析出来的OpenAPI对象应该做一次无损的中间建模,而不是边解析边注册。原因是后面注册工具时可能需要对operation做去重、校验、过滤,如果直接从原始文档流式处理,代码会非常乱。我建议至少抽象出OperationInfo、ParameterInfo、SchemaInfo三个中间类,它们分别对应“一个操作”“一个入参”“一个数据模型”。
public class OperationInfo { private String operationId; private String httpMethod; private String path; private String summary; private String description; private List<ParameterInfo> parameters; private SchemaInfo requestBodySchema; private SchemaInfo responseSchema; private boolean requiresAuth; }中间建模做扎实后,后面生成工具名、生成JSON Schema、处理鉴权都可以在同一个模型上操作,测试也好写。我甚至把同一个服务在不同环境导出的OpenAPI文档做了一次对比,用来发现接口漂移。这个在设计阶段完全没想到,后来变成了一个额外收获。
3.2 工具名生成规则与重名冲突处理
MCP协议对工具名有约束,要求只能包含字母、数字、下划线或连字符,并且在同一个服务器内不能重名。OpenAPI里的operationId虽然形式上具备唯一性,但很多团队并没有规范填写,而且operationId不一定符合MCP的命名约束。
我最终采用的生成规则整理如下:
- 优先使用operationId,对它做一次清洗:非字母、数字、下划线、连字符的字符,统一替换成下划线;
- 如果没填operationId,就使用HTTP方法加路径组合,比如
get_orders_orderId,路径参数占位符替换为可读名称; - 清洗后如果发现名称以数字开头,在开头补一个
api_前缀,避免某些客户端解析出问题; - 维护一个全局Map做计数器,遇到重名则追加后缀。
为什么这么看重唯一性和可读性?你想象一下几十个接口前缀相近的业务场景:如果不加处理,AI在判断该调用哪个工具时,很容易被相似的名字带偏。工具名是AI的“检索入口”,宁可长一点、有层次感,也不要图省事用GET /order/getById这种带斜杠和空格的东西。
以模拟项目X为例,它内部有一个订单模块,其中“根据订单号查订单”的接口,operationId是getOrderById,清洗后就是这个名字。但有一次我接老服务,发现某个operationId只写了个order,清洗后就是order,另一个同名单词又出现后变成order_2。遇到这种情况,我会在description里把完整路径写进去,帮助AI分辨。
3.3 参数Schema映射:不只是复制字段
inputSchema要求是JSON Schema格式,但OpenAPI的schema和JSON Schema并不完全是一回事。最常见的问题是$ref引用和oneOf/anyOf这种组合类型。我第一版写映射时直接操作JsonNode树,遇到引用就原封不动塞进去,结果MCP Server直接启动失败,因为它拿不到$ref引用的实际定义。
解决方法是递归展平:
private JsonNode resolveRef(String ref, OpenApiModel model) { String pointer = ref.replace("#/components/", ""); JsonNode node = model.getComponents().at("/" + pointer); if (node.has("$ref")) { return resolveRef(node.get("$ref").asText(), model); } return node.deepCopy(); }展平之后还要做第二件事:把OpenAPI特定字段清理掉。比如xml、externalDocs,还有一些版本特有的example格式,这些字段留在schema里虽然不会致命,但会让工具描述变得臃肿,AI解析时也容易被无关字段干扰。只保留title、description、type、properties、required、items、enum、default这些JSON Schema最常用的字段就行。
另外,如果同一个schema被多个接口引用,我建议按需复制,而不是直接复用同一个JsonNode对象。因为清理字段时如果动了共享节点,会影响到其他接口的schema。我踩过这个坑,后来统一用深拷贝处理,再也没出过这种串数据的怪问题。
3.4 请求与响应处理层:让工具真正能调用后端
有了定义,还差执行。每个工具注册时,我需要绑定一个handler,它负责接收AI填出来的参数,转换成对后端REST接口的HTTP请求。
这个handler的设计有个容易忽略的点:AI生成的参数与REST接口参数之间,不是简单的“同名同值”。比如OpenAPI文档里允许参数名带连字符(user-id),而AI通常会输出user_id;再比如query参数、path参数和requestBody在OpenAPI中属于不同集合,接收方构造请求时必须分别处理。
我的做法是,在生成inputSchema时把所有参数统一放进同一个名字空间,但在handler内部维护一张“来源映射表”,记录每个参数究竟属于path、query、header还是body。执行调用时:
Map<String, String> pathParams = new HashMap<>(); Map<String, String> queryParams = new HashMap<>(); Map<String, Object> bodyParams = null; for (String argName : arguments.keySet()) { ParameterSource source = sourceMap.get(argName); switch (source) { case PATH -> pathParams.put(argName, String.valueOf(arguments.get(argName))); case QUERY -> queryParams.put(argName, String.valueOf(arguments.get(argName))); case BODY -> bodyParams = arguments; } }后端接口的响应五花八门:标准JSON、纯字符串、二进制文件流都有。MCP允许返回的content中带text、image或resource。我的桥接器里,大部分接口返回JSON,统一按text处理,同时对响应做一次外层包装,带上HTTP状态码,方便AI判断调用是否成功。
{ "statusCode": 200, "headers": { "content-type": "application/json" }, "body": { "orderId": "20250101001", "status": "CREATED" } }有时候经验会告诉我,可以在这一层顺手做点“业务适配”,但我的建议是尽量保持通用,只在最外层做一次包装。因为业务特殊逻辑一旦写进通用桥接器,整个工具就会从“通用转换器”退化成“专用胶水”,后面维护成本会高得让你想删掉重写。
4. 在Spring Boot工程里落地这套桥接器
4.1 服务端总体结构:独立入口还是嵌入式
实现MCP Server有两种常见方式。
一是把桥接器做成独立的Spring Boot服务,负责读取目标服务的OpenAPI文档,自身对外提供MCP的stdio或HTTP传输通道。二是把桥接器作为依赖嵌入到现有Spring Boot服务中,服务启动时自动读取自身的OpenAPI并注册MCP Server。
两者权衡,我建议从隔离性和链路开销两个角度考虑。独立服务的优势是隔离性好、不侵入业务代码,特别适合对接老旧系统;缺点是网络链路加了一层,调用会多一跳。嵌入式方案省掉跳数,但要求目标服务能方便地引入依赖,对一些生产环境要求高的系统可能不太乐意。
我最初是在模拟项目X上做的嵌入式Demo,直接把桥接器打进了订单服务的依赖里,运行后在旁边开了一个MCP端口。效果不错,也不影响原有业务接口。后来要接一个历史老服务,老服务不方便加依赖,就拆了一个独立进程出来,通过读取该服务导出的OpenAPI文件来工作。两种形态的解析和转换内核是同一套代码,所以切换形态的成本并不高。
4.2 核心注册代码长什么样
以Java版本的MCP Server SDK为例,整个注册循环的代码量其实不大:
McpServer server = McpServer.using(transport); for (OperationInfo op : operations) { String toolName = nameGenerator.generate(op); String toolDesc = describeTool(op); String schemaJson = schemaMapper.toJsonSchema(op); server.tool( ToolSpecification.builder(toolName) .description(toolDesc) .argsSchema(schemaJson) .build(), (spec, arguments) -> { Object result = httpInvoker.invoke(op, arguments); return Observable.just(result); } ); }这段代码看着简单,但背后三个生成器才是核心。尤其是schemaMapper.toJsonSchema(op),我在上一章提到的递归展平、字段清理、名字空间统一,全都发生在这里。工具描述也不是简单把summary复制过来,而是用模板拼接生成,把HTTP方法、接口路径、支持的操作都写进去。因为AI对“这个工具的本质是一次REST调用”这件事认知越清楚,越不会在调用时误传参数。
我常用的description模板大概长这样:
根据订单号查询订单信息。调用方式:GET /orders/{orderId} 输入说明:orderId为整型订单号,必填。 返回体包含订单状态、商品、金额、创建时间。4.3 配置项与启动验证
桥接器接入Spring Boot后,我对外暴露了几个配置项:
mcp-bridge: source: type: springdoc-url # springdoc-url | openapi-file url: http://localhost:8080/v3/api-docs file: ./openapi.json server: name: order-service-mcp transport: stdio # stdio | sse port: 8890 filter: include-operations: "get*,create*" exclude-operations: "delete*" security: mode: passthrough # passthrough | fixed-token | none启动验证时我很少直接打开AI客户端去体验,通常先跑一个脚本,用MCP客户端SDK按名字拉一下工具列表,确认工具数量、名称、schema能正常返回。这一步能过滤掉大部分问题,比在IDE里反复重启高效得多。
# 假设客户端支持命令行方式 mcptool list --server http://localhost:8890/mcp如果看到输出里的工具数量和后端接口对齐、字段必填项也正确,基本就说明转换链路是通的,这时候再接入AI客户端做真实调用测试也不迟。
5. 真实转换过程中踩到的坑和适配细节
5.1 工具描述里的“废话陷阱”
第一次生成完工具列表,我用AI客户端测试,发现AI对某些接口的理解很别扭。后来打开描述一看,有些接口的summary填得实在太随意,直接写了“接口”两个字;还有一些接口的description是从字段描述里自动拼接出来,非常不自然。
后来我把描述生成的优先级调整为:先取operation的description,再看summary,最后才按接口路径自动生成。同时规定,如果description内容不足,就附加接口路径和已知参数说明。这个调整看似简单,却让AI选中正确工具的成功率高了不少。
整个调优过程中最崩溃的一次,是把一个内部服务所有接口转出来后,有一个返回体里带了个巨大的嵌套对象,AI把这个接口判断成了“万金油”工具,什么问题都往这个工具上引。排查之后发现,是schema中某个大对象没有裁剪,导致工具描述过长,扰乱了AI的判断。从那以后我给自己定了一条规矩:桥接器只做通用化转换,但如果发现某个schema体积异常大,要考虑在过滤层做裁剪,而不是让AI面对一份冗长的原始定义。
5.2 日期、枚举与默认值不应该原样搬运
REST接口里常见的日期字段,在OpenAPI里可能是string类型,也可能写了format: date-time。如果直接把format: date-time原样留在JSON Schema里,一部分MCP客户端不会把它当字符串处理,导致AI生成的是时间戳数字,后端接口解析时直接报错。
我的处理方式是:类型判断优先以type为准,把OpenAPI的format信息改写成JSON Schema的format,但确保type始终是string。碰到枚举字段,直接把枚举值列表放进去,AI生成参数时会自动收敛到合法值。有默认值的字段,把默认值写进schema的default字段,这样AI即使没填,后端也可能按默认值正常处理。
这些小细节没有写在任何规范文档里,全是我实际调调用时、盯着后端日志里的报错一点点摸出来的。每个看似无意义的字段,都可能影响AI最终生成参数的质量,不能想当然地“原样搬过去”。
5.3 对非200响应的主动拦截
REST接口的错误响应在OpenAPI里也会定义,比如400、401、500。早期版本我把这些“防御性”的响应都交给AI判断,但AI看到body里是一长串异常堆栈时,往往会开始“硬编”错误原因,甚至一本正经地编造调用方法。
我后来改成了在桥接器内部做一层简单判断:如果HTTP状态码是4xx或5xx,不把完整body直接返回,而是截取错误消息前几百个字符,同时明确告诉AI“调用失败,状态码xxx,错误信息xxx”。这样AI要么及时调整参数,要么直接告诉用户操作失败,而不是分析一段没有意义的堆栈。
这个拦截还有一个附带好处:减少了异常信息泄漏给大模型的风险。毕竟错误堆栈里经常带着服务器路径、数据库表名、中间件版本这些内部信息,让它们进入AI上下文并不合适。
5.4 鉴权透传的逻辑比想象中复杂
现有REST API基本都带着鉴权,而MCP调用方一般没有后端服务的Session或Token。桥接器必须决定一个问题:凭证由谁提供?
我的方案是支持两种模式。
- passthrough模式:AI调用时如果参数里带了token,就用它;如果没带,就从MCP请求上下文中读取已有凭证。这个模式适合已有客户端身份识别的场景。
- fixed-token模式:桥接器配置一个服务账号凭证,所有转发请求统一带它。适合内部工具、风险较低的场景。
另外有些服务走OAuth2客户端模式,我把它做成一个可扩展的凭证接口,不同实现类各自负责拿Token,这样新服务对接时只需要增加一个凭证提供者。
这块我想特别提醒一句:不要把生产环境的真实凭据硬编码在桥接器配置里。至少用环境变量注入,再配合配置中心,把敏感信息和非敏感信息分开管理。MCP协议本身不负责加密,但现实中的安全不能只靠协议兜底。
5.5 动态刷新:OpenAPI变了,工具要跟着变
业务接口不可能永远不变。如果桥接器只在启动时读取一次OpenAPI,之后后端接口改了,工具定义就陈旧了,AI调用的还是老版本接口信息,早晚要出事故。
我后来给桥接器加了一个定时刷新任务,每隔一定时间重新拉取OpenAPI文档,对工具列表做更新。更新的粒度我建议是“整组替换”,但替换前先比较工具集合是否变化,没有变化就跳过,尽量减少MCP客户端的感知。如果发现新增或删除了工具,主动打日志并通知运维。
这个功能我用一次简单的测试验证过:先在模拟项目X中新增一个接口,再通过工具列表命令查看,新工具在刷新周期之后自动出现,不需要重启桥接器。这样的体验在演示场景非常加分,同时也是生产可维护性的关键一环。
6. 从一次“自动化”到一类“AI资产”的思考
写到这里,工具本身的实现思路基本讲完了。最后我想分享一点实操中沉淀下来的想法。
桥接器不是万能的,它不会帮你把接口文档的质量变好,也不会自动补全缺失的description。如果服务本身的接口混乱、字段含义不清,转换出来的工具列表只会把这堆混乱放大。所以在把这个转换工具铺开之前,我强烈建议先对目标服务的OpenAPI文档做一次“体检”,把那些summary为空、参数类型混用、没有响应定义的operation列出来,能优化的先优化一轮。
我在模拟项目X上就是这样做的:把体检结果输出成一张清单,发给对应服务负责人去补充描述。原本以为会扯皮很久,结果大家看到清单后配合得反而很积极。为什么会这样?因为桥接器让他们更直观地感受到“接口文档质量会直接影响AI是否愿意用你的接口”,这比过往强调文档规范时的说教管用得多。
如果你想做类似的东西,我个人建议的演进路线是这样:第一版,先只读OpenAPI、在本地跑通工具注册;第二版,增加过滤、鉴权、动态刷新;第三版,再考虑做成平台,支持多个后端服务注册,统一管理工具目录。不要在第一天的目标里就写成庞大的工具网关,结合你实际要接入的接口规模,稳定地跑通最小闭环比什么都重要。
我试过在几个不同类型服务之间复用这套桥接器,Spring Boot服务之外,一个PHP微服务和某个网关导出的OpenAPI文件也成功接入了。这让我更加确认一点:OpenAPI标准化的力量不在于那份文档本身,而在于它能够成为连接“已有系统”和“新AI世界”之间的通用语言。你只需要写一次转换内核,后面接什么服务都是水到渠成的事。