Skip to content

Commit 941eaa3

Browse files
l-s-cocto-loop-agent
andcommitted
feat(mcp): add official publisher styling for LSC-24
Co-authored-by: octo-loop-agent <loop@deepminer.com.cn>
1 parent 8f8a19c commit 941eaa3

10 files changed

Lines changed: 279 additions & 16 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from "react";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
import { i18n } from "@octo/base";
4+
import McpCard from "./McpCard";
5+
import type { McpListItem } from "../types/mcp";
6+
import enUS from "../i18n/en-US.json";
7+
import zhCN from "../i18n/zh-CN.json";
8+
import "../index.css";
9+
10+
i18n.registerNamespace("mcp", {
11+
"zh-CN": zhCN,
12+
"en-US": enUS,
13+
});
14+
i18n.setLocale("zh-CN", { notify: false, persist: false });
15+
16+
const officialItem: McpListItem = {
17+
id: "official-search",
18+
name: "Search MCP",
19+
slogan: "平台维护的搜索服务,提供稳定的网页与新闻检索能力。",
20+
category: "search",
21+
tags: ["搜索", "热门"],
22+
toolCount: 6,
23+
icon: "🔎",
24+
visibility: "system",
25+
source: "system",
26+
creatorName: "Internal Admin",
27+
matchReasons: ["creator:Internal Admin", "tool:web_search"],
28+
};
29+
30+
const normalItem: McpListItem = {
31+
...officialItem,
32+
id: "community-search",
33+
name: "Community Search MCP",
34+
visibility: "public",
35+
source: "space",
36+
creatorName: "Alice",
37+
matchReasons: ["creator:Alice", "tool:web_search"],
38+
};
39+
40+
const meta = {
41+
title: "MCP/McpCard",
42+
component: McpCard,
43+
parameters: { layout: "centered" },
44+
decorators: [
45+
(Story) => (
46+
<div style={{ width: 360 }}>
47+
<Story />
48+
</div>
49+
),
50+
],
51+
args: {
52+
item: officialItem,
53+
onClick: () => undefined,
54+
},
55+
} satisfies Meta<typeof McpCard>;
56+
57+
export default meta;
58+
type Story = StoryObj<typeof meta>;
59+
60+
export const Official: Story = {};
61+
62+
export const Normal: Story = {
63+
args: { item: normalItem },
64+
};
65+
66+
export const Comparison: Story = {
67+
render: () => (
68+
<div style={{ display: "grid", gap: 16, width: 360 }}>
69+
<McpCard item={officialItem} onClick={() => undefined} />
70+
<McpCard item={normalItem} onClick={() => undefined} />
71+
</div>
72+
),
73+
};

packages/dmworkmcp/src/components/McpCard.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React from "react";
22
import { Tooltip } from "@douyinfe/semi-ui";
33
import { IconWrenchStroked } from "@douyinfe/semi-icons";
4-
import { Bot, Pencil, Trash2, UserRound } from "lucide-react";
4+
import { Bot, Pencil, ShieldCheck, Trash2, UserRound } from "lucide-react";
55
import type { McpListItem } from "../types/mcp";
66
import { t } from "@octo/base";
77
import { IconGlyph } from "../utils/icon";
88
import { getMcpAvatarColor, getMcpAvatarText } from "../utils/mcpAvatar";
9+
import { isOfficialMcp } from "../utils/publisher";
910

