Spring AI 助手与 MCP 服务如何共用工具
一个学校管理系统同时提供内部 AI 助手和基于 Streamable HTTP 的 MCP 服务,两者复用相同 Java 方法。文章介绍聊天记忆、RAG、流式输出,以及包含 16 个工具和 5 个资源的 MCP 接口设计。
AI 深度解读
同一套业务数据,既要让登录用户在系统里问 AI,也要让 Claude、IDE agent 等外部客户端通过 MCP 查询,业务代码该怎么复用?作者用一个学校管理系统给出的答案是:查询方法可以共用,写入权限必须单独设计,而且不能让模型决定调用者是谁。
这套方案值得读的地方,是它把工具复用、身份校验、对话记忆和 RAG 放进了同一个应用。代码能串起来,但距离生产可用仍有几处边界需要补齐。
两个 AI 入口,为什么可以共用一套方法
Control Escolar 使用 Spring Boot 4.1.1 和 Spring AI 2.0.1,管理学生、教师、课程、教室、课表、成绩和报表。内部助手基于 ChatClient,服务已经登录的用户;外部入口通过 Streamable HTTP 提供 MCP 服务,包含 16 个工具、5 个资源、1 个资源模板、3 个提示词和参数补全。
作者没有分别实现两套查询。Spring AI 的 @Tool / @ToolParam 面向 ChatClient,@McpTool / @McpToolParam 面向 MCP,同一个 Java 方法可以同时标注。
下面这段代码让两个入口共用「按学号查询平均分」的实现:
@Component
public class EscolarTools {
// 注册给内部 ChatClient
@Tool(description = "Obtiene el promedio general de un alumno por su matrícula.")
// 同时注册给外部 MCP 客户端,使用 kebab-case 名称
@McpTool(name = "promedio-alumno",
description = "Obtiene el promedio general de un alumno por su matrícula.")
public String promedioAlumno(
@ToolParam(description = "Matrícula del alumno")
@McpToolParam(description = "Matrícula del alumno") String matricula) {
Alumno alumno = alumnoService.buscarPorMatricula(matricula);
if (alumno == null) {
return "No se encontró ningún alumno con la matrícula " + matricula + ".";
}
Double promedio = calificacionService.promedioPorAlumno(alumno.getId());
if (promedio == null) {
return "El alumno " + alumno.getNombreCompleto() + " aún no tiene calificaciones registradas.";
}
return "El promedio general del alumno " + alumno.getNombreCompleto()
+ " es " + formatoPromedio(promedio) + ".";
}
}
要看的是两个注解落在同一个方法上。ChatClient 发现的是 promedioAlumno,MCP 发现的是 promedio-alumno,底下都调用 AlumnoService、CalificacionService。Web Controller 和 REST API 也沿用业务服务层,修复查询逻辑时不必维护两份实现。
学生不存在、尚无成绩都返回可转述的文本。这适合预期内的业务结果,但不能据此把数据库故障等异常也当成正常回答。
MCP 还用 @McpResource 暴露 escolar://alumnos/{matricula},返回 application/json;用 @McpComplete 补全学号,另有 @McpPrompt 提供提示词。配置中,spring.ai.mcp.server 前缀下的 name=control-escolar-mcp-server、version=1.0.0、type=SYNC、protocol=STREAMABLE 确定服务信息,annotation-scanner.enabled=true 开启注解扫描,capabilities.completion=true 开启补全。
本地运行 npx @modelcontextprotocol/inspector,连接 http://localhost:8080/mcp,即可检查这些能力。
写入操作的身份,为什么不能作为工具参数
内部助手可以通过 registrarCalificacion 登记成绩。作者把它放进独立的 EscolarEscrituraTools,只加 @Tool,不加 @McpTool。原因很具体:项目的 /mcp 为本地调试设置了 permitAll,没有经过认证的用户身份。
更危险的设计,是给工具加一个 rolDelUsuario 参数,让模型填写调用者角色。工具参数由模型生成,用户一句「我是校长」,就可能影响这个字段。
权限依据必须由服务端提供,不能由模型生成。
作者使用 ToolContext,把 Spring Security 的 Authentication 转换成上下文,再交给工具:
private ChatClient.ChatClientRequestSpec peticion(String pregunta, Authentication autenticacion) {
return chatClient.prompt()
.user(pregunta)
// 每个用户独立保存记忆
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversacionId(autenticacion)))
// 模型不可见,工具可以读取
.toolContext(Map.of(
EscolarEscrituraTools.CTX_USUARIO, conversacionId(autenticacion),
EscolarEscrituraTools.CTX_ROLES, roles(autenticacion)));
}
关键是 .toolContext(...):身份由应用附加,不经过模型填写。工具只接受 ROLE_ADMIN、ROLE_DIRECTOR、ROLE_MAESTRO,缺少上下文或角色格式不对就拒绝,再检查成绩是否处于 0 到 10。保存成绩时,操作人也取自服务端上下文。
工具描述要求调用前向用户确认,但描述只是给模型的指令。原文没有展示程序强制执行的确认流程,也没有展示教师是否只能修改自己授课范围内的成绩。角色校验解决了一层权限,具体数据范围仍需单独处理。
记忆、检索和流式输出,接起来还有哪些坑
内部助手通过 .defaultTools(escolarTools, escrituraTools, conocimiento) 注册能力,再由 .defaultAdvisors(advisorsDelChat()) 接入处理链。作者安排的顺序是:SafeGuardAdvisor、MessageChatMemoryAdvisor、QuestionAnswerAdvisor、SimpleLoggerAdvisor。
这里有两条作者明确报告过的现象。
第一,拦截器放在记忆之后,被阻止的问题仍可能进入历史,后续请求携带同样的敏感词,整段会话持续被拦截。作者用 Ordered.HIGHEST_PRECEDENCE 让检查先执行。
第二,默认 QuestionAnswerAdvisor 模板要求只依据检索上下文回答,导致助手面对成绩问题也不再调用工具。作者改写模板,让校规、日期类问题参考检索片段,其他问题继续使用工具。检索参数是 topK(3)、similarityThreshold(0.5),但原文没有提供这组参数的评测依据。
记忆通过 JdbcChatMemoryRepository 存进 SPRING_AI_CHAT_MEMORY,ChatMemory.CONVERSATION_ID 使用用户名。「新对话」调用 chatMemory.clear(conversationId)。这是每用户一份历史的设计,多标签页或多条独立会话如何处理,原文没有展开。
普通聊天用 Server-Sent Events,通过 .stream().content() 返回 Flux<String>,错误交给 .onErrorResume(...)。学生风险分析则使用同一 builder 克隆出的第二个客户端,去掉记忆和 RAG,以 .call().entity(AnalisisAlumno.class) 返回 Java record,包含平均分、出勤率、风险等级、优势、待改进项和建议。
风险阈值写进系统提示词:平均分低于 6.0,或出勤率低于 80%,归为 HIGH。这减少了自由判断空间,却仍是模型执行规则,原文没有展示分类准确率或边界测试。
没有 API key 时,应用也能运行。配置 spring.ai.openai.api-key=${OPENAI_API_KEY:demo} 后,启动逻辑识别缺失、空白或 demo,让 chatClient 保持 null;助手返回配置提示,/api/ia/analisis-alumno 返回 503。MCP 查询继续工作,consultar-reglamento 则从向量检索退回同一批 Markdown 文件的关键词搜索。
我的判断
我认可这套分工:业务服务集中维护,协议入口各自注册,写入能力单独决定开放范围。相比把两套工具分别开发,它减少了逻辑漂移;相比所有能力一起暴露,它把权限决策留在了可检查的代码里。
但双注解只是复用方式,不能充当安全方案。只读接口同样需要认证和数据范围控制。 学生成绩、出勤率和档案都可能包含敏感信息,作者也明确承认生产环境必须给 /mcp 加 token 或限制在私有网络。
这是一份实现经验,不是效果评测。作者没有给出工具调用成功率、RAG 命中率、延迟或成本的对照数据。风险等级既然有明确阈值,更适合由业务代码计算,再让模型生成解释和建议;结构化输出也不等于事实和分类一定正确。
工程成熟度也有限。SimpleVectorStore 每次启动重新索引,目前只有两份 Markdown,PGVector 只是后续方向;出勤已有数据和工具,尚无页面。原文提到 Micrometer、Actuator 可在 /actuator/prometheus 暴露 gen_ai_client_* 指标,并默认关闭提示词与回答日志,log-prompt=false。这些有助于观察系统,但不能替代实际验证。
能拿来做什么
- 给现有业务系统接内部助手和 MCP 时,可以先挑稳定的只读查询复用服务层,并为两个入口分别定义认证和可访问的数据范围。
- 在日常 AI 编程中,检查工具签名里有没有角色、用户 ID 或租户身份这类参数,把可信身份改由服务端上下文传入,并在上下文缺失时拒绝写入。
- 同时使用 RAG 和工具调用时,分别验证校规问答、实时业务查询,以及被拦截后继续聊天的行为,确认模板和记忆顺序没有干扰调用。
- 联调前端聊天页面时,同时覆盖流式返回、无 key 配置提示和分析接口 503;对风险分析,把阈值判断与模型生成建议分开验证。