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