feat(mcp): 工具注册同步支持更新已有工具元数据

- 已存在工具自动更新 category/kind/description,无需手动改库
- status 仍由管理端维护,同步不覆盖;只插不删语义保留
This commit is contained in:
wangheng
2026-09-23 13:56:01 +08:00
parent 31122e25f1
commit d95bd4d57c
2 changed files with 128 additions and 15 deletions
@@ -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<Tool>()
String category = xqTool == null ? "uncategorized" : xqTool.category();
String kind = xqTool == null ? "read" : xqTool.kind();
String description = toolAnnotation.description();
Tool existing = toolMapper.selectOne(new LambdaQueryWrapper<Tool>()
.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);
}
}
@@ -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<Tool> 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<Tool> 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));
}
}