feat(skill,mcp): ckjia-shopping skill bundle + same-name dedup against bridged MCP/ACP virtual skills

This commit is contained in:
matevip 2026-05-03 17:14:19 +08:00
parent 03a82ece93
commit c07048f258
8 changed files with 134 additions and 6 deletions

View File

@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
@ -39,4 +40,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
.allowCredentials(true)
.maxAge(3600);
}
/**
* Expose per-skill bundled assets (logos, screenshots, ...) at
* {@code /skill-assets/<skillName>/...}.
*
* <p>Source layout: {@code src/main/resources/skills/<name>/assets/<file>}.
* Built-in skills can ship icons / hero images alongside SKILL.md without
* polluting {@code src/main/resources/static/} (gitignored that path is
* the mateclaw-ui Vite build output).
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/skill-assets/**")
.addResourceLocations("classpath:/skills/")
.setCachePeriod(86400);
}
}

View File

@ -202,19 +202,29 @@ public class SkillRuntimeService {
.map(packageResolver::resolve)
.filter(SkillRuntimeService::passesActiveGate)
.collect(Collectors.toList());
// Track real skill names so a same-named bridged virtual skill is
// suppressed (real wins). Without this, a real SKILL.md packaged
// alongside a same-name MCP/ACP server produces two cards on the
// Skills page.
Set<String> realNames = resolved.stream()
.map(ResolvedSkill::getName)
.collect(Collectors.toSet());
try {
// RFC-090 §3.2 MCP-derived virtual skills go through the
// same active gate so a disconnected MCP server doesn't
// pollute the prompt enhancement.
// MCP-derived virtual skills go through the same active gate
// so a disconnected MCP server doesn't pollute the prompt
// enhancement. Same-name virtuals are suppressed by the real
// skill above.
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
if (passesActiveGate(virt)) resolved.add(virt);
}
} catch (Exception e) {
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
}
try {
// RFC-090 §3.2 (parallel) ACP-derived virtual skills.
// ACP-derived virtual skills. Same dedup as MCP.
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
if (passesActiveGate(virt)) resolved.add(virt);
}
} catch (Exception e) {
@ -240,13 +250,25 @@ public class SkillRuntimeService {
List<ResolvedSkill> resolved = allSkills.stream()
.map(packageResolver::resolve)
.collect(Collectors.toList());
// Same dedup-by-name as refreshActiveSkills(): a real skill with
// the same name as a bridged virtual one suppresses the virtual,
// so the Skills admin page never shows two cards for the same name.
Set<String> realNames = resolved.stream()
.map(ResolvedSkill::getName)
.collect(Collectors.toSet());
try {
resolved.addAll(mcpSkillBridge.listMcpDerivedResolvedSkills());
for (ResolvedSkill virt : mcpSkillBridge.listMcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
resolved.add(virt);
}
} catch (Exception e) {
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
}
try {
resolved.addAll(acpSkillBridge.listAcpDerivedResolvedSkills());
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
if (realNames.contains(virt.getName())) continue;
resolved.add(virt);
}
} catch (Exception e) {
log.warn("ACP skill bridge merge failed: {}", e.getMessage());
}

View File

@ -0,0 +1,22 @@
-- 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.
--
-- Column name is `url` (not `endpoint`) per McpServerEntity.
-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key
-- never lands in the database (parseHeaders expands env vars at request time).
MERGE INTO mate_mcp_server (
name, transport, url, headers_json, enabled, description,
connect_timeout_seconds, read_timeout_seconds, builtin, create_time, update_time, deleted
) KEY(name)
VALUES (
'ckjia-shopping',
'sse',
'http://localhost:8088/mcp/sse',
'{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}',
FALSE,
'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).',
30, 30, TRUE,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0
);

View File

@ -0,0 +1,22 @@
-- 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.
--
-- Column name is `url` (not `endpoint`) per McpServerEntity.
-- headers_json uses ${CKJIA_MCP_KEY} placeholder so the plaintext API key
-- never lands in the database (parseHeaders expands env vars at request time).
INSERT INTO mate_mcp_server (
name, transport, url, headers_json, enabled, description,
connect_timeout_seconds, read_timeout_seconds, builtin, create_time, update_time, deleted
)
SELECT 'ckjia-shopping',
'sse',
'http://localhost:8088/mcp/sse',
'{"Authorization": "Bearer ${CKJIA_MCP_KEY}"}',
FALSE,
'CKJIA cross-platform price comparison MCP server (Taobao/JD/Tmall/Pinduoduo).',
30, 30, TRUE,
NOW(), NOW(), 0
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM mate_mcp_server WHERE name = 'ckjia-shopping');

View File

@ -0,0 +1,45 @@
---
name: ckjia-shopping
version: "1.0.0"
icon: /skill-assets/ckjia-shopping/assets/ckjia_logo_new.png
description: "跨平台比价与购物推荐 / Cross-platform price comparison and shopping recommendation. 淘宝 / 京东 / 天猫 / 拼多多商品聚合搜索拍图识物AI 购物意图识别。Phase 1 ships shopping_recommend + image_recognize; unified_search / price_history / compare_skus 在后续迭代追加。"
category: data
type: mcp
dependencies:
tools:
- ckjia_shopping_recommend
- ckjia_image_recognize
- ckjia_ping
---
# 查价 · 比价购物
当用户询问"X 多少钱 / 哪里便宜 / 帮我推荐 X / 这个值不值买 / 拍照认一下这是什么"时使用本技能。
## 决策树
1. **"推荐 / 帮我挑 / 性价比 / 想买 X"** → `ckjia_shopping_recommend(query, top_n=5)`
- 想要 ckjia 顺便给出意图理解(用于澄清后续问句)→ `include_intent=true`
2. **附带图片 / 拍照识物**`ckjia_image_recognize(image_url)` → 拿到 `suggested_query` 后再 `ckjia_shopping_recommend(suggested_query)`
3. **transport 健康自检**`ckjia_ping("hello")`,验证 MCP 链路通
## 输出渲染建议
- `recommendations: ProductCard[]` 在 chat UI 自动渲染成卡片网格(移动端单列、桌面端多列)
- `intent` 字段是给 agent 自己看的"我理解对了吗"——拿到后若 `budgetRange` 与用户原话不符,应主动澄清
- `needsClarification=true` 时,把 `clarificationQuestion` + `clarificationOptions` 直接抛给用户选
## 注意
- 同一个 query 不要在一次对话里反复调用 —— ckjia 侧已有缓存,重复调用浪费配额
- 用户未登录时不必填 `user_id`;当前 Phase 1 所有调用以 API key owner 身份执行
- API key 由管理员在 ckjia 控制台申请后填入 mateclaw `Settings ▸ MCP Connections``headers_json`,使用 `${CKJIA_MCP_KEY}` 环境变量占位符避免明文落库
- 触发 429 `rate_limited` 时按 `Retry-After` 等待一次,再失败就汇总现有结果而不是无限重试
## 如何申请 API Key
1. 访问 ckjia 控制台 `https://ckjia.com/console/mcp-keys`(自助申请页面 P2 落地后开放Phase 1 需联系运维手工签发)
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` 即可

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB