系列:Spring AI 入门实战——从第一次对话到数仓查询助手
本篇目标:让 MCP 服务连接示例订单表,手动跑通“找表—看字段—查金额”。
技术基线:Java 17、Spring Boot 4.1.0、Spring AI 2.0.1,H2 版本由 Boot 管理。
工程承接:修改第 4 篇warehouse-mcp-server,仍使用端口8081,无需模型 Key。
1. 小周要的不是表名,是金额
小林的 MCP 服务已经能找到订单表。小周看了一眼结果:“demo_orders,挺好。然后呢?”
然后,不能直接猜字段。
一张订单表里可能同时有下单日期、支付日期、发货日期;金额也可能分原价、实付、退款。如果只看到“订单表”三个字就开始查询,结果很容易看起来像那么回事,口径却完全错了。
所以我们把查数拆成三个动作:
table_search → 哪张表可能有我要的数据? table_describe → 字段和业务口径是什么? table_query → 按确认后的条件执行查询。这条链路借鉴了实际数仓工具服务的设计。教学版只保留一张表、一个指标和一种分组,不接入公司的内部服务,也不展开 JOIN、复杂过滤和导出。
今天先继续手动调用工具。模型下篇再回来上班。
2. 把业务口径写在前面
本篇统计规则固定如下:
| 项目 | 本系列约定 |
|---|---|
| 日期 | pay_date,支付日期 |
| 金额 | pay_amount,单位为元,使用SUM |
| 状态 | 仅统计PAID和FINISHED |
| 范围 | 一个完整自然月,开始日包含、结束边界不包含 |
| 分组 | 按支付日期升序,最多 31 行 |
| 无订单日期 | 不自动补零,只返回有符合条件订单的日期 |
| 退款 | 暂不扣减,不把支付金额称为净收入 |
这样做不是为了增加规定,而是为了让同一句问题有一个可以核对的答案。否则你和小周可能都在说“金额”,脑子里却想的是两种东西。
3. 用 H2 准备一间迷你仓库
H2 可以在应用内启动,适合这个不想额外安装数据库的练习。本例使用内存数据库,每次应用进程重启都会重新装入种子数据,不保存上一次运行中的修改。
在第 4 篇pom.xml的<dependencies>内新增:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency><dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>将application.yml替换为下面的完整内容。这里把数据库配置和原来的 MCP 配置放在同一个spring节点下,别复制出两个顶层spring:
server:address:127.0.0.1port:8081spring:application:name:warehouse-mcp-serverai:mcp:server:name:warehouse-demoversion:1.0.0type:SYNCprotocol:STATELESSstateless:mcp-endpoint:/mcpdatasource:url:jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1username:sapassword:""sql:init:mode:always在src/main/resources/新建schema.sql:
CREATETABLEdemo_orders(order_idBIGINTPRIMARYKEY,pay_dateDATENOTNULL,order_statusVARCHAR(20)NOTNULL,pay_amountDECIMAL(12,2)NOTNULL);再新建data.sql:
INSERTINTOdemo_orders(order_id,pay_date,order_status,pay_amount)VALUES(1,DATE'2026-08-01','PAID',100.50),(2,DATE'2026-08-01','FINISHED',49.50),(3,DATE'2026-08-02','PAID',200.00),(4,DATE'2026-08-02','FINISHED',80.00),(5,DATE'2026-08-03','PAID',39.90),(6,DATE'2026-08-03','CANCELLED',999.00),(7,DATE'2026-08-31','FINISHED',300.10),(8,DATE'2026-07-31','PAID',88.00),(9,DATE'2026-09-01','PAID',500.00),(10,DATE'2026-08-02','PENDING',50.00);这十条记录里故意混入了取消订单、待支付订单、上个月和下个月的数据。它们像考试里的干扰项,用来检查筛选条件是不是真的起作用。
8 月的正确结果应该是四行,合计770.00 元。这个数来自固定种子数据的计算,不依赖模型怎么说话。
4. 把三个工具放进一个清楚的入口
下面是完整的WarehouseTools.java,用于替换第 4 篇的同名文件,不要额外保留一个同名工具类。
这份代码稍长,但可以分三段看:前面的 record 定义返回结构,中间三个注解方法对应三个工具,最后一个小方法负责精确匹配允许的字段。
packagecom.example.warehouse;importjava.math.BigDecimal;importjava.sql.Date;importjava.time.LocalDate;importjava.util.List;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.ai.mcp.annotation.McpTool;importorg.springframework.ai.mcp.annotation.McpToolParam;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.stereotype.Component;/** 单表、固定口径的查数工具;SQL 结构始终由 Java 控制。 */@ComponentpublicclassWarehouseTools{privatestaticfinalLoggerlog=LoggerFactory.getLogger(WarehouseTools.class);privatefinalJdbcTemplatejdbc;publicWarehouseTools(JdbcTemplatejdbc){this.jdbc=jdbc;}publicrecordTableBrief(Stringtable,Stringdescription){}publicrecordFieldInfo(Stringname,Stringtype,Stringdescription){}publicrecordDescription(Stringtable,List<FieldInfo>fields,Stringrules){}publicrecordDailyAmount(LocalDatedate,BigDecimalamount){}publicrecordQueryResult(Stringsource,StringstartDate,StringendExclusive,List<String>statuses,Stringunit,booleancomplete,List<DailyAmount>rows,BigDecimaltotal){}@McpTool(name="table_search",description="按业务关键词寻找订单示例表,返回候选表名和说明")publicList<TableBrief>search(@McpToolParam(description="业务关键词,例如订单或支付",required=true)Stringkeyword){if(keyword==null||keyword.isBlank()||keyword.length()>50){thrownewIllegalArgumentException("关键词长度应为 1 到 50 个字符");}log.info("table_search keyword={}",keyword);if(keyword.contains("订单")||keyword.contains("支付")||keyword.contains("金额")){returnList.of(newTableBrief("demo_orders","订单支付明细;支持按支付日期统计支付金额"));}returnList.of();}@McpTool(name="table_describe",description="查看候选表的字段与统计口径;查数前应先查看")publicDescriptiondescribe(@McpToolParam(description="搜索返回的表名",required=true)Stringtable){requireEqual(table,"demo_orders","表名");log.info("table_describe table={}",table);returnnewDescription(table,List.of(newFieldInfo("pay_date","DATE","支付日期,用于日期过滤及按天分组"),newFieldInfo("pay_amount","DECIMAL(12,2)","支付金额,单位元,聚合方式为 SUM"),newFieldInfo("order_status","VARCHAR","服务端固定计入 PAID、FINISHED")),"只查一个完整自然月,左闭右开;dateField 和 groupBy 均为 pay_date;"+"amountField 为 pay_amount;只返回有符合条件订单的日期,不补零;"+"最多 31 行,按日期升序;暂不扣减退款,数据仅供教学。");}@McpTool(name="table_query",description=""" 查询一个自然月的每日支付金额。先通过 table_describe 确认字段。 服务端固定 SUM 聚合与 PAID、FINISHED 状态,禁止传入 SQL。 返回真实执行示例数据库所得的结果,不能称为生产数据。 """)publicQueryResultquery(@McpToolParam(description="表名,来自搜索结果",required=true)Stringtable,@McpToolParam(description="日期字段,来自字段说明",required=true)StringdateField,@McpToolParam(description="金额字段,来自字段说明",required=true)StringamountField,@McpToolParam(description="按天分组的字段",required=true)StringgroupBy,@McpToolParam(description="月份第一天,yyyy-MM-dd,包含",required=true)StringstartDate,@McpToolParam(description="下个月第一天,yyyy-MM-dd,不包含",required=true)StringendExclusive){requireEqual(table,"demo_orders","表名");requireEqual(dateField,"pay_date","日期字段");requireEqual(amountField,"pay_amount","金额字段");requireEqual(groupBy,"pay_date","分组字段");LocalDatestart=LocalDate.parse(startDate);LocalDateend=LocalDate.parse(endExclusive);if(start.getDayOfMonth()!=1||!end.equals(start.plusMonths(1))){thrownewIllegalArgumentException("只允许查询一个完整自然月");}Stringsql=""" SELECT pay_date, SUM(pay_amount) AS total_amount FROM demo_orders WHERE pay_date >= ? AND pay_date < ? AND order_status IN (?, ?) GROUP BY pay_date ORDER BY pay_date ASC LIMIT 32 """;List<DailyAmount>rows=jdbc.query(sql,(result,rowNumber)->newDailyAmount(result.getDate("pay_date").toLocalDate(),result.getBigDecimal("total_amount")),Date.valueOf(start),Date.valueOf(end),"PAID","FINISHED");if(rows.size()>31){thrownewIllegalStateException("结果超过一个月的日期数,不能返回不完整结果");}BigDecimaltotal=rows.stream().map(DailyAmount::amount).reduce(newBigDecimal("0.00"),BigDecimal::add);log.info("table_query table={}, start={}, endExclusive={}, rows={}",table,start,end,rows.size());returnnewQueryResult("H2_DEMO",startDate,endExclusive,List.of("PAID","FINISHED"),"CNY",true,rows,total);}privatestaticvoidrequireEqual(Stringactual,Stringallowed,Stringfield){if(!allowed.equals(actual)){thrownewIllegalArgumentException(field+"不受支持");}}}table_describe在这里直接返回静态元数据。只有一张示例表,没有必要为找字段再搭一套元数据平台;以后接入真实数仓,再把这一部分替换成受控的元数据查询即可。
金额计算由数据库完成,月份总额由 Java 用BigDecimal对每天的结果求和。最终模型读取total,无需重新当计算器。
为什么既校验字段,又使用固定 SQL
工具接收dateField、amountField、groupBy,是为了让客户端先理解元数据,再提出查询请求。服务端只允许它们对应本例中的固定字段。
SQL 本身是固定模板,用户输入没有被拼接到 SQL 中。日期、状态通过 JDBC 参数绑定传入。
这里要记住一个常见边界:?用于绑定值,不能拿它绑定表名、列名。以后若需要支持多个字段,应先将输入映射到允许的标识符;不能直接把模型传来的字符串接到 SQL 后面。
本例把范围收得很小,正好让这件事容易检查。
为什么 SQL 写LIMIT 32,却说最多 31 行
因为一个完整自然月最多 31 天。我们额外读取一行作为防御检查;如果出现第 32 行,就报错,不返回一个看似完整的月份结果。
正常情况下,DATE字段按天分组加上一个月范围,最多就是 31 行。本例因此可以在检查后返回complete=true。
它也避免了一个很隐蔽的问题:只返回前 10 天,最后却让模型总结整个 8 月。预览行数与完整查询结果,必须分清楚。
5. 手动跑通整条链路
重启服务端,沿用第 4 篇的初始化与协议版本设置步骤。重新执行tools/list,现在应看到三个工具。
第一步,搜索订单表:
curl-sS-XPOST http://127.0.0.1:8081/mcp\-H'Content-Type: application/json'\-H'Accept: application/json, text/event-stream'\-H"MCP-Protocol-Version:$MCP_PROTOCOL_VERSION"\-d'{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"table_search","arguments":{"keyword":"订单支付"}}}'第二步,使用搜索返回的表名查看字段:
curl-sS-XPOST http://127.0.0.1:8081/mcp\-H'Content-Type: application/json'\-H'Accept: application/json, text/event-stream'\-H"MCP-Protocol-Version:$MCP_PROTOCOL_VERSION"\-d'{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"table_describe","arguments":{"table":"demo_orders"}}}'检查描述中的字段和规则,确认日期字段、金额字段与状态口径。
第三步,提交结构化参数:
curl-sS-XPOST http://127.0.0.1:8081/mcp\-H'Content-Type: application/json'\-H'Accept: application/json, text/event-stream'\-H"MCP-Protocol-Version:$MCP_PROTOCOL_VERSION"\-d'{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"table_query","arguments":{"table":"demo_orders","dateField":"pay_date","amountField":"pay_amount","groupBy":"pay_date","startDate":"2026-08-01","endExclusive":"2026-09-01"}}}'解开 MCP 外层包装后,业务结果应为:
{"source":"H2_DEMO","startDate":"2026-08-01","endExclusive":"2026-09-01","statuses":["PAID","FINISHED"],"unit":"CNY","complete":true,"rows":[{"date":"2026-08-01","amount":150.00},{"date":"2026-08-02","amount":280.00},{"date":"2026-08-03","amount":39.90},{"date":"2026-08-31","amount":300.10}],"total":770.00}JSON 的数字显示可能省略末尾零,例如150.0与150.00数值相同;展示金额时再统一格式为两位小数。
核对计算也不复杂:8 月 1 日是100.50 + 49.50,8 月 2 日是200.00 + 80.00,再加39.90和300.10,得到770.00。取消和待支付记录,以及月边界外的记录,都不应该混进来。
6. 别只走一遍成功路线
保持其他参数不变,做下面几次小实验:
| 修改 | 应有的结果 |
|---|---|
amountField改为refund_amount | 拒绝:金额字段不受支持 |
表名改成demo_orders; DROP TABLE demo_orders | 拒绝:表名不受支持,不执行该字符串 |
结束日期改为2026-10-01 | 拒绝:不允许一次查询两个月 |
日期改为2026-06-01至2026-07-01 | 成功查询但rows=[]、total=0.00 |
工具执行失败时,要检查 MCP 的错误内容或isError标记,不要只看 HTTP 是否为 200。协议请求被成功处理,不代表业务查询成功。MCP 工具结果与错误约定
“没有符合条件的数据”和“根本没查成功”尤其不能混为一谈。前者是一次成功查询的结果,后者没有资格给出金额结论。
7. 常见问题:数据怎么消失了,日期怎么不全
“重启后为什么数据又回到了初始值?”
因为使用的是内存 H2。这是教学环境的预期行为,方便每个人得到相同结果。schema.sql与data.sql会在新进程中重新初始化。
“既然查每天,为什么只有四天?”
因为约定只展示有符合条件订单的日期。需要 31 天都出现,就要明确增加补零规则;不能让模型临时编出 27 行零。如果以后接真实数据,还要区分“确实没有订单”和“数据尚未到齐”。
“这是不是完整的生产数仓查询服务?”
它是教学版:本地示例数据库、一张表、固定口径、固定 SQL。真实业务里的用户权限、敏感字段、查询超时和审计,需要结合现有系统继续实现,本文不把这些能力假装成已经具备。
8. 现在,只差让助手自己点菜了
小林把770.00和原始数据逐条对了一遍。小周也终于看到了能追溯来源的数字。
“挺好,”小周说,“就是这几条 curl,我不太想每天复制。”
下一篇,我们把 Spring AI MCP 客户端接回来。用户只提出问题,助手负责发现工具、查看字段、发起查询,再把结果说明白。
上一篇:《Spring AI 入门(四):认识 MCP,编写第一个工具服务》
下一篇:《Spring AI 入门(六):接入 MCP 客户端,完成一个自然语言查数助手》