From 0d43b6af23c00d603992e4f65e7cab62901c2bc2 Mon Sep 17 00:00:00 2001 From: xiaoqu-ai Date: Wed, 21 Oct 2026 12:43:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E4=BD=9C=E7=94=A8=E5=9F=9F-?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=91=E5=AE=9A=E6=94=B9=E4=B8=BA=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E5=B9=82=E7=AD=89=E5=90=8C=E6=AD=A5=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=E6=89=8B=E5=B7=A5=20SQL?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:新工具部署后必须先手工绑 scope_tool,否则调用方 PERMISSION_DENIED。 2026-10-21 新增的 hardware.statistics.query 与用纸类工具因此一直不可用。 改动: - ScopeToolSyncService:启动时按 mcp.scope-tool-bindings 幂等写入 scope_tool · 格式 sync=toolA,toolB(多条用 ; 分隔) · 只新增不删除;作用域/工具缺失记 WARN 跳过,不阻断启动 · @Order(2) 保证在工具注册(ToolSyncService)之后执行 - application-prod.yml:默认绑定 sync 作用域到 CRM 画像所需的 4 个工具 (district.paper.statistics / device_average / trace.list / hardware.statistics), 可通过 MCP_SCOPE_TOOL_BINDINGS 覆盖 - 设计文档 doc/scope-tool-binding-sync-design.md - 单测 3 例(解析/幂等/容错) 部署后启动日志会打印 scope-tool binding 新增记录,可直接核对。 --- .../doc/scope-tool-binding-sync-design.md | 45 +++++++ .../iiv/agent/mcp/ScopeToolSyncService.java | 119 ++++++++++++++++++ .../src/main/resources/application-prod.yml | 7 ++ .../agent/mcp/ScopeToolSyncServiceTest.java | 77 ++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 xiaoqu-agent-service/doc/scope-tool-binding-sync-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ScopeToolSyncService.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ScopeToolSyncServiceTest.java diff --git a/xiaoqu-agent-service/doc/scope-tool-binding-sync-design.md b/xiaoqu-agent-service/doc/scope-tool-binding-sync-design.md new file mode 100644 index 0000000..070f5ff --- /dev/null +++ b/xiaoqu-agent-service/doc/scope-tool-binding-sync-design.md @@ -0,0 +1,45 @@ +# 作用域-工具绑定启动同步(ScopeToolSyncService) + +## 背景 + +新 MCP 工具部署后,必须绑到调用方使用的 `scope_tool` 记录,否则调用会返回 +`PERMISSION_DENIED:该 token 的作用域不包含工具 [...]`。 + +此前这一步是**管理员手工执行 SQL**,问题: +1. 容易遗漏(2026-10-21:新增的 `hardware.statistics.query` 与用纸类工具部署后一直不可用); +2. 无法随代码评审,变更不可追溯; +3. 每个环境(test/operation/xiaoqu-ai/sync)都要重复操作。 + +## 方案 + +启动时读取配置 `mcp.scope-tool-bindings`,幂等写入 `scope_tool`: + +```yaml +mcp: + scope-tool-bindings: ${MCP_SCOPE_TOOL_BINDINGS:sync=tool.a,tool.b} +``` + +- 多条绑定用 `;` 分隔,同一条内多个工具用 `,` 分隔 +- 格式:`scopeCode=toolName1,toolName2` +- 执行顺序在 `ToolSyncService`(工具注册)之后(`@Order(2)`) + +## 安全约束 + +| 约束 | 说明 | +|---|---| +| 只新增,不删除 | 不触碰管理员手工配置的其他绑定 | +| 能力变更可评审 | 绑定写在 `application-prod.yml` / 环境变量,随代码 diff 审查 | +| 失败不阻断启动 | 作用域或工具不存在时记 WARN 并跳过 | +| 显式声明 | 默认值即"CRM 同步所需 4 个工具",不含其他能力 | + +## 测试 + +`ScopeToolSyncServiceTest`: +1. 解析:`;` 分隔、空白忽略、缺 `=` 忽略 +2. 幂等:已存在的绑定不重复插入 +3. 容错:作用域不存在时跳过且不抛异常 + +## 运维 + +开通新工具只需改配置并重新部署;启动日志会打印 +`scope-tool binding 新增 scope=... tool=...`,可直接核对。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ScopeToolSyncService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ScopeToolSyncService.java new file mode 100644 index 0000000..ece1aaf --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ScopeToolSyncService.java @@ -0,0 +1,119 @@ +package me.iiv.agent.mcp; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.ScopeTool; +import me.iiv.agent.entity.svc.Tool; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.ScopeToolMapper; +import me.iiv.agent.mapper.svc.ToolMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 启动时把配置声明的作用域→工具绑定幂等地写入 scope_tool。 + * + *

