From d95bd4d57cc190b1538d9bfbffd7b59687f742b5 Mon Sep 17 00:00:00 2001 From: wangheng Date: Wed, 23 Sep 2026 13:56:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=B7=A5=E5=85=B7=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=90=8C=E6=AD=A5=E6=94=AF=E6=8C=81=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E5=B7=A5=E5=85=B7=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 已存在工具自动更新 category/kind/description,无需手动改库 - status 仍由管理端维护,同步不覆盖;只插不删语义保留 --- .../me/iiv/agent/mcp/ToolSyncService.java | 46 ++++++--- .../me/iiv/agent/mcp/ToolSyncServiceTest.java | 97 +++++++++++++++++++ 2 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolSyncServiceTest.java diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java index 94b9387..a9ba918 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/ToolSyncService.java @@ -11,11 +11,13 @@ import org.springframework.stereotype.Component; import java.lang.reflect.Method; import java.util.List; +import java.util.Objects; /** * 工具注册同步(设计文档 §3.1):启动时把 @Tool + @XqTool 注解声明的工具 - * 同步进 tool 表(name/category/kind/description/status),只插不删—— - * 已存在的工具(含被管理员停用的)保持原状。 + * 同步进 tool 表(name/category/kind/description/status)。新工具插入; + * 已存在的工具更新 category/kind/description 元数据(代码内客观属性), + * 但保留管理员手动设置的 status,且不删除任何已有工具。 */ @Component public class ToolSyncService implements ApplicationRunner { @@ -33,6 +35,7 @@ public class ToolSyncService implements ApplicationRunner { @Override public void run(ApplicationArguments args) { int inserted = 0; + int updated = 0; for (Object toolObject : toolObjects) { for (Method method : toolObject.getClass().getMethods()) { org.springframework.ai.tool.annotation.Tool toolAnnotation = @@ -42,23 +45,36 @@ public class ToolSyncService implements ApplicationRunner { } XqTool xqTool = method.getAnnotation(XqTool.class); String name = toolAnnotation.name().isBlank() ? method.getName() : toolAnnotation.name(); - Long exists = toolMapper.selectCount(new LambdaQueryWrapper() + String category = xqTool == null ? "uncategorized" : xqTool.category(); + String kind = xqTool == null ? "read" : xqTool.kind(); + String description = toolAnnotation.description(); + Tool existing = toolMapper.selectOne(new LambdaQueryWrapper() .eq(Tool::getSoftDelete, 0) .eq(Tool::getName, name)); - if (exists != null && exists > 0) { - continue; + if (existing == null) { + Tool tool = new Tool(); + tool.setName(name); + tool.setCategory(category); + tool.setDescription(description); + tool.setKind(kind); + tool.setStatus((byte) 1); + toolMapper.insert(tool); + inserted++; + log.info("工具注册同步:新增 tool {}", name); + } else if (!Objects.equals(existing.getCategory(), category) + || !Objects.equals(existing.getKind(), kind) + || !Objects.equals(existing.getDescription(), description)) { + Tool patch = new Tool(); + patch.setId(existing.getId()); + patch.setCategory(category); + patch.setKind(kind); + patch.setDescription(description); + toolMapper.updateById(patch); + updated++; + log.info("工具注册同步:更新 tool {} 元数据(status 保持 {})", name, existing.getStatus()); } - Tool tool = new Tool(); - tool.setName(name); - tool.setCategory(xqTool == null ? "uncategorized" : xqTool.category()); - tool.setDescription(toolAnnotation.description()); - tool.setKind(xqTool == null ? "read" : xqTool.kind()); - tool.setStatus((byte) 1); - toolMapper.insert(tool); - inserted++; - log.info("工具注册同步:新增 tool {}", name); } } - log.info("工具注册同步完成,新增 {} 个", inserted); + log.info("工具注册同步完成,新增 {} 个,更新 {} 个", inserted, updated); } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolSyncServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolSyncServiceTest.java new file mode 100644 index 0000000..22102d5 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/ToolSyncServiceTest.java @@ -0,0 +1,97 @@ +package me.iiv.agent.mcp; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.svc.Tool; +import me.iiv.agent.mapper.svc.ToolMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ToolSyncServiceTest { + + @Mock + ToolMapper toolMapper; + + static class FakeTools implements ToolObject { + + @org.springframework.ai.tool.annotation.Tool(name = "demo.ping", description = "演示工具") + @XqTool(category = "customer", kind = "read") + public String ping() { + return "pong"; + } + } + + private ToolSyncService newService() { + return new ToolSyncService(toolMapper, List.of(new FakeTools())); + } + + @Test + void insertsNewToolWithDeclaredMetadata() { + when(toolMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null); + + newService().run(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Tool.class); + verify(toolMapper).insert(captor.capture()); + Tool inserted = captor.getValue(); + assertEquals("demo.ping", inserted.getName()); + assertEquals("customer", inserted.getCategory()); + assertEquals("read", inserted.getKind()); + assertEquals("演示工具", inserted.getDescription()); + assertEquals((byte) 1, inserted.getStatus()); + verify(toolMapper, never()).updateById(any(Tool.class)); + } + + @Test + void updatesMetadataButKeepsAdminManagedStatus() { + Tool existing = new Tool(); + existing.setId(7L); + existing.setName("demo.ping"); + existing.setCategory("hardware"); + existing.setKind("read"); + existing.setDescription("旧描述"); + existing.setStatus((byte) 0); // 管理员手动停用 + when(toolMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + newService().run(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Tool.class); + verify(toolMapper).updateById(captor.capture()); + Tool patch = captor.getValue(); + assertEquals(7L, patch.getId()); + assertEquals("customer", patch.getCategory()); + assertEquals("read", patch.getKind()); + assertEquals("演示工具", patch.getDescription()); + assertNull(patch.getStatus(), "status 必须由管理端维护,同步不得覆盖"); + verify(toolMapper, never()).insert(any(Tool.class)); + } + + @Test + void leavesUnchangedToolAlone() { + Tool existing = new Tool(); + existing.setId(7L); + existing.setName("demo.ping"); + existing.setCategory("customer"); + existing.setKind("read"); + existing.setDescription("演示工具"); + existing.setStatus((byte) 0); + when(toolMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing); + + newService().run(null); + + verify(toolMapper, never()).insert(any(Tool.class)); + verify(toolMapper, never()).updateById(any(Tool.class)); + } +}