cc 3 недель назад
Родитель
Сommit
1939e57819

+ 62 - 0
.workbuddy-ai/memory/2026-09-20.md

@@ -0,0 +1,62 @@
+# 2026-09-20
+
+## 把 `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)` 显式包裹。
+- **必须放行 sa-token**:`/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。
+- 工具返回值**不做**二次 JSON 序列化(`ToolResultBlock` → TextBlock 文本直出),与内置 Agent 一致。
+- `Json.toStr` 会把 Long 序列化成**字符串**(`"caseId":"7"`),写断言时别按数字写。
+
+**测试**:`AgentScopeMcpToolProviderTest`(9 例)——全量暴露且无重名、每个工具都能 `McpToolUtils.toAsyncToolSpecification`(这一步是 schema 兼容性的真正回归点,失败会让 MCP server 起不来)、`render_graph` 真实调用链路、入参校验错误文案、4 个案件工具与会话绑定、标量列表回归。
+
+## 端到端验证结果(8981 临时实例,标准库 Python 探针)
+
+`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` 正常释放
+
+### ★ base-url 必须配(否则外部客户端全部 404)
+
+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,容易误判成「配置没生效」)。
+
+## ★ 顺手修掉一个既有 bug:`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)兜底成单列 `value`
+- `PersonAnalysisTool.listPersonNames` 显式包成单列 `name`
+
+## 既有测试失败(与本次改动无关,未修)
+
+`AgentToolRegistryTest` 5 例失败,原因是 `AgentToolRegistry` 里 OTG 组被注释、工具清单未做完
+(实际 64 个业务工具,测试按计划中的 77 个断言):`registersAllBusinessTools`(NPE)、
+`activatingOneGroupRevealsOnlyThatGroup`、`metaToolEnumListsAllGroups`、
+`noSchemaLeaksInjectionSurface`、`numericSpecFieldsBecomeJsonNumbers`。
+另 4 个测试类(Call/GraphRender/Sql/Track)全绿。
+

+ 11 - 0
ai-server/src/main/java/com/zsjz/ai/common/config/SaTokenWebConfig.java

@@ -32,6 +32,13 @@ public class SaTokenWebConfig implements WebMvcConfigurer {
      * <p>{@code /sys/health}、{@code /sys/checkAuth} 必须在白名单 ——
      * 前端 {@code main.ts} 在登录之前就会调用它们做后端就绪探测与授权校验。
      * {@code /sys/map/**} 是地图瓦片/样式等公开静态资源,由地图组件按 URL 直接加载(无法携带请求头)。
+     *
+     * <p><b>MCP 端点({@code /mcpsse}、{@code /mcpstreamable})也在白名单</b>:
+     * 外部 MCP 客户端(Claude Desktop / Cursor 等)走的是 MCP 协议握手,不带 {@code x-token},
+     * 若被 sa-token 拦截会直接 401、连 {@code initialize} 都过不去。
+     * ⚠️ 代价是这两个端点<b>无鉴权</b>:能访问 8980 端口的人即可读取全部案件数据。
+     * 部署到非可信网络时必须在外层(反向代理 / 防火墙)限制来源,或改为在 MCP 侧校验自定义请求头。
+     * 案件上下文由 {@code McpCaseSession} 按 MCP 会话绑定,见 {@code AgentScopeMcpToolProvider}。
      */
     private static final String[] WHITELIST = {
             "/auth/login",
@@ -42,6 +49,10 @@ public class SaTokenWebConfig implements WebMvcConfigurer {
             "/sys/noNet",
             "/sys/map/**",
             "/error",
+            "/mcpsse",
+            "/mcp/message",
+            "/mcpstreamable",
+            "/mcpstreamable/**",
     };
 
     @Override

+ 390 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProvider.java

@@ -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);
+    }
+}

+ 45 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/AgentScopeToolCallback.java

