☰
SpringAI+MySQL:基于MCP协议的安全数据访问服务实战
2026/10/13 3:43:54 网站建设 项目流程

前阵子有个需求,要在内部系统里挂一个能回答业务问题的大模型助手,结果发现大部分问题都牵扯到真实订单数据。最开始我有两条路:给模型一个直连数据库的账号,或者给它写一堆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 解决的是连接标准化问题

MCP(Model 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-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?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/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 Map<String, 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 List<Map<String, 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

把类型转换问题丢给 MySQL,Java 层拿到的就是 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 固定为按订单号更新到指定状态,并且限制影响行数为 1:

@Tool(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服务时少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询