系列:Spring AI + MCP------大数据智能运维实战
本篇目标:用通用教学方案给查询工具加上访问约束、范围限制、超时、审计和明确的错误语义。
阅读基础:第七至十篇;沿用
lab-07-12,Java 17、Spring Boot 4.1.0、Spring AI 2.0.1。案例说明:全部对象和数据为虚构;本机令牌与固定角色用于教学,不是生产身份系统的替代品。
1. 只读工具,也不能想查多少就查多少
"我们又不重启机器、不改任务,只查数据,还需要做这么多限制吗?"小周问。
小林把两个请求摆出来:一个查某次运行 35 分钟内的日志;另一个想查所有节点一个月的日志。
它们都叫只读查询,对系统的影响却完全不同。
还有权限问题。能查看任务状态的人,不一定能读取日志;能访问青禾教学范围的人,也不因此获得其他范围的数据。
前面几篇已经把工具的输入和返回讲清楚。本篇把每一次查询真正经过的服务端边界展开:先确认谁在查、允许查什么,再决定是否执行、执行多久,以及失败以后怎么解释。
2. 身份来自已经验证的请求,不来自模型填写的参数
一个容易踩坑的设计是:
json
{
"userId": "admin",
"role": "operator",
"runId": "run-current-0917"
}
如果服务端相信模型提供的 role,权限判断就变成了"调用者说自己是谁,就算谁"。
教学工程换一种做法:启动时从环境读取两组不同的随机令牌,入口验证 Bearer 值,再映射到两个固定身份。映射结果由服务端写入请求上下文,工具参数中没有用户或角色字段。
| 教学身份 | 运行、关联、指标 | 日志 |
|---|---|---|
| ops-reader | 可读 | 可读 |
| viewer | 可读 | 不可读 |
两个身份都只访问固定的 qinghe-demo 样本。未知对象统一返回"对象不存在或不可访问",不会为了验证一个猜测的标识而扫描其他环境。
在无状态 HTTP 教学传输中,工具通过 McpTransportContext 获取入口已经建立的身份。Spring AI 对注解工具与上下文参数提供支持,具体支持范围与传输类型有关。Spring AI MCP 注解服务端文档
本实验默认只监听 127.0.0.1,只演示本机 CLI 调用,带浏览器 Origin 的请求会被拒绝。生产环境的令牌签发、有效期、撤销、受众校验、TLS 和网关集成需要另行实现,不能把这两组环境令牌直接部署为公共服务的完整认证方案。
3. 每个工具都走同一道边界

图 1:模型提供业务查询参数;身份、权限和执行预算由服务端掌握。
五个工具都通过 QueryBoundary.execute 执行。顺序是:
text
入口认证
→ 工具权限与调用额度
→ 对象、参数和范围校验
→ 在受限工作池中执行查询
→ 返回结构化结果
→ 记录工具调用审计
这里有两层权限需要区分。
一层是"这个身份能不能调用日志工具"。viewer 即使拿到别人返回的日志游标,也先被日志权限检查拒绝。
另一层是"这个对象是否属于允许访问的范围"。本实验只有一个固定范围,用内存白名单演示;真实适配器必须在查询数据源时带上服务端确定的范围条件,不能先把全平台数据查回来,再交给模型过滤。
readOnlyHint=true 只是向客户端描述工具属性,并不会自动完成鉴权或阻止错误查询。MCP 工具规范也要求服务端落实输入验证、访问控制等约束。MCP 工具规范
这些规则不能只写在系统提示词里。模型没有遵守提示时,服务端仍然要拒绝越界请求。
4. 限制要落到可执行的数字上
教学工程采用一组容易复现的参数:
| 约束 | 本实验值 | 解决什么问题 |
|---|---|---|
| 单次日志或指标窗口 | 最多 60 分钟 | 避免随意扩大查询范围 |
| 每页日志数量 | 1---100 条 | 限制单次返回体量 |
| 单条日志消息 | 最多 160 个字符 | 避免长消息占满上下文,并明确截断 |
| 每身份工具额度 | 每自然分钟 60 次 | 限制持续调用 |
| 查询工作池 | 4 个工作线程,8 个排队位置 | 限制同时占用资源的查询数 |
| 单次工具等待上限 | 500 毫秒,包含排队等待 | 及时返回明确超时 |
| 日志游标 | 120 秒,最多保留 256 项 | 限制续查状态占用 |
这些数字是教学配置,不是生产容量建议。真实值要根据数据源响应、用户需求和容量评估确定。
每自然分钟 60 次采用固定窗口计数,分钟边界附近仍可能出现短时突发;生产需要更平滑的限制时,可以使用合适的令牌桶或滑动窗口实现。多实例部署也不能各自在内存里计数,却声称有一个全局额度。
工作池和超时是互补的。超时让调用方及时得到响应,但后台查询可能不理会中断。本实验的慢源使用可中断等待,所以取消能够结束它;实际数据库、HTTP 客户端或日志检索服务还要配置自己的超时和取消机制。
因此不能说"Future 取消了,底层所有资源就一定释放了"。受限线程池只是把影响限制在明确范围,不能替代下游取消能力。
5. 查不到、没权限、查询失败,要让助手看得出区别