@@ -0,0 +1,45 @@
+package com.zsjz.ai.module.agent.mcp;
+
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+
+import java.util.function.BiFunction;
+
+/**
+ * 把一个 AgentScope 工具适配成 Spring AI 的 {@link ToolCallback},交给 MCP server 暴露。
+ *
+ * <p>不做任何语义转换:{@code inputSchema} 直接复用 AgentScope 生成的 JSON Schema
+ * (同一个 {@code Toolkit} 既服务内置 Agent 又服务 MCP,两边看到的入参契约必然一致),
+ * 调用也直接走 {@code Toolkit#callTool} —— 因此框架层的参数校验、工具组激活校验、
+ * 异常兜底与内置 Agent 完全同源。
+ */
+final class AgentScopeToolCallback implements ToolCallback {
+
+    private final ToolDefinition definition;
+
+    /**
+     * (toolInputJson, toolContext) → 结果文本
+     */
+    private final BiFunction<String, ToolContext, String> invoker;
+
+    AgentScopeToolCallback(ToolDefinition definition, BiFunction<String, ToolContext, String> invoker) {
+        this.definition = definition;
+        this.invoker = invoker;
+    }
+
+    @Override
+    public ToolDefinition getToolDefinition() {
+        return definition;
+    }
+
+    @Override
+    public String call(String toolInput) {
+        return invoker.apply(toolInput, null);
+    }
+
+    @Override
+    public String call(String toolInput, ToolContext toolContext) {
+        return invoker.apply(toolInput, toolContext);
+    }
+}

+ 141 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mcp/McpCaseSession.java

