diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index e18d371a..e0d46ec6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * 技能管理接口 @@ -65,6 +66,11 @@ public class SkillController { @RequestParam(required = false) Boolean enabled, @RequestParam(required = false) String scanStatus) { IPage dbPage = skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus); + boolean mergeMcpVirtuals = page == 1 + && (skillType == null || skillType.isBlank() || "mcp".equalsIgnoreCase(skillType)); + boolean mergeAcpVirtuals = page == 1 + && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType)); + Set realNames = (mergeMcpVirtuals || mergeAcpVirtuals) ? realSkillNames() : Set.of(); // RFC-090 §3.2 — surface MCP servers as virtual skills on the // first page so users see GitHub / Filesystem / etc. as cards @@ -72,14 +78,14 @@ public class SkillController { // because virtual rows are unpaginated; a follow-up can push // them through proper SQL UNION ALL when MCP server count gets // into the dozens. - if (page == 1 && (skillType == null || skillType.isBlank() || "mcp".equalsIgnoreCase(skillType))) { + if (mergeMcpVirtuals) { try { List mcpSkills = mcpSkillBridge.listMcpDerivedSkillEntities(); if (!mcpSkills.isEmpty()) { // In-memory filter mirrors the DB filters so the // user's filter chips still apply to virtual rows. String kw = keyword == null ? "" : keyword.trim().toLowerCase(); - List filtered = mcpSkills.stream() + List filtered = filterShadowedVirtualSkills(mcpSkills, realNames).stream() .filter(s -> kw.isEmpty() || (s.getName() != null && s.getName().toLowerCase().contains(kw)) || (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw))) @@ -97,12 +103,12 @@ public class SkillController { } } // RFC-090 §3.2 (parallel) — same auto-bridge for ACP endpoints. - if (page == 1 && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType))) { + if (mergeAcpVirtuals) { try { List acpSkills = acpSkillBridge.listAcpDerivedSkillEntities(); if (!acpSkills.isEmpty()) { String kw = keyword == null ? "" : keyword.trim().toLowerCase(); - List filtered = acpSkills.stream() + List filtered = filterShadowedVirtualSkills(acpSkills, realNames).stream() .filter(s -> kw.isEmpty() || (s.getName() != null && s.getName().toLowerCase().contains(kw)) || (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw))) @@ -126,11 +132,13 @@ public class SkillController { @GetMapping("/counts") public R> counts() { Map result = skillService.countByType(); + Set realNames = realSkillNames(); // RFC-090 §3.2 — virtual MCP-derived skills aren't in mate_skill, // so countByType() misses them. Fold in the live count so the // "MCP" and "all" tab badges match what the list endpoint shows. try { - long virtualMcp = mcpSkillBridge.listMcpDerivedSkillEntities().size(); + long virtualMcp = countUnshadowedVirtualSkills( + mcpSkillBridge.listMcpDerivedSkillEntities(), realNames); if (virtualMcp > 0) { result.merge("mcp", virtualMcp, Long::sum); result.merge("all", virtualMcp, Long::sum); @@ -139,7 +147,8 @@ public class SkillController { // Bridge failure must not break the badge fetch. } try { - long virtualAcp = acpSkillBridge.listAcpDerivedSkillEntities().size(); + long virtualAcp = countUnshadowedVirtualSkills( + acpSkillBridge.listAcpDerivedSkillEntities(), realNames); if (virtualAcp > 0) { result.merge("acp", virtualAcp, Long::sum); result.merge("all", virtualAcp, Long::sum); @@ -150,6 +159,32 @@ public class SkillController { return R.ok(result); } + /** + * Real skill rows own their slug. Same-name MCP/ACP virtual rows are + * shadowed even when the real row is disabled, matching the runtime status + * view and avoiding duplicate cards for the same capability. The comparison + * is name-only because {@code mate_skill.name} is the unique skill slug. + */ + static List filterShadowedVirtualSkills(List virtualSkills, + Set realSkillNames) { + if (virtualSkills == null || virtualSkills.isEmpty()) return List.of(); + Set realNames = realSkillNames == null ? Set.of() : realSkillNames; + return virtualSkills.stream() + .filter(s -> s != null && !realNames.contains(s.getName())) + .toList(); + } + + static long countUnshadowedVirtualSkills(List virtualSkills, + Set realSkillNames) { + return filterShadowedVirtualSkills(virtualSkills, realSkillNames).size(); + } + + private Set realSkillNames() { + return skillService.listSkills().stream() + .map(SkillEntity::getName) + .collect(java.util.stream.Collectors.toSet()); + } + @Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)") @PostMapping("/{id}/rescan") public R rescan(@PathVariable Long id) { diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql b/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql index 95431be8..2684c65a 100644 --- a/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql +++ b/mateclaw-server/src/main/resources/db/migration/h2/V85__ckjia_mcp_seed.sql @@ -1,6 +1,8 @@ --- Seed default ckjia-shopping MCP server config (disabled by default). --- Admin enables in Settings > MCP Connections after pointing url to their --- ckjia instance and configuring CKJIA_MCP_KEY env var for authorization. +-- Seed ckjia-shopping MCP server config (disabled by default). +-- The localhost URL below is a dev/test placeholder only. Production admins +-- must replace it in Settings > MCP Connections with the official CKJIA SaaS +-- domain or their private CKJIA deployment URL before enabling the server, +-- then configure CKJIA_MCP_KEY for authorization. -- -- Column name is `url` (not `endpoint`) per McpServerEntity. -- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key @@ -22,7 +24,7 @@ VALUES ( 'http://localhost:8085/sse', '{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}', FALSE, - 'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).', + 'CKJIA price comparison MCP server. Disabled by default; replace the dev/test localhost URL with the production CKJIA domain before enabling.', 30, 30, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0 ); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql index a2d45f79..8b8b217b 100644 --- a/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V85__ckjia_mcp_seed.sql @@ -1,6 +1,8 @@ --- Seed default ckjia-shopping MCP server config (disabled by default). --- Admin enables in Settings > MCP Connections after pointing url to their --- ckjia instance and configuring CKJIA_MCP_KEY env var for authorization. +-- Seed ckjia-shopping MCP server config (disabled by default). +-- The localhost URL below is a dev/test placeholder only. Production admins +-- must replace it in Settings > MCP Connections with the official CKJIA SaaS +-- domain or their private CKJIA deployment URL before enabling the server, +-- then configure CKJIA_MCP_KEY for authorization. -- -- Column name is `url` (not `endpoint`) per McpServerEntity. -- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key @@ -20,7 +22,7 @@ SELECT 1000000903, 'http://localhost:8085/sse', '{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}', FALSE, - 'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).', + 'CKJIA price comparison MCP server. Disabled by default; replace the dev/test localhost URL with the production CKJIA domain before enabling.', 30, 30, TRUE, NOW(), NOW(), 0 FROM dual diff --git a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md index 6e35ffa2..19072144 100644 --- a/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md +++ b/mateclaw-server/src/main/resources/skills/ckjia-shopping/SKILL.md @@ -2,11 +2,15 @@ name: ckjia-shopping nameZh: 参考价 - 比价购物 nameEn: CKJIA Shopping -version: "1.0.0" +version: "1.0.1" icon: /skill-assets/ckjia-shopping/assets/ckjia_app_icon.png description: "跨平台比价与购物推荐 / Cross-platform price comparison. 淘宝 / 京东 / 天猫 / 拼多多商品聚合搜索 + 拍图识物。需要先启用 ckjia-shopping MCP server 并配置 CKJIA_MCP_KEY 才能用。" category: data type: mcp +allowed-tools: + - ckjia_shopping_recommend + - ckjia_image_recognize + - ckjia_ping tags: - shopping - price @@ -93,6 +97,7 @@ tags: - 同一个 query 不要在一次对话里反复调用 —— ckjia 侧已有缓存,重复调用浪费配额 - 用户未登录时不必填 `user_id`;当前 Phase 1 所有调用以 API key owner 身份执行 +- `mate_mcp_server` 里预置的 localhost URL 只用于本地开发/测试;生产启用前必须在 `Settings ▸ MCP Connections` 改成 ckjia 官方 SaaS 域名或私有部署域名 - API key 由管理员在 ckjia 控制台申请后填入 mateclaw `Settings ▸ MCP Connections` 的 `headers_json`,使用 `${CKJIA_MCP_KEY}` 环境变量占位符避免明文落库 - 触发 429 `rate_limited` 时按 `Retry-After` 等待一次,再失败就汇总现有结果而不是无限重试 @@ -102,4 +107,4 @@ tags: 2. 选 `free` / `standard` tier 与勾选所需 scopes 3. 一次性获得明文 key(形如 `ckjia_mcp_live_5fK8j2nQ…`) 4. 在 mateclaw 部署环境配 `CKJIA_MCP_KEY=ckjia_mcp_live_xxx` -5. Settings ▸ MCP Connections 启用 `ckjia-shopping` 即可 +5. Settings ▸ MCP Connections 将 `ckjia-shopping` 的 URL 改为生产域名后再启用