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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user