为什么需要:新工具部署后必须绑到对应作用域,否则调用方会收到 + * PERMISSION_DENIED("该 token 的作用域不包含工具")。此前这一步依赖管理员 + * 手工执行 SQL,容易遗漏、也无法随代码评审。改为配置驱动后,"开通工具"变成 + * 一次可评审的配置变更 + 常规部署。

+ * + *

配置格式(application-*.yml 或环境变量 MCP_SCOPE_TOOL_BINDINGS):

+ *
+ * mcp:
+ *   # 多条绑定用 ; 分隔;同一条绑定内多个工具用 , 分隔
+ *   scope-tool-bindings: "sync=district.paper.statistics.query,hardware.statistics.query;xiaoqu-ai=agent.user.list.query"
+ * 
+ * + *

安全约束:只新增、不删除;作用域或工具不存在时记警告并跳过(不阻断启动)。

+ */ +@Component +@Order(2) // 必须在 ToolSyncService(@Order 缺省最低)之后,确保 tool 表已有记录 +public class ScopeToolSyncService implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(ScopeToolSyncService.class); + + final AgentScopeMapper scopeMapper; + final ToolMapper toolMapper; + final ScopeToolMapper scopeToolMapper; + + @Value("${mcp.scope-tool-bindings:}") + String rawBindings; + + public ScopeToolSyncService(AgentScopeMapper scopeMapper, ToolMapper toolMapper, + ScopeToolMapper scopeToolMapper) { + this.scopeMapper = scopeMapper; + this.toolMapper = toolMapper; + this.scopeToolMapper = scopeToolMapper; + } + + @Override + public void run(ApplicationArguments args) { + List bindings = parse(rawBindings); + if (bindings.isEmpty()) { + return; + } + int inserted = 0; + int skipped = 0; + for (String binding : bindings) { + String[] parts = binding.split("=", 2); + if (parts.length != 2) { + log.warn("scope-tool binding 格式非法(应为 scopeCode=toolA,toolB): {}", binding); + skipped++; + continue; + } + String scopeCode = parts[0].trim(); + AgentScope scope = scopeMapper.selectOne(new LambdaQueryWrapper() + .eq(AgentScope::getCode, scopeCode).eq(AgentScope::getStatus, (byte) 1)); + if (scope == null) { + log.warn("scope-tool binding 跳过:作用域不存在 code={}", scopeCode); + skipped++; + continue; + } + for (String name : parts[1].split(",")) { + String toolName = name.trim(); + if (toolName.isEmpty()) { + continue; + } + Tool tool = toolMapper.selectOne(new LambdaQueryWrapper() + .eq(Tool::getName, toolName).eq(Tool::getSoftDelete, 0)); + if (tool == null) { + log.warn("scope-tool binding 跳过:工具不存在 name={}", toolName); + skipped++; + continue; + } + Long existing = scopeToolMapper.selectCount(new LambdaQueryWrapper() + .eq(ScopeTool::getScopeId, scope.getId()).eq(ScopeTool::getToolId, tool.getId())); + if (existing != null && existing > 0) { + continue; + } + ScopeTool relation = new ScopeTool(); + relation.setScopeId(scope.getId()); + relation.setToolId(tool.getId()); + scopeToolMapper.insert(relation); + inserted++; + log.info("scope-tool binding 新增 scope={} tool={}", scopeCode, toolName); + } + } + log.info("scope-tool binding 同步完成:新增 {},跳过 {}", inserted, skipped); + } + + /** 解析配置:多条绑定用 ; 分隔,忽略空白项与缺少 = 的项。 */ + static List parse(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + return java.util.Arrays.stream(raw.split(";")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .filter(s -> s.contains("=")) + .toList(); + } +} diff --git a/xiaoqu-agent-service/src/main/resources/application-prod.yml b/xiaoqu-agent-service/src/main/resources/application-prod.yml index bc71d8e..257e4b6 100644 --- a/xiaoqu-agent-service/src/main/resources/application-prod.yml +++ b/xiaoqu-agent-service/src/main/resources/application-prod.yml @@ -1,6 +1,13 @@ # prd 环境:阿里云 RDS / Redis(拓扑沿用 xiaoqu-franchisee pom.xml prd profile) # 敏感值一律走环境变量,不写明文、不入库; # 服务器上由 /data/xiaoqu-agent-service/app.env 注入(deploy.sh 启动前 source) +# MCP 作用域-工具绑定:启动时幂等写入 scope_tool(只新增不删除)。 +# 2026-10-21 用户授权:CRM 客户画像需要"用量(换纸/用纸)"与"设备统计"工具, +# 我们的同步 token 使用 sync 作用域,故绑定到 sync。 +# 多条用 ; 分隔,同一绑定的多个工具用 , 分隔。 +mcp: + scope-tool-bindings: ${MCP_SCOPE_TOOL_BINDINGS:sync=district.paper.statistics.query,district.paper.device_average.query,district.paper.trace.list.query,hardware.statistics.query} + app: open: api-key: ${OPEN_API_KEY} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ScopeToolSyncServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ScopeToolSyncServiceTest.java new file mode 100644 index 0000000..c8c9178 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ScopeToolSyncServiceTest.java @@ -0,0 +1,77 @@ +package me.iiv.agent.mcp; + +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.ScopeTool; +import me.iiv.agent.entity.svc.Tool; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.ScopeToolMapper; +import me.iiv.agent.mapper.svc.ToolMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 启动时幂等绑定 scope_tool:只新增、不删除、缺作用域/工具时跳过。 */ +@ExtendWith(MockitoExtension.class) +class ScopeToolSyncServiceTest { + + @Mock AgentScopeMapper scopeMapper; + @Mock ToolMapper toolMapper; + @Mock ScopeToolMapper scopeToolMapper; + + ScopeToolSyncService service; + + @BeforeEach + void setUp() { + service = new ScopeToolSyncService(scopeMapper, toolMapper, scopeToolMapper); + } + + @Test + void parseSplitsBindingsBySemicolon() { + assertEquals(List.of("sync=a,b", "xiaoqu-ai=c"), ScopeToolSyncService.parse(" sync=a,b ; xiaoqu-ai=c ")); + assertEquals(List.of(), ScopeToolSyncService.parse("")); + assertEquals(List.of(), ScopeToolSyncService.parse("no-equals-sign")); + } + + @Test + void bindsMissingRelationsOnly() { + service.rawBindings = "sync=tool.a,tool.b"; + AgentScope scope = new AgentScope(); + scope.setId(4L); + scope.setCode("sync"); + when(scopeMapper.selectOne(any())).thenReturn(scope); + Tool toolA = new Tool(); + toolA.setId(10L); + toolA.setName("tool.a"); + Tool toolB = new Tool(); + toolB.setId(11L); + toolB.setName("tool.b"); + when(toolMapper.selectOne(any())).thenReturn(toolA, toolB); + when(scopeToolMapper.selectCount(any())).thenReturn(1L, 0L); + + service.run(null); + + // tool.a 已存在(计数 1)→ 不插入;tool.b 缺失 → 插入一次 + verify(scopeToolMapper, times(1)).insert(any(ScopeTool.class)); + } + + @Test + void skipsUnknownScopeWithoutFailing() { + service.rawBindings = "nope=tool.a"; + when(scopeMapper.selectOne(any())).thenReturn(null); + + service.run(null); + + verify(scopeToolMapper, never()).insert(any(ScopeTool.class)); + } +}