1011
interface McpCardProps {
1112
item: McpListItem;
@@ -38,9 +39,10 @@ export function parseMatchReason(reason: string): { key: string; value?: string
3839
* the card itself doesn't already show (tool / usage_example / creator) —
3940
* matches on name / description / tag are visible in the card body and
4041
* don't need their own chip. */
41-
export function MatchReasons({ reasons }: { reasons: string[] }) {
42+
export function MatchReasons({ reasons, hideCreator = false }: { reasons: string[]; hideCreator?: boolean }) {
4243
const revealing = reasons.filter((reason) => {
4344
const type = reason.split(":", 1)[0];
45+
if (hideCreator && type === "creator") return false;
4446
return type === "tool" || type === "usage_example" || type === "creator";
4547
});
4648
if (!revealing.length) return null;
@@ -89,14 +91,15 @@ export function resolveOwner(item: McpListItem): { botName?: string; humanName?:
8991
const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) => {
9092
const visibleTags = item.tags.slice(0, CARD_TAG_LIMIT);
9193
const overflowTags = item.tags.slice(CARD_TAG_LIMIT);
92-
const owner = resolveOwner(item);
94+
const isOfficial = isOfficialMcp(item);
95+
const owner = isOfficial ? null : resolveOwner(item);
9396
// `.trim()` gates the fallback avatar so a whitespace-only icon string
9497
// (paste artifact, backend quirk) doesn't slip past the truthiness check
9598
// and render an empty box via IconGlyph.
9699
const hasIcon = !!item.icon?.trim();
97100
return (
98101
<div
99-
className="wk-mcp-card"
102+
className={`wk-mcp-card${isOfficial ? " wk-mcp-card--official" : ""}`}
100103
role="button"
101104
tabIndex={0}
102105
onClick={() => onClick(item)}
@@ -133,7 +136,14 @@ const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) =>
133136
{item.name}
134137
</h3>
135138
</div>
136-
{owner && (
139+
{isOfficial ? (
140+
<div className="wk-mcp-card__meta-row">
141+
<span className="wk-mcp-card__owner wk-mcp-card__owner--official">
142+
<ShieldCheck className="wk-mcp-card__owner-official-icon" size={13} aria-hidden="true" />
143+
<span className="wk-mcp-card__owner-name">{t("mcp.card.officialPublisher")}</span>
144+
</span>
145+
</div>
146+
) : owner ? (
137147
<div className="wk-mcp-card__meta-row">
138148
{owner.botName && (
139149
<span className="wk-mcp-card__owner" title={owner.botName}>
@@ -151,7 +161,7 @@ const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) =>
151161
</span>
152162
)}
153163
</div>
154-
)}
164+
) : null}
155165
</div>
156166
</div>
157167
<div className="wk-mcp-card__slogan">{item.slogan}</div>
@@ -182,7 +192,9 @@ const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) =>
182192
</Tooltip>
183193
)}
184194
</div>
185-
{item.matchReasons?.length ? <MatchReasons reasons={item.matchReasons} /> : null}
195+
{item.matchReasons?.length ? (
196+
<MatchReasons reasons={item.matchReasons} hideCreator={isOfficial} />
197+
) : null}
186198
<div className="wk-mcp-card__footer">
187199
<div className="wk-mcp-card__stats">
188200
<span

packages/dmworkmcp/src/components/McpDetailModal.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import React, { useEffect, useMemo, useState } from "react";
22
import { WKModal, WKButton, t } from "@octo/base";
33
import { Toast, Spin } from "@douyinfe/semi-ui";
44
import { IconWrenchStroked } from "@douyinfe/semi-icons";
5-
import { Bot, UserRound } from "lucide-react";
5+
import { Bot, ShieldCheck, UserRound } from "lucide-react";
66
import { deleteMcp, fetchMcpDetail } from "../api/mcpService";
77
import { buildQuickStartTabs, TOKEN_PLACEHOLDER_RE } from "../api/quickStartTemplates";
88
import type { McpDetail, McpQuickStart } from "../types/mcp";
99
import { IconGlyph } from "../utils/icon";
1010
import { getMcpAvatarColor, getMcpAvatarText } from "../utils/mcpAvatar";
1111
import { resolveOwner } from "./McpCard";
12+
import { isOfficialMcp } from "../utils/publisher";
1213

1314
interface McpDetailModalProps {
1415
/** The id of the MCP to show; null closes the modal. */
@@ -266,9 +267,17 @@ const McpDetailModal: React.FC<McpDetailModalProps> = ({
266267
)}
267268
</div>
268269
{(() => {
269-
const owner = resolveOwner(detail);
270+
const isOfficial = isOfficialMcp(detail);
271+
const owner = isOfficial ? null : resolveOwner(detail);
270272
const parts: React.ReactNode[] = [];
271-
if (owner?.botName) {
273+
if (isOfficial) {
274+
parts.push(
275+
<span key="official" className="wk-mcp-detail__owner wk-mcp-detail__owner--official">
276+
<ShieldCheck className="wk-mcp-card__owner-official-icon" size={13} aria-hidden="true" />
277+
<span className="wk-mcp-card__owner-name">{t("mcp.card.officialPublisher")}</span>
278+
</span>
279+
);
280+
} else if (owner?.botName) {
272281
parts.push(
273282
<span key="bot" className="wk-mcp-detail__owner" title={owner.botName}>
274283
<Bot className="wk-mcp-card__owner-bot-icon" size={13} aria-hidden="true" />
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// @vitest-environment jsdom
2+
import React from "react";
3+
import ReactDOM from "react-dom";
4+
import { act } from "react-dom/test-utils";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import McpCard from "../McpCard";
7+
import McpDetailModal from "../McpDetailModal";
8+
import type { McpDetail, McpListItem } from "../../types/mcp";
9+
10+
const fetchMcpDetail = vi.fn();
11+
12+
vi.mock("../../api/mcpService", () => ({
13+
deleteMcp: vi.fn(),
14+
fetchMcpDetail: (...args: unknown[]) => fetchMcpDetail(...args),
15+
}));
16+
vi.mock("../../api/quickStartTemplates", () => ({
17+
buildQuickStartTabs: () => [],
18+
TOKEN_PLACEHOLDER_RE: /$^/g,
19+
}));
20+
vi.mock("../../utils/icon", () => ({ IconGlyph: () => null }));
21+
vi.mock("@douyinfe/semi-ui", () => ({
22+
Spin: () => null,
23+
Toast: { success: vi.fn(), error: vi.fn() },
24+
Tooltip: ({ children }: { children: React.ReactNode }) => children,
25+
}));
26+
vi.mock("@octo/base", () => ({
27+
t: (key: string) => (key === "mcp.card.officialPublisher" ? "官方发布" : key),
28+
WKButton: ({ children }: { children: React.ReactNode }) =>
29+
React.createElement("button", null, children),
30+
WKModal: ({
31+
children,
32+
header,
33+
}: {
34+
children: React.ReactNode;
35+
header?: React.ReactNode;
36+
}) => React.createElement("div", null, header, children),
37+
wkConfirm: vi.fn(),
38+
}));
39+
40+
let container: HTMLDivElement | null = null;
41+
42+
afterEach(() => {
43+
if (container) {
44+
ReactDOM.unmountComponentAtNode(container);
45+
container.remove();
46+
container = null;
47+
}
48+
vi.clearAllMocks();
49+
});
50+
51+
function render(element: React.ReactElement) {
52+
container = document.createElement("div");
53+
document.body.appendChild(container);
54+
act(() => {
55+
ReactDOM.render(element, container);
56+
});
57+
return container;
58+
}
59+
60+
const baseItem: McpListItem = {
61+
id: "mcp-1",
62+
name: "Official MCP",
63+
slogan: "Test MCP",
64+
category: "dev",
65+
tags: [],
66+
toolCount: 1,
67+
icon: "",
68+
visibility: "system",
69+
source: "system",
70+
creatorName: "Internal Admin",
71+
matchReasons: ["creator:Internal Admin", "tool:search"],
72+
};
73+
74+
describe("official MCP publisher", () => {
75+
it("shows official publisher on cards without leaking creator identity", () => {
76+
const root = render(<McpCard item={baseItem} onClick={vi.fn()} />);
77+
78+
expect(root.querySelector(".wk-mcp-card--official")).not.toBeNull();
79+
expect(root.textContent).toContain("官方发布");
80+
expect(root.textContent).not.toContain("Internal Admin");
81+
expect(root.textContent).toContain("search");
82+
});
83+
84+
it("keeps normal publisher rendering for non-system MCPs", () => {
85+
const root = render(
86+
<McpCard
87+
item={{ ...baseItem, visibility: "public", source: "system" }}
88+
onClick={vi.fn()}
89+
/>
90+
);
91+
92+
expect(root.querySelector(".wk-mcp-card--official")).toBeNull();
93+
expect(root.textContent).toContain("Internal Admin");
94+
expect(root.textContent).not.toContain("官方发布");
95+
});
96+
97+
it("keeps card keyboard activation for official MCPs", () => {
98+
const onClick = vi.fn();
99+
const root = render(<McpCard item={baseItem} onClick={onClick} />);
100+
const card = root.querySelector(".wk-mcp-card") as HTMLElement;
101+
102+
act(() => {
103+
card.dispatchEvent(
104+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
105+
);
106+
});
107+
108+
expect(onClick).toHaveBeenCalledWith(baseItem);
109+
});
110+
111+
it("shows the same official publisher in details", async () => {
112+
const detail: McpDetail = {
113+
...baseItem,
114+
quickStart: { transport: "streamable-http", serverName: "Official MCP" },
115+
tools: [],
116+
usageExamples: [],
117+
faqs: [],
118+
notes: [],
119+
};
120+
fetchMcpDetail.mockResolvedValue(detail);
121+
122+
let root!: HTMLElement;
123+
await act(async () => {
124+
root = render(<McpDetailModal mcpId="mcp-1" onClose={vi.fn()} />);
125+
await Promise.resolve();
126+
await Promise.resolve();
127+
});
128+
129+
expect(root.textContent).toContain("官方发布");
130+
expect(root.textContent).not.toContain("Internal Admin");
131+
});
132+
});

packages/dmworkmcp/src/i18n/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"clear": "Clear"
8888
},
8989
"card": {
90+
"officialPublisher": "Official publisher",
9091
"matchReason": {
9192
"name": "Matched name",
9293
"description": "Matched summary",

packages/dmworkmcp/src/i18n/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"clear": "清空"
8888
},
8989
"card": {
90+
"officialPublisher": "官方发布",
9091
"matchReason": {
9192
"name": "命中名称",
9293
"description": "命中简介",

packages/dmworkmcp/src/index.css

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@
560560
border-radius: var(--wk-r-full);
561561
background: var(--wk-bg-surface);
562562
color: var(--wk-text-secondary);
563-
font: 400 var(--wk-text-size-sm)/1 var(--wk-font-sans);
563+
font: 400 var(--wk-text-size-sm) / 1 var(--wk-font-sans);
564564
cursor: pointer;
565565
white-space: nowrap;
566566
transition: all var(--wk-dur-fast) var(--wk-ease);
@@ -651,6 +651,16 @@
651651
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
652652
}
653653

654+
.wk-mcp-card--official {
655+
border-color: var(--wk-ai-border);
656+
background: var(--wk-ai-surface);
657+
}
658+
659+
.wk-mcp-card--official:hover,
660+
.wk-mcp-card--official:focus-visible {
661+
border-color: var(--wk-color-accent);
662+
}
663+
654664
/* Row 1: icon + header column. Matches `.skill-market-card__top`. */
655665
.wk-mcp-card__top {
656666
display: flex;
@@ -751,10 +761,16 @@
751761
}
752762

753763
.wk-mcp-card__owner-bot-icon,
754-
.wk-mcp-card__owner-user-icon {
764+
.wk-mcp-card__owner-user-icon,
765+
.wk-mcp-card__owner-official-icon {
755766
flex: 0 0 auto;
756767
}
757768

769+
.wk-mcp-card__owner--official,
770+
.wk-mcp-detail__owner--official {
771+
color: var(--wk-text-accent);
772+
}
773+
758774
.wk-mcp-card__meta-separator {
759775
flex: 0 0 auto;
760776
color: var(--wk-text-quaternary, var(--wk-text-tertiary));
@@ -845,7 +861,8 @@
845861
color: #1f2937;
846862
border: 1px solid rgba(15, 23, 42, 0.06);
847863
border-radius: 8px;
848-
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.06);
864+
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12),
865+
0 2px 6px rgba(15, 23, 42, 0.06);
849866
}
850867

851868
/* Content already sits inside the wrapper's box; kill the redundant shadow

0 commit comments

Comments
 (0)