feat(mcp): 作用域-工具绑定改为启动时幂等同步(不再依赖手工 SQL)

问题:新工具部署后必须先手工绑 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 新增记录,可直接核对。
This commit is contained in:
xiaoqu-ai
2026-10-21 12:43:49 +08:00
parent 10982f24a3
commit 0d43b6af23
4 changed files with 248 additions and 0 deletions
@@ -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=...`,可直接核对。
@@ -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。
*
* <p>为什么需要:新工具部署后必须绑到对应作用域,否则调用方会收到
* PERMISSION_DENIED("该 token 的作用域不包含工具")。此前这一步依赖管理员
* 手工执行 SQL,容易遗漏、也无法随代码评审。改为配置驱动后,"开通工具"变成
* 一次可评审的配置变更 + 常规部署。</p>
*
* <p>配置格式(application-*.yml 或环境变量 MCP_SCOPE_TOOL_BINDINGS):</p>
* <pre>
* mcp:
* # 多条绑定用 ; 分隔;同一条绑定内多个工具用 , 分隔
* scope-tool-bindings: "sync=district.paper.statistics.query,hardware.statistics.query;xiaoqu-ai=agent.user.list.query"
* </pre>
*
* <p>安全约束:只新增、不删除;作用域或工具不存在时记警告并跳过(不阻断启动)。</p>
*/
@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<String> 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<AgentScope>()
.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<Tool>()
.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<ScopeTool>()
.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<String> 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();
}
}
@@ -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}
@@ -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));
}
}