前阵子有个需求要在内部系统里挂一个能回答业务问题的大模型助手结果发现大部分问题都牵扯到真实订单数据。最开始我有两条路给模型一个直连数据库的账号或者给它写一堆REST查询接口。前者太吓人后者太磨人。后来换了个思路用 SpringAI 和 MCP 协议把 MySQL 包成一个标准化的服务模型不碰连接串不碰表结构只和数据访问层暴露出来的工具打交道。这篇文章就把 SpringAI-MySQLMcp服务 从原理到落地完整走一遍包括服务端怎么搭、工具方法怎么设计、安全边界怎么扎还有我在 stdio 模式下踩过的几个坑。适合已经在用 Spring 生态、想把企业数据安全地接到大模型应用的开发者参考。1. 起因为什么没让模型直连数据库而是包了一层 MCP 服务1.1 直连数据库的三个真实痛点让大模型直连数据库这个方案最早我确实认真考虑过因为实现起来太简单了给模型配一个数据库账号让它自己写SQL自己查。但冷静下来一推演这条路基本走不通。第一个痛点是权限粒度太粗。数据库账号的权限只能控制在 SELECT、INSERT、UPDATE 这一层完全管不住查哪张表、查多少行、什么时候查。一个模型会话一旦拿到查询权限全库的表对它来说都是开放的敏感字段、内部数据结构全部暴露在提示词上下文里。哪怕你告诉它只准查订单表它也随时可能因为某个问题触达用户表、日志表。更麻烦的是模型生成的SQL经常带方言、带错列名一旦命中慢查询DBA连这个连接是谁开的都很难追溯。第二个痛点是输出格式不稳定。模型直接查完SQL之后返回的结果它想怎么组织就怎么组织。今天返回昨天订单量是230单总金额45000元这样一句话明天可能就是一段带着原始SQL的复述。对上层应用来说这种非结构化输出没办法做后续加工也没办法做断言和校验。第三个痛点是维护成本。如果后面不止一个大模型应用要接数据每个应用都配一个数据库账号安全策略散落各处改一个表结构要反复通知所有接入方。说白了直连方案把一次连接的问题做成了一堆连接的问题。1.2 MCP 解决的是连接标准化问题MCPModel Context Protocol本质上是给 AI 应用和数据服务之间定了一个标准接口。服务端可以暴露一个工具清单客户端通过协议去发现工具、调用工具、拿回结果。模型再也不需要知道数据库地址、连接串、表结构它只需要知道有一个名叫queryOrderByNo的工具接收一个orderNo参数返回订单基本信息。这个模式很像我们去银行柜台办事你不用进金库不用知道账本放在哪一排柜子里你只需要把单子递进窗口柜员帮你查好再递出来。窗口就是 MCP 工具银行后台就是 MySQL。Spring AI 在这个故事里的角色是把 MCP 服务端的搭建变成了标准的 Spring Boot 开发。数据源可以用 Spring 那一套成熟的配置体系事务、连接池、监控都能复用工具方法只需要用注解标注剩下的协议细节框架处理。这对 Java 生态里的团队来说学习曲线比从零实现一套 JSON-RPC 服务要平滑得多。2. 协议视角MCP 服务端在整个链路里干了什么活2.1 工具、资源、提示词MCP 的三个基本维度MCP 规范里服务端可以向客户端暴露三类能力工具Tools、资源Resources、提示词Prompts。工具是最常用的维度它对应的是能被调用的函数有名字、有描述、有输入参数。MySQL 查询服务主要依赖这一类能力。资源更像是可读取的数据文件比如一张 CSV、一份 JSON 文档、一个日志文件客户端可以读取但不需要传参。提示词则是服务端预置的对话模板比如帮我总结这份数据这类固定指令。对于一个 MySQL MCP 服务来说核心就是把查询方法变成 Tools 暴露出去。Resources 和 Prompts 在某些场景也有用比如把慢查询统计导出成 JSON 资源或者预先写好 SQL 生成提示词但最初版本建议先聚焦在 Tools 上避免类型膨胀。2.2 一次 tools/call 调用的完整链路客户端连上 MCP 服务端之后虽然底层是一串 JSON-RPC 2.0 消息在来回但整个调用过程可以理解成四步初始化握手、能力协商、发现工具、调用工具。初始化握手阶段客户端发initialize请求服务端返回协议版本和自身信息。之后客户端发送notifications/initialized通知。接着客户端会发tools/list获取工具列表服务端把每个工具的名称、描述和 JSON Schema 参数格式返回回去。最后客户端发tools/call携带工具名和参数服务端执行业务逻辑返回一个 content 数组里面就是文本格式的结果。这个过程和普通 REST 接口很不一样我整理了一个对照表帮大家理解差异维度普通 REST 接口MCP 工具调用接口定位URL HTTP 方法工具名 参数接口发现依赖文档或 OpenAPI 定义运行时通过 tools/list 发现返回格式接口自定义统一的 content 数组权限控制通常在网关或接口层服务端工具方法自行把关这个统一性最大的价值在于接入成本。接一个新的 AI 应用进来不需要重新写适配逻辑只要这个应用支持 MCP 客户端直接就能看到工具、调用工具。2.3 Spring AI 把复杂度压在了哪里如果抛开框架从零实现一个 MCP 服务端你需要处理协议版本协商、JSON-RPC 消息解析、连接状态管理、stdin/stdout 或者 HTTP 传输层适配再加上工具注册和 Schema 生成工程量并不小。Spring AI 的 starter 把这些都封装掉了。开发者只需要做两件事声明带注解的工具方法把方法注册成 ToolCallback。框架会自动生成工具入参的 JSON Schema自动处理消息收发自动在工具执行前后完成结果序列化。这也是我推荐在 Spring 生态内用 Spring AI 做 MCP 服务端的最主要原因——你专注的是查询逻辑应该怎么设计而不是协议层怎么通。3. 工程落地把 MySQL 查询能力变成 MCP 工具3.1 工程骨架与依赖我们用 Spring Boot 3 作为基础。需要注意 Spring AI 的起步依赖和 Boot 版本是对齐的直接用 Boot 3.2 以上的版本再引入对应版本的 Spring AI 1.0 GA兼容性基本不用操心。Maven 依赖里核心就这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-mcp-server/artifactId /dependency如果你之后需要走 HTTP 传输还需要额外引入spring-ai-starter-mcp-server-webmvc。这里我们先基于 stdio 模式这是 MCP 最常见的进程内通信方式由一个父进程启动你的 Spring Boot 应用与应用的标准输入输出通信。配置文件里面主要是两段数据源和 MCP 服务信息。spring: application: name: mysql-mcp-server datasource: url: jdbc:mysql://localhost:3306/biz_db?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ai_read password: change-me hikari: maximum-pool-size: 5 ai: mcp: server: name: mysql-mcp-server version: 0.1.0 transport: stdio数据源复用 Spring Boot 的标准配置HikariCP 的 pool 我一般调小一点因为 AI 应用调用工具的并发通常不会特别高连接数给到 5 已经够用。3.2 第一个工具方法按订单号查订单工具方法本身就是一个普通的 Java 方法只是加了Tool注解并写明描述。这里拿最常用的场景举例按订单号查询订单主信息。import org.springframework.ai.tool.annotation.Tool; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.stereotype.Service; import java.util.Map; Service public class OrderQueryTool { private final JdbcTemplate jdbcTemplate; public OrderQueryTool(JdbcTemplate jdbcTemplate) { this.jdbcTemplate jdbcTemplate; } Tool(description 根据订单号查询订单主信息返回订单号、金额、状态、创建时间订单不存在时返回 null) public MapString, Object queryOrderByNo(String orderNo) { String sql SELECT order_no, amount, status, created_at FROM orders WHERE order_no ? LIMIT 1; try { return jdbcTemplate.queryForMap(sql, orderNo); } catch (EmptyResultDataAccessException e) { return null; } } }要让 MCP 服务端识别这个工具需要把它注册为 ToolCallback Bean。不同版本的 Spring AI 这个 API 略有差异核心逻辑是用 MethodToolCallback 把一个带Tool注解的方法包装起来交给容器管理。import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.MethodToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class McpServerConfig { Bean public ToolCallback orderQueryToolCallback(OrderQueryTool tool) throws NoSuchMethodException { return MethodToolCallback.builder() .toolDefinition(ToolDefinition.builder(tool) .description(按订单号查询订单主信息) .build()) .toolType(tool) .build(); } }注册完成之后启动应用MCP 客户端通过 tools/list 就能看到这个工具输入参数会被自动生成成 JSON Schema。3.3 为什么工具描述字段值得认真写工具描述是给模型看的不是给程序员看的。很多人在这一步随便写一句查询订单结果模型在实际使用时会拿不准到底应该传单号、传用户ID还是传日期范围。工具描述写得越精确模型调对的概率越高。下面这两种描述模型的表现会差很多模糊写法查询订单数据精确写法根据订单号查询订单主信息订单号格式为字母数字组合的 6-32 位字符串返回订单号、金额、状态、创建时间订单不存在时返回 null原因很简单模型选择工具靠的是对描述的理解参数填得准不准靠的是对参数定义的理解。描述里把约束条件、返回字段都写清楚模型就不需要猜上下文里也不会产生额外歧义。3.4 一个工具类里常见的方法划分方式数据查询类工具我一般按业务问题来划分而不是按表划分。比如先做一张用户维度查询再做一张订单维度查询然后做统计类查询。划分粒度太细不行比如查询订单号和查询订单金额分开工具列表会爆炸划分粒度太粗也不行比如只做一个查询任意表的工具安全上直接失控。一个折中的做法是每个工具对应一类完整业务问题参数设计限制在 1-3 个。后面我会再展开讲方法白名单的思路那是这个服务的核心安全设计之一。4. 设计安全边界AI 访问数据库的这道防线要扎多紧4.1 从账号层做隔离只读用户是最低要求既然目标是让模型能查数据库账号层面必须和业务账号完全隔离。我建议单独创建一个只读账号并且只给 SELECT 权限同时回收所有 DDL 权限。CREATE USER ai_read% IDENTIFIED BY strong-readonly-password; GRANT SELECT ON biz_db.* TO ai_read%; FLUSH PRIVILEGES;如果能做到表级隔离更好比如这个 AI 应用只需要订单数据就只授权订单相关表。账号隔离是最粗的一层防线也是所有其他安全设计的前提。如果这层失效后面无论代码里写多严密的过滤都可能被绕过。4.2 方法白名单比通用 SQL 执行器可靠最危险的工具设计是暴露一个通用的执行入口比如Tool(description 执行任意 SQL 查询) public ListMapString, Object executeSql(String sql) { ... }这种工具一旦被模型拿到等于把数据库的半扇门打开了。你可以在描述里写禁止 DROP、DELETE但模型在复杂对话里完全可能被诱导绕过或理解偏差。白名单方法才是正解把所有允许 AI 执行的查询预先写成固定方法每个方法只接收几个参数SQL 完全由开发者控制。举个例子如果业务方需要查询最近 N 天的订单量工具就设计成getOrderCountSince(int days)SQL 写死在代码里。模型传进来的只有一个数字它没有机会拼出DELETE FROM orders这种语句因为整个 SQL 文本根本不在模型的控制范围内。4.3 参数校验、LIMIT、超时与并发熔断方法白名单能挡住 SQL 层面的失控但查询参数引发的问题还需要单独处理。参数必须做格式校验。订单号就用正则^[A-Za-z0-9]{6,32}$日期就用指定格式解析非法输入直接拒绝。这能防住两类问题一是超长输入拖慢查询二是特殊字符绕过占位符场景下的隐患。查询结果必须强制 LIMIT。哪怕业务上认为只会查一条我也会在 SQL 里显式加LIMIT 1或者LIMIT 100。模型不会主动说自己要全表扫但它可能出于对话上下文的理解反复调同一个工具把结果集撑大。查询超时要设置。在查询方法上可以加Transactional(readOnly true, timeout 5)让 Spring 帮我们控制执行时间。如果是复杂统计我还会在 JdbcTemplate 层再设一个 statement 超时双保险。并发熔断很有必要。AI 应用调用工具不像是人点按钮可能在一个瞬间并发发起多个查询。我用一个简单信号量控制同时执行查询的数量import java.util.concurrent.Semaphore; public class QueryRateLimiter { private static final Semaphore SEMAPHORE new Semaphore(4); public boolean tryAcquire() { return SEMAPHORE.tryAcquire(); } public void release() { SEMAPHORE.release(); } }拿不到信号量就直接返回系统繁忙请稍后重试避免一个异常查询把数据库连接打满。4.4 数据脱敏与审计日志工具方法返回的是真实业务数据这本身没问题但敏感字段要在返回前处理。比如手机号中间四位打码、邮箱前缀打码、身份证号直接不返回。这一步通常在 Service 层做写一个脱敏工具类统一处理。审计日志也要设计好。每个工具调用记录谁调的、传了什么参数、返回了几行、耗时多少。日志是用来出事之后追溯的没有审计的 AI 数据访问就像没有监控的机房出了问题只能靠猜。但注意日志里不要打印完整参数值和完整返回体脱敏后的再打。5. 实测中的意外stdio 模式下容易翻车的几个点5.1 日志打到标准输出协议直接崩跑第一个 stdio 模式版本时MCP 客户端连接后工具列表一直拉不到没有报错也没有超时。排查了很久才发现是日志惹的祸logback 默认的 ConsoleAppender 会把日志打到 System.out而 stdio 模式下 MCP 协议消息也走 stdout两者混在一起客户端解析 JSON-RPC 消息直接失败。这个问题非常隐蔽因为应用本身启动正常没有任何异常抛出来。解决方案是给 stdio 模式单独准备一份日志配置取消 ConsoleAppender或者把 Appender 的 target 改成 System.err只让日志走 stderr。实际调试时再用文件日志辅助查看。5.2 LocalDateTime 返回成一串数组第一次用工具查订单时间字段时发现返回 JSON 里的时间变成了[2025, 1, 10, 10, 35, 0]这种数组结构模型完全没法直接读。原因是 Jackson 没有正确序列化 Java 8 时间类型。我最终的解决方法是统一注册 JavaTimeModule并指定时间格式。但更省事的方案是查询 SQL 里面直接格式化比如SELECT DATE_FORMAT(created_at, %Y-%m-%d %H:%i:%s) AS created_at FROM orders把类型转换问题丢给 MySQLJava 层拿到的就是 String简单可靠。这里也提醒一下返回给 MCP 客户端的内容最终是 JSON尽量返回基本类型、字符串尽量减少 JVM 数据类型带来的序列化不确定性。5.3 大文本字段撑爆上下文商品备注这类 TEXT 字段单条数据可能好几 KB。当工具返回包含大文本字段时大模型上下文会被瞬间撑爆而且这些文本对回答用户问题往往没帮助。解决办法是查询时先用LEFT(remark, 200)截断需要看完整备注再额外提供一个专门的查询完整备注工具。这个模式我后来沿用到 BLOB 字段、长日志字段目标只有一个工具默认返回的字段必须精炼。5.4 调试工具与验证链路在实际对接中我建议先用一个支持 MCP 协议的调试器来验证服务端而不是一上来就接业务客户端。像我常用的方式就是把 Spring Boot 应用以 stdio 方式启动然后让调试工具作为父进程拉起它检查 tools/list 是否正常、调用工具是否能拿到预期结果。验证链路里最容易出问题的环节反倒不是协议本身而是环境依赖。比如 MySQL 连接池启动失败、账号权限不对导致查询报错。这类问题在 MCP 服务里被包装成了调用工具返回错误信息没有使用经验的开发者容易误判成协议问题。因此我建议先直接跑一个单元测试确认 JdbcTemplate 能正常查到数据再去看 MCP 暴露是否正确分层排错会轻松很多。6. 再进一步从只读查询到可写操作的路怎么走6.1 写操作的边界设计当需求从查数据延伸到改数据时比如让 AI 助手直接更新订单状态安全设计就需要重新审视。我个人的建议是写操作默认不暴露即使业务上确实需要也要单独设计工具不走通用查询通道。真要做写操作至少要有三样东西独立的账号权限、事务包裹、影响行数限制。比如更新订单状态的方法SQL 固定为按订单号更新到指定状态并且限制影响行数为 1Tool(description 按订单号更新订单状态仅允许更新为已确认或已取消) public int updateOrderStatus(String orderNo, String status) { if (!Arrays.asList(CONFIRMED, CANCELLED).contains(status)) { throw new IllegalArgumentException(不允许的状态值); } String sql UPDATE orders SET status ? WHERE order_no ? LIMIT 1; return jdbcTemplate.update(sql, status, orderNo); }更敏感的操作还可以加人工审批位工具发起后先把变更写入待审批表等管理员确认后再真正执行。这一步把 AI 的自主权收回到人手里可以有效防止误操作。6.2 固定方法白名单和 Text-to-SQL 该如何取舍固定方法解决问题的方式简单可靠但如果业务问题特别碎片化比如领导问本周华东区退货率最高的三类商品分别是什么固定方法就很难提前覆盖。这时候可以考虑 Text-to-SQL 方案模型生成 SQL服务端在JdbcTemplate执行前用 SQL 解析器做校验。校验规则必须有几条底线只允许单条 SELECT 语句必须以 SELECT 开头必须包含 LIMIT关键字黑名单里放 DELETE、UPDATE、DROP、ALTER、INTO、UNION 等表名必须匹配白名单。每次执行前做一层严格过滤危险操作直接拒绝。不过我的经验是固定方法优先Text-to-SQL 只作为补充。先把常用查询做成固定方法覆盖 80% 的问题实在覆盖不了的再开放一个受控的 SQL 解释工具并且给它单独的账户和更短的超时时间。6.3 后续扩展方向这套 MCP 服务的后续演进可以考虑三个方向。第一个是数据源路由。如果系统里有多套 MySQL甚至混有 PostgreSQL可以给工具名称加前缀区分数据源或者基于路由上下文自动切换。第二个是增加预聚合层和指标层。很多 AI 查询实际上是在查统计指标与其让查询工具直接扫大表不如提前把常用指标放到汇总表或者物化视图里查询速度快几个数量级数据库压力也小很多。第三个是和检索增强结合。MySQL MCP 服务解决的是精确查询问题知识库向量检索解决的是模糊匹配问题。把两者都暴露成工具AI 应用就可以根据问题自动选择走哪条路这也是目前比较完整的落地形态。最后说一个我自己的体会这套服务能不能跑得稳关键不在协议而在你愿不愿意把工具边界收敛住。每次想多暴露一个通用查询入口的时候多想想这个入口被误用之后的成本。我自己最初也嫌白名单方法写着麻烦但线上跑到现在没有出过一次数据事故回头来看还是很值。希望这篇实战记录能帮你在搭 SpringAI-MySQLMcp服务时少走几步弯路。