module/agent/tools 的全部 Agent 工具通过 Spring AI MCP 暴露给外部需求:com.zsjz.ai.module.agent.tools 下的所有 tools 都要能通过 Spring AI MCP 给外部客户端用。
方案(适配而非重写):新增包 com.zsjz.ai.module.agent.mcp,3 个类:
| 文件 | 职责 |
|---|---|
AgentScopeToolCallback |
把 AgentScope 工具包成 Spring AI ToolCallback(ToolDefinition 直通 AgentScope 生成的 JSON Schema) |
AgentScopeMcpToolProvider |
@Component implements ToolCallbackProvider,另建一个独立 Toolkit(与内置 Agent 的实例隔离),注册同一批工具对象 + 5 个业务组全部激活,另附 4 个案件管理工具 |
McpCaseSession |
MCP 会话级「当前案件」绑定(按 McpSyncServerExchange#sessionId()),开案走 CaseDataSourceRegistry.open + CaseDataCache.initCache,不写 sa-token Token-Session(不干扰 Web 端) |
关键结论:
AgentToolRegistry 注册,MCP 侧自动出现;两边 schema 同源不会漂移。@DS("slave"),由 CaseRoutingDataSource 按 CaseContextHolder 路由到 case{caseId}。MCP 无登录态/无请求线程 ⇒ 调用前用 CaseContextHolder.callWith(caseId, null, supplier) 显式包裹。/mcpsse、/mcpstreamable 已加入 SaTokenWebConfig.WHITELIST(MCP 握手不带 x-token)。⚠️ 端点无鉴权,需外层限制来源。spring.ai.mcp.server.type: async ⇒ McpToolUtils.toAsyncToolSpecification 用 Schedulers.boundedElastic() 执行工具,所以在 call 里 .block(timeout) 是安全的。McpToolUtils 的 ToolContext 里带 McpSyncServerExchange(key = McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY),sessionId() 可拿会话 ID。ToolResultBlock → TextBlock 文本直出),与内置 Agent 一致。Json.toStr 会把 Long 序列化成字符串("caseId":"7"),写断言时别按数字写。测试:AgentScopeMcpToolProviderTest(9 例)——全量暴露且无重名、每个工具都能 McpToolUtils.toAsyncToolSpecification(这一步是 schema 兼容性的真正回归点,失败会让 MCP server 起不来)、render_graph 真实调用链路、入参校验错误文案、4 个案件工具与会话绑定、标量列表回归。
GET /js/a/mcpsse → event:endpoint → POST /js/a/mcp/message?sessionId=… 全链路打通:
tools/list = 74 个工具,0 重复,全部 inputSchema.type == object
(64 业务 + 6 基础设施含 search_table_schema + 4 案件管理)render_graph 返回规范化后的图谱 JSON(未被二次转义)list_cases → open_case 5 → execute_sql 查 call_record = 38263 行、
person_basic_info = 7 行;换案件 9 同样查询 = 0 行 ⇒ 案件上下文按 MCP 会话隔离、@DS 路由正确close_case 正常释放SSE 首帧下发的是 data:/mcp/message?sessionId=…(相对路径,不含 context-path),
客户端会拼成 http://host:8980/mcp/message → 404。
spring.ai.mcp.server.base-url: /js/a 修好后变成 /js/a/mcp/message?…。
另注:spring.ai.mcp.server.protocol 默认 SSE,此时 streamable-http.mcp-endpoint 不注册;
端点只接受对应方法(SSE 端点只认 GET,用 POST 探会得到 404,容易误判成「配置没生效」)。
list_person_names 在有人名的案件上必然报错ToolResultTable.toRows 按「元素都是 POJO」处理,对 List<String>(姓名清单)会抛
Cannot construct instance of java.util.LinkedHashMap ... from String value。
空人名时返回空列表所以一直没暴露,MCP 链路上在案件 5(7 个人名)实测到。
ToolResultTable.toRows 改为逐元素处理,标量(String/Number/Boolean/Character)兜底成单列 valuePersonAnalysisTool.listPersonNames 显式包成单列 nameAgentToolRegistryTest 5 例失败,原因是 AgentToolRegistry 里 OTG 组被注释、工具清单未做完
(实际 64 个业务工具,测试按计划中的 77 个断言):registersAllBusinessTools(NPE)、
activatingOneGroupRevealsOnlyThatGroup、metaToolEnumListsAllGroups、
noSchemaLeaksInjectionSurface、numericSpecFieldsBecomeJsonNumbers。
另 4 个测试类(Call/GraphRender/Sql/Track)全绿。
com.zsjz.ai.module.agent.llm)需求:封装一个不经过 Agent、直接调大模型拿结果的 service。
产出:
| 文件 | 职责 |
|---|---|
| LlmService | chat / chatDetail / chatAs / stream,核心是把 Model#stream 的分片拼成文本 |
| LlmRequest | @Builder(toBuilder=true):modelId / system / user / messages / temperature / maxTokens / timeout(默认 60s) |
| LlmResult | record:text / modelId / modelName / inputTokens / outputTokens / elapsedMs / totalTokens() |
设计要点:
agent_model 表(⚠️ 表名是 agent_model 不是 model),走 AgentModelFactory.create(),
与 Agent 链路同一套连接参数;不传 modelId 时取默认对话模型。ServerException(400/404/500/504),绝不返回 null —— 与
IntentService/FollowupService 的 fail-open 刻意相反(这是调用方主动要结果,不是辅助链路)。chatAs 不用 AgentScope 的 getStructuredData(那要 ReActAgent 的 generate_response 工具),
改为「提示词注入 schema + 宽松解析」(剥 ``` 围栏、截最外层 JSON)。踩到并修掉的坑:Flux.blockLast(Duration) 把流内任何错误都包成
IllegalStateException("Timeout on blocking read...") ⇒ 模型 401 被误报成 504。
改为 .timeout(...) + .onErrorMap(TimeoutException.class, …)。
验证(19 例 mock 单测 + 6 例真实 HTTP 测试,全绿):
本机 Ollama 未启动(11434 无监听)、且 shell 有 http_proxy=127.0.0.1:58859,
默认模型 Ollama:qwen 连不上。于是新增
ai-server/src/test/resources/mock-openai-server.py(HTTP/1.1 chunked 手写 SSE 的假 OpenAI 端点)
LlmServiceHttpTest(不启 Spring 上下文,秒级)真实跑通
「DB 配置 → AgentModelFactory → HTTP SSE → 分片拼接 → usage 提取 → 错误码映射」。
真厂商端点的 LlmServiceLiveTest 因本机无可用模型未验证。顺带发现(未修):com.zsjz.ai.module.plat.mapper.PhoneIspMapper 是该包下唯一漏 @Mapper
注解的接口,本项目不用 @MapperScan ⇒ GlobalCache#initIspData() 启动时抛
NoSuchBeanDefinitionException,被 AppLoadEndEventListener try-catch 吞掉(日志「初始化系统文件失败」),
代价是手机号运营商映射从未加载。测试里用 @MockitoBean 绕过。
环境提示:探测本地端口必须 curl --noproxy '*',否则 http_proxy 会把请求转给代理并返回 502。