图 2:数据缺失是一种查询结果;执行失败则意味着没有得到可据此判断的完整结果。
本实验在工具成功执行时返回统一外层:
json
{
"status": "OK",
"requestId": "本次查询标识",
"observedAt": "2026-09-17T09:40+08:00",
"snapshotId": "teaching-20260917-v1",
"data": {"availability": "NO_DATA", "points": []}
}
这是字段示意。真实 requestId 每次生成,文章不要求它固定不变。
这里的 NO_DATA 表示查询成功,但这个教学对象没有相应指标样本。MCP 层 isError=false。它不表示指标为 0,更不表示系统健康。
如果查询超时,返回形态改为:
json
{
"status": "ERROR",
"requestId": "本次查询标识",
"observedAt": "2026-09-17T09:40+08:00",
"snapshotId": "teaching-20260917-v1",
"error": {
"code": "TIMEOUT",
"message": "数据源查询超时,未获得完整结果",
"retryable": true
}
}
此时 MCP 层 isError=true。HTTP 请求完成,也不意味着工具执行成功,客户端必须检查 CallToolResult。
工程通过 CallToolResult.builder() 同时写入结构化结果和相同内容的文本结果,便于不同客户端读取;没有另外宣称生成了业务输出 Schema。输入参数定义与服务端校验仍然保留,输出字段由测试核对。
另外还有两类失败不要混在一起:未通过入口认证的请求直接得到 HTTP 401;无法正常解析或调用不存在的方法等协议问题,由协议层处理。它们不是一条"查询成功、结果为空"的业务返回。
6. retryable=true,也不等于让助手一直重试
可以把错误处理约定写成一张表:
| 状态或错误 | 助手的合理动作 |
|---|---|
| INVALID_ARGUMENT | 检查参数,不原样反复调用 |
| FORBIDDEN、NOT_FOUND_OR_FORBIDDEN | 说明访问边界,不换标识试探 |
| CURSOR_INVALID | 在原范围内重新查询,并处理记录去重 |
| RATE_LIMITED、BUSY | 在总预算内退避;没有预算就停止 |
| TIMEOUT | 明确未取得结果,必要时在预算内重试或缩小范围 |
| NO_DATA | 说明样本缺失,不填 0,不报健康 |
| MISSING、AMBIGUOUS | 保留关联问题,补证据后再继续 |
retryable 只表示这类错误可能通过后续调用恢复,不保证下一次成功,也不授予无限重试权限。第十篇的演示脚本选择遇错停止,便于读者看清发生了什么。
把同一个错误包装成一段自然语言再抛回模型,远不如保留稳定错误码、是否可重试和查询标识。模型可以据此决定怎么解释,程序也能据此实施有限重试。
未知异常只返回通用错误与 requestId,不会把堆栈、连接信息或原始响应直接塞进助手上下文。
7. 审计记录"谁查了什么能力",不再复制一遍敏感内容
工具调用审计保留以下字段:
text
requestId、真实查询时间、调用主体、授权范围、工具名、结果码、耗时
它不记录 Authorization 头、令牌、完整日志内容和原始参数。教学实现把审计写到应用日志,并保留最近 100 条内存记录供测试检查。
注意这里有两种时钟:业务 observedAt 固定为虚构场景的 2026-09-17 09:40,审计 at 是实际发起查询的时间。它们回答的问题不同,不应该伪造成同一个时刻。
当前审计覆盖进入公共工具边界的调用,HTTP 入口直接拒绝的请求不进入这份工具审计。实际部署还需要入口或网关访问审计、受控存储、留存周期和查询权限;内存里的 100 条记录不能当作长期审计系统。
想追到某次失败,先通过 requestId 找到对应调用,再由有权限的人查看服务端诊断信息。没必要为了排查方便,把敏感内容复制到每一层日志里。
8. 用几个反例证明边界真的生效
配套验证不只检查成功查询,还会实际运行这些情况:
- 不带有效令牌:入口返回 HTTP 401。
- viewer 调用日志工具:返回 FORBIDDEN,日志查询不执行。
- 把日志条数改成 101,或查询超过 60 分钟:返回 INVALID_ARGUMENT。
- 用其他范围的标识查询:返回 NOT_FOUND_OR_FORBIDDEN。
- 模拟数据源等待 2 秒:工具在 500 毫秒预算附近返回 TIMEOUT,不能返回空数组。
- 修改续查参数:返回 CURSOR_INVALID。
单元测试还会使用缩小的额度验证限流,检查审计不含查询正文,以及未知异常不会泄漏后端错误细节。
在工程目录执行:
bash
mvn test package
python3 scripts/verify_mcp.py
这些检查能证明教学实现的特定行为,不代表它已经完成生产安全评估。下一篇会把它们放进更完整的验收矩阵:除了工具能执行,还要检查助手选得对不对、参数是否越界、证据有没有被误读。