@@ -0,0 +1,141 @@
+package com.zsjz.ai.module.agent.mcp;
+
+import cn.hutool.core.util.StrUtil;
+import com.zsjz.ai.common.cache.CaseDataCache;
+import com.zsjz.ai.common.datasource.CaseDataSourceRegistry;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.common.model.plat.entity.CaseInfo;
+import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * MCP 侧的「当前案件」会话状态。
+ *
+ * <h3>为什么需要它</h3>
+ * {@code module/agent/tools} 下的业务工具(通话/交易/轨迹/画像/图谱)全部读写 <b>案件 DuckDB 库</b>:
+ * mapper 上标的是 {@code @DS("slave")},由 {@code CaseRoutingDataSource} 按
+ * {@code CaseContextHolder} 里的案件 ID 路由到物理库 {@code case{caseId}}。
+ * HTTP 链路上这个上下文由 {@code CaseContextFilter} 从 sa-token Token-Session 解析;
+ * 而 MCP 是外部客户端直连、没有登录态也没有 HTTP 请求线程 —— 上下文为空时
+ * {@code @DS("slave")} 直接报「找不到数据源」。
+ *
+ * <h3>绑定粒度</h3>
+ * 按 <b>MCP 会话</b>({@code McpSyncServerExchange#sessionId()})绑定案件,
+ * 因此多个外部客户端可以各自打开不同案件而互不干扰;取不到 sessionId 时
+ * (例如 stdio 传输)退化到 {@link #DEFAULT_SLOT} 全局槽位。
+ *
+ * <h3>开案做了什么</h3>
+ * 与 {@code CaseInfoService#open} 等价,但<b>跳过 sa-token 归属校验</b>
+ * (MCP 调用方不是登录用户),并且<b>不写 sa-token Token-Session</b> ——
+ * 避免影响同进程内 Web 端用户的当前案件。
+ *
+ * @see AgentScopeMcpToolProvider
+ */
+@Slf4j
+@Component
+public class McpCaseSession {
+
+    /**
+     * 取不到 MCP 会话 ID 时使用的兜底槽位
+     */
+    private static final String DEFAULT_SLOT = "__default__";
+
+    private final CaseInfoMapper caseInfoMapper;
+
+    private final CaseDataSourceRegistry caseDataSourceRegistry;
+
+    /**
+     * MCP 会话 ID → 案件 ID
+     */
+    private final Map<String, Long> sessionCases = new ConcurrentHashMap<>();
+
+    public McpCaseSession(CaseInfoMapper caseInfoMapper, CaseDataSourceRegistry caseDataSourceRegistry) {
+        this.caseInfoMapper = caseInfoMapper;
+        this.caseDataSourceRegistry = caseDataSourceRegistry;
+    }
+
+    /**
+     * 全部案件(MCP 调用方没有登录态,不做归属过滤),按 ID 倒序。
+     */
+    public List<CaseInfo> listCases() {
+        List<CaseInfo> all = caseInfoMapper.selectList(null);
+        if (all == null || all.isEmpty()) {
+            return List.of();
+        }
+        List<CaseInfo> sorted = new ArrayList<>(all);
+        sorted.sort(Comparator.comparing(CaseInfo::getId, Comparator.nullsLast(Comparator.reverseOrder())));
+        return sorted;
+    }
+
+    /**
+     * 当前会话绑定的案件 ID;未绑定返回 null。
+     */
+    public Long current(String sessionId) {
+        return sessionCases.get(slot(sessionId));
+    }
+
+    /**
+     * 当前会话绑定的案件详情;未绑定或案件已被删除时返回 null。
+     */
+    public CaseInfo currentCase(String sessionId) {
+        Long caseId = current(sessionId);
+        return caseId == null ? null : caseInfoMapper.selectById(caseId);
+    }
+
+    /**
+     * 打开案件并绑定到当前 MCP 会话(幂等)。
+     *
+     * @param sessionId MCP 会话 ID,可为 null
+     * @param caseId    案件 ID
+     * @return 已打开的案件
+     */
+    public CaseInfo open(String sessionId, Long caseId) {
+        if (caseId == null) {
+            throw new ServerException(400, "缺少参数 case_id");
+        }
+        CaseInfo ci = caseInfoMapper.selectById(caseId);
+        if (ci == null) {
+            throw new ServerException(404, "案件不存在: " + caseId);
+        }
+        if (StrUtil.isBlank(ci.getDbPath())) {
+            throw new ServerException(400, "案件数据库未初始化,请先在客户端创建/初始化该案件: " + caseId);
+        }
+        // ownerUserId 传 null:MCP 调用方不是登录用户,不做归属记账,也不触发「一人一案」的关闭逻辑
+        caseDataSourceRegistry.open(ci.getId(), ci.getDbPath(), null);
+        try {
+            CaseDataCache.initCache(ci.getId());
+        } catch (Exception e) {
+            // 人员库缓存是可选加速项(依赖 person_lib_no 等表),失败不应阻断开案
+            log.warn("MCP 开案初始化人员库缓存失败: caseId={}", ci.getId(), e);
+        }
+        sessionCases.put(slot(sessionId), ci.getId());
+        log.info("MCP 会话已打开案件: slot={}, caseId={}, name={}", slot(sessionId), ci.getId(), ci.getName());
+        return ci;
+    }
+
+    /**
+     * 关闭当前会话绑定的案件数据源并解绑。
+     *
+     * @return 被关闭的案件 ID;原本未绑定时返回 null
+     */
+    public Long close(String sessionId) {
+        Long caseId = sessionCases.remove(slot(sessionId));
+        if (caseId != null) {
+            caseDataSourceRegistry.close(caseId);
+            CaseDataCache.cleanCache(caseId);
+            log.info("MCP 会话已关闭案件: slot={}, caseId={}", slot(sessionId), caseId);
+        }
+        return caseId;
+    }
+
+    private static String slot(String sessionId) {
+        return StrUtil.isBlank(sessionId) ? DEFAULT_SLOT : sessionId;
+    }
+}

+ 14 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/PersonAnalysisTool.java

@@ -681,7 +681,20 @@ public class PersonAnalysisTool {
     public ToolResultBlock listPersonNames() {
         try {
             List<String> names = personBasicInfoService.getPersonNames();
-            return renderList(names, null);
+            // ★ 不能直接把 List<String> 交给 ToolResultTable:它按「POJO 列表」处理,
+            //   会把每个姓名当成对象反序列化,抛
+            //   "Cannot construct instance of java.util.LinkedHashMap ... from String value"
+            //   (2026-09-20 在 MCP 链路上实测到,空人名时不会触发,所以一直没暴露)。
+            //   这里显式包成单列,列名与 list_persons 的姓名字段保持一致。
+            List<Map<String, Object>> rows = new ArrayList<>();
+            if (names != null) {
+                for (String name : names) {
+                    Map<String, Object> row = new LinkedHashMap<>();
+                    row.put("name", name);
+                    rows.add(row);
+                }
+            }
+            return renderList(rows, null);
         } catch (Exception e) {
             return fail("对象人员姓名查询", e);
         }

+ 32 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/ToolResultTable.java

@@ -127,21 +127,50 @@ final class ToolResultTable {
         return Json.toStr(result);
     }
 
+    /**
+     * 标量列表的兜底列名(如「姓名清单」这类 {@code List<String>})
+     */
+    static final String SCALAR_COLUMN = "value";
+
     /**
      * 把业务对象列表转成「行」(LinkedHashMap 保序)。
      *
      * <p>走全局 {@link Json#objectMapper()},因此日期/金额/长整型的字符串形态与
      * {@code execute_sql} 的单元格一致({@code LocalDateTime → "yyyy-MM-dd HH:mm:ss"}、
      * {@code Long → 字符串}),前端表格不会出现两套格式。
+     *
+     * <p>★ <b>标量元素必须兜底</b>:本方法按「每个元素都是对象」处理,若列表里装的是
+     * {@code String} / {@code Number} 之类的裸值(如姓名清单),{@code convertValue} 会抛
+     * <pre>Cannot construct instance of `java.util.LinkedHashMap` ...
+     * no String-argument constructor/factory method to deserialize from String value</pre>
+     * 这类元素统一包成单列 {@link #SCALAR_COLUMN}。工具侧若想要更贴切的列名
+     * (如 {@code list_person_names} 用 {@code name}),应在调用前自行包装成 Map。
      */
     static List<LinkedHashMap<String, Object>> toRows(List<?> pojos) {
         if (pojos == null || pojos.isEmpty()) {
             return new ArrayList<>();
         }
-        List<LinkedHashMap<String, Object>> rows =
-                Json.objectMapper().convertValue(pojos, new TypeReference<>() {
+        List<LinkedHashMap<String, Object>> rows = new ArrayList<>(pojos.size());
+        for (Object item : pojos) {
+            rows.add(toRow(item));
+        }
+        return rows;
+    }
+
+    /**
+     * 单个元素 → 一行;标量包成单列,其余按 POJO 反序列化。
+     */
+    private static LinkedHashMap<String, Object> toRow(Object item) {
+        if (item == null || item instanceof CharSequence || item instanceof Number
+                || item instanceof Boolean || item instanceof Character) {
+            LinkedHashMap<String, Object> row = new LinkedHashMap<>(1);
+            row.put(SCALAR_COLUMN, item);
+            return row;
+        }
+        LinkedHashMap<String, Object> converted =
+                Json.objectMapper().convertValue(item, new TypeReference<>() {
                 });
-        return rows == null ? new ArrayList<>() : rows;
+        return converted == null ? new LinkedHashMap<>() : converted;
     }
 
     /**

+ 9 - 4
ai-server/src/main/resources/application.yaml

@@ -24,12 +24,17 @@ spring:
     mcp:
       server:
         enabled: true
-        type: async              # 异步模式,提升并发性能
-        name: my-mcp-server      # MCP 服务名称
+        type: async              # 异步模式:工具在 boundedElastic 线程上执行,不占用 HTTP 线程
+        protocol: SSE            # 传输协议,默认即 SSE;改成 STREAMABLE 时生效的是下面的 mcp-endpoint
+        name: my-mcp-server      # MCP 服务名称(客户端 tools/list 里能看到)
         version: 1.0.0           # 服务版本
-        sse-endpoint: /mcpsse       # SSE 端点(默认值)
+        # ★ 必须与 server.context-path 一致:SSE 首帧会把 message 端点按「相对路径」下发给客户端
+        #   (data:/mcp/message?sessionId=...)。不配 base-url 时客户端会 POST 到
+        #   http://host:8980/mcp/message —— 缺 /js/a 前缀,直接 404。
+        base-url: /js/a
+        sse-endpoint: /mcpsse       # SSE 端点 → GET  /js/a/mcpsse
         streamable-http:
-          mcp-endpoint: /mcpstreamable     # Streamable HTTP 端点
+          mcp-endpoint: /mcpstreamable     # Streamable HTTP 端点(protocol=STREAMABLE 时才注册)
 
 
 # AI 研判(对象关系分析)配置 —— 见 continuous-insight-plan.md §4.1.7

+ 324 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/mcp/AgentScopeMcpToolProviderTest.java

@@ -0,0 +1,324 @@
+package com.zsjz.ai.module.agent.mcp;
+
+import com.zsjz.ai.common.model.plat.entity.CaseInfo;
+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.call.service.CallContinuousService;
+import com.zsjz.ai.module.call.service.CallNightService;
+import com.zsjz.ai.module.call.service.CallRecordService;
+import com.zsjz.ai.module.call.service.CallSensitiveService;
+import com.zsjz.ai.module.graph.service.GraphService;
+import com.zsjz.ai.module.person.service.DataProfileService;
+import com.zsjz.ai.module.person.service.IntimacyService;
+import com.zsjz.ai.module.person.service.PersonBasicInfoService;
+import com.zsjz.ai.module.person.service.PersonGroupService;
+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 com.zsjz.ai.module.track.service.TrackCellTowerService;
+import com.zsjz.ai.module.track.service.TrackEnLocalService;
+import com.zsjz.ai.module.track.service.TrackExpressInfoService;
+import com.zsjz.ai.module.track.service.TrackMeetService;
+import com.zsjz.ai.module.track.service.TrackTogetherLiveService;
+import com.zsjz.ai.module.track.service.TrackTogetherTravelService;
+import com.zsjz.ai.module.trans.service.TransBigService;
+import com.zsjz.ai.module.trans.service.TransCardHoldService;
+import com.zsjz.ai.module.trans.service.TransCashFlowService;
+import com.zsjz.ai.module.trans.service.TransContinuousService;
+import com.zsjz.ai.module.trans.service.TransFastFundFlowService;
+import com.zsjz.ai.module.trans.service.TransFinancialService;
+import com.zsjz.ai.module.trans.service.TransFixedDepositService;
+import com.zsjz.ai.module.trans.service.TransFrequencyService;
+import com.zsjz.ai.module.trans.service.TransFundFlowService;
+import com.zsjz.ai.module.trans.service.TransRecordService;
+import com.zsjz.ai.module.trans.service.TransSensitiveService;
+import io.modelcontextprotocol.server.McpServerFeatures;
+import io.modelcontextprotocol.server.McpSyncServerExchange;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.mcp.McpToolUtils;
+import org.springframework.ai.tool.ToolCallback;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * MCP 暴露层的契约测试。
+ *
+ * <p>要守住四件事:
+ * <ol>
+ *   <li><b>全量暴露</b>:{@code tools/list} 必须同时包含基础设施工具(execute_sql / render_graph…)、
+ *       5 个业务组的全部工具,以及 4 个案件管理工具,且<b>无重名</b>
+ *       (MCP 自动配置按名字去重,重名会静默丢掉一个);</li>
+ *   <li><b>schema 必须能被 MCP SDK 解析</b>:{@code McpToolUtils#toAsyncToolSpecification} 内部会把
+ *       AgentScope 生成的 JSON Schema 反序列化成 {@code McpSchema.JsonSchema},
+ *       出现未知字段就直接抛异常、整个 MCP server 起不来 —— 所以逐个转换一遍是必要的回归;</li>
+ *   <li><b>调用链路可用</b>:经 {@code ToolCallback#call} 能真正执行到 AgentScope 工具,
+ *       拿到未被二次转义的文本;</li>
+ *   <li><b>案件会话绑定</b>:会话 ID 必须被正确透传到 {@link McpCaseSession}。</li>
+ * </ol>
+ *
+ * <p>全程用 Mockito 挡掉业务 service 与 Spring 容器,不触发任何 SQL。
+ */
+class AgentScopeMcpToolProviderTest {
+
+    /**
+     * 与 {@code AgentService#buildAgent} 同源的 5 个基础设施工具
+     */
+    private static final Set<String> INFRA_TOOLS = Set.of(
+            "execute_sql", "list_tables", "render_chart", "render_graph", "get_current_workspace");
+
+    /**
+     * 本类附带的案件上下文管理工具
+     */
+    private static final Set<String> CASE_TOOLS = Set.of(
+            "list_cases", "open_case", "current_case", "close_case");
+
+    // ==================== 构造 ====================
+
+    /**
+     * 最近一次 {@link #registry()} 里用到的 mock,供用例设置返回值。
+     */
+    private static PersonBasicInfoService personBasicInfoService;
+
+    private static AgentToolRegistry registry() {
+        personBasicInfoService = mock(PersonBasicInfoService.class);
+        return new AgentToolRegistry(
+                mock(SqlResultStore.class),
+                mock(CallRecordService.class), mock(CallNightService.class),
+                mock(CallContinuousService.class), mock(CallSensitiveService.class),
+                mock(TransBigService.class), mock(TransCardHoldService.class),
+                mock(TransCashFlowService.class), mock(TransContinuousService.class),
+                mock(TransFastFundFlowService.class), mock(TransFinancialService.class),
+                mock(TransFixedDepositService.class), mock(TransFrequencyService.class),
+                mock(TransFundFlowService.class), mock(TransRecordService.class),
+                mock(TransSensitiveService.class),
+                mock(TrackCellTowerService.class), mock(TrackEnLocalService.class),
+                mock(TrackExpressInfoService.class), mock(TrackMeetService.class),
+                mock(TrackTogetherLiveService.class), mock(TrackTogetherTravelService.class),
+                mock(GraphService.class),
+                mock(DataProfileService.class), mock(IntimacyService.class),
+                personBasicInfoService,
+                mock(PersonGroupService.class));
+    }
+
+    private static AgentScopeMcpToolProvider provider(McpCaseSession caseSession) {
+        // Python / RAG 默认 isAvailable()=false,因此这两个工具不注册,断言时不能依赖它们
+        return new AgentScopeMcpToolProvider(registry(), caseSession,
+                mock(SqlQueryMapper.class), mock(SqlResultStore.class),
+                mock(TableInfoMapper.class), mock(TableFieldMapper.class),
+                mock(PythonExecutor.class), mock(AgentChatSessionMapper.class),
+                mock(CaseInfoMapper.class), mock(RagSchemaService.class));
+    }
+
+    private static Map<String, ToolCallback> byName(ToolCallback[] callbacks) {
+        Map<String, ToolCallback> map = new HashMap<>();
+        for (ToolCallback cb : callbacks) {
+            map.put(cb.getToolDefinition().name(), cb);
+        }
+        return map;
+    }
+
+    private static ToolContext contextOf(String sessionId) {
+        McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
+        when(exchange.sessionId()).thenReturn(sessionId);
+        return new ToolContext(Map.of(McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchange));
+    }
+
+    // ==================== 1. 全量暴露 ====================
+
+    @Test
+    @DisplayName("暴露全部工具:基础设施 + 5 个业务组 + 案件管理,且无重名")
+    void exposesEveryTool() {
+        ToolCallback[] callbacks = provider(mock(McpCaseSession.class)).getToolCallbacks();
+        Set<String> names = Arrays.stream(callbacks)
+                .map(cb -> cb.getToolDefinition().name())
+                .collect(Collectors.toSet());
+
+        assertEquals(callbacks.length, names.size(), "工具名重复会导致 MCP 侧静默丢弃一个");
+        assertTrue(names.containsAll(INFRA_TOOLS), "缺少基础设施工具,实际: " + names);
+        assertTrue(names.containsAll(CASE_TOOLS), "缺少案件管理工具,实际: " + names);
+
+        // 业务组清单:用真实 registry 反查,避免在测试里复制一份工具名清单
+        Set<String> grouped = new HashSet<>();
+        for (String group : List.of(AgentToolRegistry.GROUP_PERSON, AgentToolRegistry.GROUP_CALL,
+                AgentToolRegistry.GROUP_TRANS, AgentToolRegistry.GROUP_TRACK,
+                AgentToolRegistry.GROUP_GRAPH)) {
+            grouped.addAll(groupTools(group));
+        }
+        assertTrue(names.containsAll(grouped), "业务工具未被全部暴露,缺少: "
+                + grouped.stream().filter(n -> !names.contains(n)).toList());
+
+        // 数量下限:5 基础设施 + 64 业务 + 4 管理
+        assertTrue(callbacks.length >= 73, "暴露的工具数偏少: " + callbacks.length);
+    }
+
+    /**
+     * 取某个业务组注册的工具名(用真实 registry + 真实 Toolkit 反查,避免在测试里复制清单)
+     */
+    private static Set<String> groupTools(String group) {
+        io.agentscope.core.tool.Toolkit toolkit = new io.agentscope.core.tool.Toolkit();
+        registry().registerBusinessTools(toolkit, List.of(group));
+        return toolkit.getToolGroup(group).getTools();
+    }
+
+    // ==================== 2. schema 能被 MCP SDK 消费 ====================
+
+    @Test
+    @DisplayName("每份工具都能转成 MCP ToolSpecification,且 inputSchema 是合法 object schema")
+    void everyToolIsConvertibleToMcpSpecification() {
+        ToolCallback[] callbacks = provider(mock(McpCaseSession.class)).getToolCallbacks();
+
+        for (ToolCallback cb : callbacks) {
+            String name = cb.getToolDefinition().name();
+            // 内部会把 AgentScope 的 JSON Schema 反序列化成 McpSchema.JsonSchema,失败即抛异常
+            McpServerFeatures.AsyncToolSpecification spec = McpToolUtils.toAsyncToolSpecification(cb);
+            McpSchema.Tool tool = spec.tool();
+
+            assertEquals(name, tool.name());
+            assertNotNull(tool.description(), "缺少 description: " + name);
+            assertFalse(tool.description().isBlank(), "description 不能为空: " + name);
+            assertNotNull(tool.inputSchema(), "缺少 inputSchema: " + name);
+            assertEquals("object", tool.inputSchema().type(), "inputSchema.type 必须是 object: " + name);
+        }
+    }
+
+    // ==================== 3. 调用链路 ====================
+
+    @Test
+    @DisplayName("经 MCP 调用 render_graph:拿到未二次转义的图谱 JSON")
+    void renderGraphThroughMcp() {
+        ToolCallback callback = byName(provider(mock(McpCaseSession.class)).getToolCallbacks())
+                .get("render_graph");
+        assertNotNull(callback);
+
+        String input = """
+                {"graph":{"title":"测试图谱",
+                  "nodes":[{"id":"a","name":"张三"},{"id":"b","name":"李四"}],
+                  "edges":[{"source":"a","target":"b","label":"通话 5 次","type":"call"}]}}
+                """;
+        String result = callback.call(input);
+
+        assertFalse(result.startsWith("Error"), "调用失败: " + result);
+        assertTrue(result.contains("\"nodes\""), "返回的不是图谱 JSON: " + result);
+        assertTrue(result.contains("\"type\":\"call\""), "边类型未被规范化: " + result);
+        // 返回值必须原样是 JSON 对象,不能是被二次序列化的字符串字面量
+        assertFalse(result.startsWith("\"{"), "返回值被二次转义了: " + result.substring(0, 20));
+    }
+
+    @Test
+    @DisplayName("入参缺必填字段时由框架返回可读的校验错误,而不是抛栈")
+    void invalidInputGivesReadableError() {
+        ToolCallback callback = byName(provider(mock(McpCaseSession.class)).getToolCallbacks())
+                .get("render_graph");
+        String result = callback.call("{\"graph\":{\"nodes\":[],\"edges\":[]}}");
+
+        assertTrue(result.startsWith("Error"), "应返回错误文本: " + result);
+        assertFalse(result.contains("Exception"), "错误文案不该是异常栈: " + result);
+    }
+
+    // ==================== 4. 案件会话绑定 ====================
+
+    @Test
+    @DisplayName("list_cases 返回案件清单(含是否已初始化数据库)")
+    void listCases() {
+        McpCaseSession session = mock(McpCaseSession.class);
+        CaseInfo ci = new CaseInfo();
+        ci.setId(7L);
+        ci.setName("测试案件");
+        ci.setDbPath("E:/tmp/case7.duckdb");
+        when(session.listCases()).thenReturn(List.of(ci));
+
+        String result = byName(provider(session).getToolCallbacks()).get("list_cases").call("{}");
+
+        assertTrue(result.contains("\"caseId\":\"7\""), result);
+        assertTrue(result.contains("测试案件"), result);
+        assertTrue(result.contains("\"dbInitialized\":true"), result);
+    }
+
+    @Test
+    @DisplayName("open_case 把 case_id 交给会话;current_case 反映绑定结果")
+    void openCaseBindsSession() {
+        McpCaseSession session = mock(McpCaseSession.class);
+        CaseInfo ci = new CaseInfo();
+        ci.setId(9L);
+        ci.setName("案件九");
+        when(session.open(any(), anyLong())).thenReturn(ci);
+        when(session.currentCase("s-1")).thenReturn(ci);
+
+        Map<String, ToolCallback> tools = byName(provider(session).getToolCallbacks());
+        String opened = tools.get("open_case").call("{\"case_id\":9}", contextOf("s-1"));
+
+        assertTrue(opened.contains("\"caseId\":\"9\""), opened);
+        assertTrue(opened.contains("\"opened\":true"), opened);
+        verify(session).open("s-1", 9L);
+
+        String current = tools.get("current_case").call("{}", contextOf("s-1"));
+        assertTrue(current.contains("\"caseId\":\"9\""), current);
+    }
+
+    @Test
+    @DisplayName("未绑定案件时 current_case 给出明确的下一步指引")
+    void currentCaseWithoutBinding() {
+        McpCaseSession session = mock(McpCaseSession.class);
+        when(session.currentCase(any())).thenReturn(null);
+
+        String result = byName(provider(session).getToolCallbacks())
+                .get("current_case").call("{}", contextOf("s-2"));
+
+        assertTrue(result.contains("\"caseId\":null"), result);
+        assertTrue(result.contains("open_case"), "应提示先调用 open_case: " + result);
+    }
+
+    @Test
+    @DisplayName("会话 ID 取不到时退化到默认槽位,不抛异常")
+    void missingSessionFallsBack() {
+        McpCaseSession session = mock(McpCaseSession.class);
+        when(session.listCases()).thenReturn(List.of());
+
+        // 不传 ToolContext(等价于 stdio 等拿不到 exchange 的传输)
+        String result = byName(provider(session).getToolCallbacks()).get("list_cases").call("{}");
+
+        assertTrue(result.contains("\"cases\":[]"), result);
+    }
+
+    // ==================== 5. 回归:标量列表不能被当成 POJO 列表 ====================
+
+    @Test
+    @DisplayName("list_person_names 的姓名清单要能正常出表(标量列表回归)")
+    void listPersonNamesHandlesScalarList() {
+        Map<String, ToolCallback> tools = byName(provider(mock(McpCaseSession.class)).getToolCallbacks());
+        // provider(...) 内部刚重建过 registry,此刻的 mock 就是工具实际持有的那一个
+        when(personBasicInfoService.getPersonNames()).thenReturn(List.of("张三", "李四"));
+
+        String result = tools.get("list_person_names").call("{}");
+
+        // 曾经的实现直接把 List<String> 交给 ToolResultTable,触发
+        // "Cannot construct instance of java.util.LinkedHashMap ... from String value"
+        assertFalse(result.startsWith("Error"), "标量列表仍未兜底: " + result);
+        assertTrue(result.contains("\"name\""), "列名应为 name: " + result);
+        assertTrue(result.contains("张三"), result);
+        assertTrue(result.contains("\"totalRows\":2"), result);
+    }
+}