|
|
@@ -0,0 +1,390 @@
|
|
|
+package com.zsjz.ai.module.agent.mcp;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
+import com.zsjz.ai.common.context.CaseContextHolder;
|
|
|
+import com.zsjz.ai.common.model.plat.entity.CaseInfo;
|
|
|
+import com.zsjz.ai.common.utils.Json;
|
|
|
+import com.zsjz.ai.module.agent.mapper.AgentChatSessionMapper;
|
|
|
+import com.zsjz.ai.module.agent.mapper.SqlQueryMapper;
|
|
|
+import com.zsjz.ai.module.agent.python.PythonExecutor;
|
|
|
+import com.zsjz.ai.module.agent.rag.RagSchemaService;
|
|
|
+import com.zsjz.ai.module.agent.sql.SqlResultStore;
|
|
|
+import com.zsjz.ai.module.agent.tools.AgentToolRegistry;
|
|
|
+import com.zsjz.ai.module.agent.tools.GraphRenderTool;
|
|
|
+import com.zsjz.ai.module.agent.tools.PythonAnalysisTool;
|
|
|
+import com.zsjz.ai.module.agent.tools.RagSchemaSearchTool;
|
|
|
+import com.zsjz.ai.module.agent.tools.SqlAnalysisTool;
|
|
|
+import com.zsjz.ai.module.agent.tools.WorkspaceInfoTool;
|
|
|
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
|
|
|
+import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
|
|
|
+import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
|
|
|
+import io.agentscope.core.message.ContentBlock;
|
|
|
+import io.agentscope.core.message.TextBlock;
|
|
|
+import io.agentscope.core.message.ToolResultBlock;
|
|
|
+import io.agentscope.core.model.ToolSchema;
|
|
|
+import io.agentscope.core.tool.ToolCallParam;
|
|
|
+import io.agentscope.core.tool.Toolkit;
|
|
|
+import io.agentscope.core.message.ToolUseBlock;
|
|
|
+import io.modelcontextprotocol.server.McpSyncServerExchange;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.ai.chat.model.ToolContext;
|
|
|
+import org.springframework.ai.mcp.McpToolUtils;
|
|
|
+import org.springframework.ai.tool.ToolCallback;
|
|
|
+import org.springframework.ai.tool.ToolCallbackProvider;
|
|
|
+import org.springframework.ai.tool.definition.DefaultToolDefinition;
|
|
|
+import org.springframework.ai.tool.definition.ToolDefinition;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import java.time.Duration;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.UUID;
|
|
|
+import java.util.function.BiFunction;
|
|
|
+import java.util.function.Supplier;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 把 {@code com.zsjz.ai.module.agent.tools} 下的<b>全部</b> Agent 工具通过 Spring AI MCP 暴露给外部。
|
|
|
+ *
|
|
|
+ * <h3>实现方式:适配而不是重写</h3>
|
|
|
+ * 工具本体是 AgentScope 的 {@code @Tool} 方法({@code CallAnalysisTool} / {@code TransAnalysisTool} /
|
|
|
+ * {@code TrackAnalysisTool} / {@code PersonAnalysisTool} / {@code GraphAnalysisTool} /
|
|
|
+ * {@code GraphRenderTool} / {@code SqlAnalysisTool} / {@code RagSchemaSearchTool} /
|
|
|
+ * {@code PythonAnalysisTool} / {@code WorkspaceInfoTool}),注解体系与 Spring AI 不兼容,
|
|
|
+ * 逐个用 {@code @Tool} 重写一遍会产生「两套实现、两套 schema、迟早漂移」的维护地狱。
|
|
|
+ *
|
|
|
+ * <p>这里改为:<b>再建一个独立的 {@link Toolkit}</b>(与内置 Agent 用的实例互不干扰),
|
|
|
+ * 注册同一批工具对象,把 {@code getToolSchemas()} 拿到的 JSON Schema 原样转成 Spring AI 的
|
|
|
+ * {@link ToolDefinition},调用也走 {@code Toolkit#callTool}。于是:
|
|
|
+ * <ul>
|
|
|
+ * <li>外部 MCP 客户端与内置 Agent 看到的<b>入参契约完全一致</b>(同一份 schema);</li>
|
|
|
+ * <li>新增/修改工具时<b>零改动</b> —— 只要在 {@code AgentToolRegistry} 里注册即可;</li>
|
|
|
+ * <li>框架层的必填/类型校验、异常兜底、{@code ToolResultBlock} 文本输出全部复用。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <h3>工具组:MCP 侧全部激活</h3>
|
|
|
+ * 内置 Agent 为省 token 只默认装备 {@code person} 组,其余组由 {@code reset_equipped_tools}
|
|
|
+ * 按需切换。MCP 的外部模型(Claude Desktop / Cursor 等)没有这套元工具,
|
|
|
+ * 因此这里一次性把 5 个业务组全部激活 —— MCP 的 {@code tools/list} 会返回全部工具。
|
|
|
+ *
|
|
|
+ * <h3>案件上下文</h3>
|
|
|
+ * 业务工具读写案件 DuckDB 库({@code @DS("slave")} 按 {@code CaseContextHolder} 路由)。
|
|
|
+ * MCP 调用没有登录态也没有请求线程,因此本类在调用前用
|
|
|
+ * {@link CaseContextHolder#callWith(Long, Long, Supplier)} 把会话绑定的案件显式包起来,
|
|
|
+ * 见 {@link McpCaseSession}。未绑定时业务工具会返回明确的报错(而不是抛栈)。
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class AgentScopeMcpToolProvider implements ToolCallbackProvider {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 单个工具调用的阻塞上限。业务工具最慢的是大范围聚合查询,5 分钟足够;
|
|
|
+ * 超时返回文本错误,避免 MCP 客户端无限等待。
|
|
|
+ */
|
|
|
+ private static final Duration TOOL_TIMEOUT = Duration.ofMinutes(5);
|
|
|
+
|
|
|
+ // ==================== 依赖(与 AgentService#buildAgent 保持一致) ====================
|
|
|
+
|
|
|
+ private final AgentToolRegistry agentToolRegistry;
|
|
|
+ private final McpCaseSession caseSession;
|
|
|
+ private final SqlQueryMapper sqlQueryMapper;
|
|
|
+ private final SqlResultStore sqlResultStore;
|
|
|
+ private final TableInfoMapper tableInfoMapper;
|
|
|
+ private final TableFieldMapper tableFieldMapper;
|
|
|
+ private final PythonExecutor pythonExecutor;
|
|
|
+ private final AgentChatSessionMapper chatSessionMapper;
|
|
|
+ private final CaseInfoMapper caseInfoMapper;
|
|
|
+ private final RagSchemaService ragSchemaService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * MCP 专用工具容器:全组激活,与内置 Agent 的实例隔离
|
|
|
+ */
|
|
|
+ private volatile Toolkit toolkit;
|
|
|
+
|
|
|
+ private volatile ToolCallback[] callbacks;
|
|
|
+
|
|
|
+ public AgentScopeMcpToolProvider(AgentToolRegistry agentToolRegistry,
|
|
|
+ McpCaseSession caseSession,
|
|
|
+ SqlQueryMapper sqlQueryMapper,
|
|
|
+ SqlResultStore sqlResultStore,
|
|
|
+ TableInfoMapper tableInfoMapper,
|
|
|
+ TableFieldMapper tableFieldMapper,
|
|
|
+ PythonExecutor pythonExecutor,
|
|
|
+ AgentChatSessionMapper chatSessionMapper,
|
|
|
+ CaseInfoMapper caseInfoMapper,
|
|
|
+ RagSchemaService ragSchemaService) {
|
|
|
+ this.agentToolRegistry = agentToolRegistry;
|
|
|
+ this.caseSession = caseSession;
|
|
|
+ this.sqlQueryMapper = sqlQueryMapper;
|
|
|
+ this.sqlResultStore = sqlResultStore;
|
|
|
+ this.tableInfoMapper = tableInfoMapper;
|
|
|
+ this.tableFieldMapper = tableFieldMapper;
|
|
|
+ this.pythonExecutor = pythonExecutor;
|
|
|
+ this.chatSessionMapper = chatSessionMapper;
|
|
|
+ this.caseInfoMapper = caseInfoMapper;
|
|
|
+ this.ragSchemaService = ragSchemaService;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * MCP server 启动时调用一次({@code ToolCallbackConverterAutoConfiguration}),
|
|
|
+ * 结果同时用于 {@code tools/list} 与 {@code tools/call}。
|
|
|
+ */
|
|
|
+ @Override
|
|
|
+ public ToolCallback[] getToolCallbacks() {
|
|
|
+ ToolCallback[] local = callbacks;
|
|
|
+ if (local != null) {
|
|
|
+ return local;
|
|
|
+ }
|
|
|
+ synchronized (this) {
|
|
|
+ if (callbacks == null) {
|
|
|
+ callbacks = build();
|
|
|
+ }
|
|
|
+ return callbacks;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 构建 ====================
|
|
|
+
|
|
|
+ private ToolCallback[] build() {
|
|
|
+ Toolkit tk = buildToolkit();
|
|
|
+ this.toolkit = tk;
|
|
|
+
|
|
|
+ List<ToolCallback> result = new ArrayList<>();
|
|
|
+ for (ToolSchema schema : tk.getToolSchemas()) {
|
|
|
+ String name = schema.getName();
|
|
|
+ result.add(new AgentScopeToolCallback(toDefinition(schema),
|
|
|
+ (input, ctx) -> invoke(tk, name, input, ctx)));
|
|
|
+ }
|
|
|
+ // MCP 专用管理工具(案件上下文),业务工具之外的必要补充
|
|
|
+ result.addAll(caseManagementTools());
|
|
|
+ log.info("MCP 已暴露 Agent 工具 {} 个(含 4 个案件管理工具)", result.size());
|
|
|
+ return result.toArray(new ToolCallback[0]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建与 {@code AgentService#buildAgent} 同构的工具容器,区别只有「业务组全部激活」。
|
|
|
+ */
|
|
|
+ private Toolkit buildToolkit() {
|
|
|
+ Toolkit tk = new Toolkit();
|
|
|
+ if (ragSchemaService.isAvailable()) {
|
|
|
+ tk.registerTool(new RagSchemaSearchTool(ragSchemaService));
|
|
|
+ }
|
|
|
+ tk.registerTool(new SqlAnalysisTool(sqlQueryMapper, sqlResultStore, tableInfoMapper, tableFieldMapper));
|
|
|
+ if (pythonExecutor.isAvailable()) {
|
|
|
+ tk.registerTool(new PythonAnalysisTool(pythonExecutor));
|
|
|
+ }
|
|
|
+ tk.registerTool(new WorkspaceInfoTool(chatSessionMapper, caseInfoMapper));
|
|
|
+ tk.registerTool(new GraphRenderTool());
|
|
|
+ // 内置 Agent 只默认装备 person 组;MCP 侧外部模型没有 reset_equipped_tools,全部激活
|
|
|
+ agentToolRegistry.registerBusinessTools(tk, List.of(
|
|
|
+ AgentToolRegistry.GROUP_PERSON,
|
|
|
+ AgentToolRegistry.GROUP_CALL,
|
|
|
+ AgentToolRegistry.GROUP_TRANS,
|
|
|
+ AgentToolRegistry.GROUP_TRACK,
|
|
|
+ AgentToolRegistry.GROUP_GRAPH));
|
|
|
+ return tk;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * AgentScope 的 {@link ToolSchema} → Spring AI 的 {@link ToolDefinition}。
|
|
|
+ *
|
|
|
+ * <p>{@code parameters} 是完整 JSON Schema({@code {type,properties,required,$defs}}),
|
|
|
+ * 直接序列化后交给 MCP;MCP SDK 的 {@code JsonSchema} 记录恰好覆盖这几个键,
|
|
|
+ * 因此 {@code tools/list} 返回的 inputSchema 与内置 Agent 完全一致。
|
|
|
+ */
|
|
|
+ private static ToolDefinition toDefinition(ToolSchema schema) {
|
|
|
+ String description = schema.getDescription() == null ? schema.getName() : schema.getDescription();
|
|
|
+ return DefaultToolDefinition.builder()
|
|
|
+ .name(schema.getName())
|
|
|
+ .description(description)
|
|
|
+ .inputSchema(Json.toStr(schema.getParameters()))
|
|
|
+ .build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 调用 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 执行一次工具调用。
|
|
|
+ *
|
|
|
+ * <p>步骤:解析入参 JSON → 解析会话绑定的案件 → 在案件作用域内调用
|
|
|
+ * {@code Toolkit#callTool} → 抽取 {@link ToolResultBlock} 的文本。
|
|
|
+ */
|
|
|
+ private String invoke(Toolkit tk, String toolName, String toolInput, ToolContext toolContext) {
|
|
|
+ Map<String, Object> input = parseInput(toolInput);
|
|
|
+ Long caseId = caseSession.current(sessionIdOf(toolContext));
|
|
|
+
|
|
|
+ Supplier<String> action = () -> callTool(tk, toolName, toolInput, input);
|
|
|
+ // 业务工具要按案件路由 DuckDB 数据源;管理工具(案件列表/开案)不依赖,绑不绑都行
|
|
|
+ return caseId == null ? action.get() : CaseContextHolder.callWith(caseId, null, action);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String callTool(Toolkit tk, String toolName, String toolInput, Map<String, Object> input) {
|
|
|
+ String raw = (toolInput == null || toolInput.isBlank()) ? "{}" : toolInput;
|
|
|
+ try {
|
|
|
+ ToolUseBlock use = ToolUseBlock.builder()
|
|
|
+ .id("mcp-" + UUID.randomUUID())
|
|
|
+ .name(toolName)
|
|
|
+ // content 是框架做 schema 校验时读的原始 JSON 文本,必须带上
|
|
|
+ .content(raw)
|
|
|
+ .input(input)
|
|
|
+ .build();
|
|
|
+ ToolCallParam param = ToolCallParam.builder()
|
|
|
+ .toolUseBlock(use)
|
|
|
+ .input(input)
|
|
|
+ .build();
|
|
|
+ return toText(tk.callTool(param).block(TOOL_TIMEOUT));
|
|
|
+ } catch (IllegalStateException e) {
|
|
|
+ return "Error: 工具 " + toolName + " 执行超时(" + TOOL_TIMEOUT.toMinutes() + " 分钟),请缩小查询范围后重试";
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("MCP 工具调用失败: tool={}", toolName, e);
|
|
|
+ return "Error: 工具 " + toolName + " 执行失败: " + e.getMessage();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取 {@link ToolResultBlock} 里的文本;非文本块降级为 JSON。
|
|
|
+ */
|
|
|
+ private static String toText(ToolResultBlock block) {
|
|
|
+ if (block == null) {
|
|
|
+ return "Error: 工具执行未返回结果";
|
|
|
+ }
|
|
|
+ List<ContentBlock> output = block.getOutput();
|
|
|
+ if (output == null || output.isEmpty()) {
|
|
|
+ return "Error: 工具执行未产生输出(state=" + block.getState() + ")";
|
|
|
+ }
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (ContentBlock cb : output) {
|
|
|
+ if (cb instanceof TextBlock tb && tb.getText() != null) {
|
|
|
+ sb.append(tb.getText());
|
|
|
+ } else {
|
|
|
+ sb.append(Json.toStr(cb));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, Object> parseInput(String toolInput) {
|
|
|
+ if (toolInput == null || toolInput.isBlank()) {
|
|
|
+ return Map.of();
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Map<String, Object> parsed =
|
|
|
+ Json.objectMapper().readValue(toolInput, new TypeReference<Map<String, Object>>() {
|
|
|
+ });
|
|
|
+ return parsed == null ? Map.of() : parsed;
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 解析失败不在这里报错:让框架的 schema 校验给出「参数不是合法 JSON」的明确文案
|
|
|
+ return Map.of();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String sessionIdOf(ToolContext toolContext) {
|
|
|
+ if (toolContext == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ return McpToolUtils.getMcpExchange(toolContext)
|
|
|
+ .map(McpSyncServerExchange::sessionId)
|
|
|
+ .orElse(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== MCP 专用:案件上下文管理 ====================
|
|
|
+
|
|
|
+ private List<ToolCallback> caseManagementTools() {
|
|
|
+ List<ToolCallback> tools = new ArrayList<>();
|
|
|
+
|
|
|
+ tools.add(simple("list_cases",
|
|
|
+ "列出本机全部案件(ID、名称、是否已初始化数据库)。"
|
|
|
+ + "★ 外部客户端接入后必须先调用本工具拿到案件 ID,再调用 open_case 打开案件,"
|
|
|
+ + "否则所有通话/交易/轨迹/画像类工具都会报「缺少案件上下文」。",
|
|
|
+ emptySchema(),
|
|
|
+ (input, ctx) -> {
|
|
|
+ List<CaseInfo> cases = caseSession.listCases();
|
|
|
+ if (cases.isEmpty()) {
|
|
|
+ return "{\"cases\":[],\"note\":\"本机没有任何案件,请先在客户端创建并导入数据\"}";
|
|
|
+ }
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
+ for (CaseInfo ci : cases) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("caseId", ci.getId());
|
|
|
+ item.put("name", ci.getName());
|
|
|
+ item.put("dbInitialized", ci.getDbPath() != null && !ci.getDbPath().isBlank());
|
|
|
+ item.put("createTime", ci.getCreateTime() == null ? null : ci.getCreateTime().toString());
|
|
|
+ items.add(item);
|
|
|
+ }
|
|
|
+ return Json.toStr(Map.of("cases", items));
|
|
|
+ }));
|
|
|
+
|
|
|
+ tools.add(simple("open_case",
|
|
|
+ "打开指定案件并绑定到当前 MCP 会话。绑定后所有业务工具(通话/交易/轨迹/画像/图谱/SQL)"
|
|
|
+ + "都作用于该案件的数据。换案件重复调用本工具即可。"
|
|
|
+ + "参数 case_id 来自 list_cases。",
|
|
|
+ objectSchema(Map.of(
|
|
|
+ "case_id", Map.of("type", "integer", "description", "案件 ID,来自 list_cases")),
|
|
|
+ List.of("case_id")),
|
|
|
+ (input, ctx) -> {
|
|
|
+ Long caseId = longValue(parseInput(input).get("case_id"));
|
|
|
+ CaseInfo ci = caseSession.open(sessionIdOf(ctx), caseId);
|
|
|
+ return Json.toStr(Map.of(
|
|
|
+ "caseId", ci.getId(),
|
|
|
+ "name", ci.getName() == null ? "" : ci.getName(),
|
|
|
+ "opened", true));
|
|
|
+ }));
|
|
|
+
|
|
|
+ tools.add(simple("current_case",
|
|
|
+ "查看当前 MCP 会话已绑定的案件;未绑定时提示先调用 open_case。",
|
|
|
+ emptySchema(),
|
|
|
+ (input, ctx) -> {
|
|
|
+ CaseInfo ci = caseSession.currentCase(sessionIdOf(ctx));
|
|
|
+ if (ci == null) {
|
|
|
+ return "{\"caseId\":null,\"note\":\"当前未绑定案件,请先调用 list_cases 再调用 open_case\"}";
|
|
|
+ }
|
|
|
+ return Json.toStr(Map.of("caseId", ci.getId(), "name", ci.getName() == null ? "" : ci.getName()));
|
|
|
+ }));
|
|
|
+
|
|
|
+ tools.add(simple("close_case",
|
|
|
+ "关闭当前 MCP 会话绑定的案件数据源(释放 DuckDB 内存)。"
|
|
|
+ + "仅影响本 MCP 会话,不影响 Web 端已打开的案件。",
|
|
|
+ emptySchema(),
|
|
|
+ (input, ctx) -> {
|
|
|
+ Long closed = caseSession.close(sessionIdOf(ctx));
|
|
|
+ return Json.toStr(Map.of("closedCaseId", closed == null ? "" : closed,
|
|
|
+ "note", closed == null ? "当前未绑定案件" : "已关闭"));
|
|
|
+ }));
|
|
|
+
|
|
|
+ return tools;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static ToolCallback simple(String name, String description, Map<String, Object> schema,
|
|
|
+ BiFunction<String, ToolContext, String> fn) {
|
|
|
+ ToolDefinition definition = DefaultToolDefinition.builder()
|
|
|
+ .name(name)
|
|
|
+ .description(description)
|
|
|
+ .inputSchema(Json.toStr(schema))
|
|
|
+ .build();
|
|
|
+ return new AgentScopeToolCallback(definition, fn);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, Object> emptySchema() {
|
|
|
+ return Map.of("type", "object", "properties", Map.of());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, Object> objectSchema(Map<String, Object> properties, List<String> required) {
|
|
|
+ Map<String, Object> schema = new LinkedHashMap<>();
|
|
|
+ schema.put("type", "object");
|
|
|
+ schema.put("properties", properties);
|
|
|
+ schema.put("required", required);
|
|
|
+ return schema;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Long longValue(Object raw) {
|
|
|
+ if (raw == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (raw instanceof Number n) {
|
|
|
+ return n.longValue();
|
|
|
+ }
|
|
|
+ String text = String.valueOf(raw).trim();
|
|
|
+ return text.isEmpty() ? null : Long.valueOf(text);
|
|
|
+ }
|
|
|
+}
|