Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/dmworkmcp/src/components/McpCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { i18n } from "@octo/base";
import McpCard from "./McpCard";
import type { McpListItem } from "../types/mcp";
import enUS from "../i18n/en-US.json";
import zhCN from "../i18n/zh-CN.json";
import "../index.css";

i18n.registerNamespace("mcp", {
"zh-CN": zhCN,
"en-US": enUS,
});
i18n.setLocale("zh-CN", { notify: false, persist: false });

const officialItem: McpListItem = {
id: "official-search",
name: "Search MCP",
slogan: "平台维护的搜索服务,提供稳定的网页与新闻检索能力。",
category: "search",
tags: ["搜索", "热门"],
toolCount: 6,
icon: "🔎",
visibility: "system",
source: "system",
creatorName: "Internal Admin",
matchReasons: ["creator:Internal Admin", "tool:web_search"],
};

const normalItem: McpListItem = {
...officialItem,
id: "community-search",
name: "Community Search MCP",
visibility: "public",
source: "space",
creatorName: "Alice",
matchReasons: ["creator:Alice", "tool:web_search"],
};

const meta = {
title: "MCP/McpCard",
component: McpCard,
parameters: { layout: "centered" },
decorators: [
(Story) => (
<div style={{ width: 360 }}>
<Story />
</div>
),
],
args: {
item: officialItem,
onClick: () => undefined,
},
} satisfies Meta<typeof McpCard>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Official: Story = {};

export const Normal: Story = {
args: { item: normalItem },
};

export const Comparison: Story = {
render: () => (
<div style={{ display: "grid", gap: 16, width: 360 }}>
<McpCard item={officialItem} onClick={() => undefined} />
<McpCard item={normalItem} onClick={() => undefined} />
</div>
),
};
26 changes: 19 additions & 7 deletions packages/dmworkmcp/src/components/McpCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React from "react";
import { Tooltip } from "@douyinfe/semi-ui";
import { IconWrenchStroked } from "@douyinfe/semi-icons";
import { Bot, Pencil, Trash2, UserRound } from "lucide-react";
import { Bot, Pencil, ShieldCheck, Trash2, UserRound } from "lucide-react";
import type { McpListItem } from "../types/mcp";
import { t } from "@octo/base";
import { IconGlyph } from "../utils/icon";
import { getMcpAvatarColor, getMcpAvatarText } from "../utils/mcpAvatar";
import { isOfficialMcp } from "../utils/publisher";

interface McpCardProps {
item: McpListItem;
Expand Down Expand Up @@ -38,9 +39,10 @@ export function parseMatchReason(reason: string): { key: string; value?: string
* the card itself doesn't already show (tool / usage_example / creator) —
* matches on name / description / tag are visible in the card body and
* don't need their own chip. */
export function MatchReasons({ reasons }: { reasons: string[] }) {
export function MatchReasons({ reasons, hideCreator = false }: { reasons: string[]; hideCreator?: boolean }) {
const revealing = reasons.filter((reason) => {
const type = reason.split(":", 1)[0];
if (hideCreator && type === "creator") return false;
return type === "tool" || type === "usage_example" || type === "creator";
});
if (!revealing.length) return null;
Expand Down Expand Up @@ -89,14 +91,15 @@ export function resolveOwner(item: McpListItem): { botName?: string; humanName?:
const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) => {
const visibleTags = item.tags.slice(0, CARD_TAG_LIMIT);
const overflowTags = item.tags.slice(CARD_TAG_LIMIT);
const owner = resolveOwner(item);
const isOfficial = isOfficialMcp(item);
const owner = isOfficial ? null : resolveOwner(item);
// `.trim()` gates the fallback avatar so a whitespace-only icon string
// (paste artifact, backend quirk) doesn't slip past the truthiness check
// and render an empty box via IconGlyph.
const hasIcon = !!item.icon?.trim();
return (
<div
className="wk-mcp-card"
className={`wk-mcp-card${isOfficial ? " wk-mcp-card--official" : ""}`}
role="button"
tabIndex={0}
onClick={() => onClick(item)}
Expand Down Expand Up @@ -133,7 +136,14 @@ const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) =>
{item.name}
</h3>
</div>
{owner && (
{isOfficial ? (
<div className="wk-mcp-card__meta-row">
<span className="wk-mcp-card__owner wk-mcp-card__owner--official">
<ShieldCheck className="wk-mcp-card__owner-official-icon" size={13} aria-hidden="true" />
<span className="wk-mcp-card__owner-name">{t("mcp.card.officialPublisher")}</span>
</span>
</div>
) : owner ? (
<div className="wk-mcp-card__meta-row">
{owner.botName && (
<span className="wk-mcp-card__owner" title={owner.botName}>
Expand All @@ -151,7 +161,7 @@ const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) =>
</span>
)}
</div>
)}
) : null}
</div>
</div>
<div className="wk-mcp-card__slogan">{item.slogan}</div>
Expand Down Expand Up @@ -182,7 +192,9 @@ const McpCard: React.FC<McpCardProps> = ({ item, onClick, onEdit, onDelete }) =>
</Tooltip>
)}
</div>
{item.matchReasons?.length ? <MatchReasons reasons={item.matchReasons} /> : null}
{item.matchReasons?.length ? (
<MatchReasons reasons={item.matchReasons} hideCreator={isOfficial} />
) : null}
<div className="wk-mcp-card__footer">
<div className="wk-mcp-card__stats">
<span
Expand Down
15 changes: 12 additions & 3 deletions packages/dmworkmcp/src/components/McpDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import React, { useEffect, useMemo, useState } from "react";
import { WKModal, WKButton, t } from "@octo/base";
import { Toast, Spin } from "@douyinfe/semi-ui";
import { IconWrenchStroked } from "@douyinfe/semi-icons";
import { Bot, UserRound } from "lucide-react";
import { Bot, ShieldCheck, UserRound } from "lucide-react";
import { deleteMcp, fetchMcpDetail } from "../api/mcpService";
import { buildQuickStartTabs, TOKEN_PLACEHOLDER_RE } from "../api/quickStartTemplates";
import type { McpDetail, McpQuickStart } from "../types/mcp";
import { IconGlyph } from "../utils/icon";
import { getMcpAvatarColor, getMcpAvatarText } from "../utils/mcpAvatar";
import { resolveOwner } from "./McpCard";
import { isOfficialMcp } from "../utils/publisher";

interface McpDetailModalProps {
/** The id of the MCP to show; null closes the modal. */
Expand Down Expand Up @@ -266,9 +267,17 @@ const McpDetailModal: React.FC<McpDetailModalProps> = ({
)}
</div>
{(() => {
const owner = resolveOwner(detail);
const isOfficial = isOfficialMcp(detail);
const owner = isOfficial ? null : resolveOwner(detail);
const parts: React.ReactNode[] = [];
if (owner?.botName) {
if (isOfficial) {
parts.push(
<span key="official" className="wk-mcp-detail__owner wk-mcp-detail__owner--official">
<ShieldCheck className="wk-mcp-card__owner-official-icon" size={13} aria-hidden="true" />
<span className="wk-mcp-card__owner-name">{t("mcp.card.officialPublisher")}</span>
</span>
);
} else if (owner?.botName) {
parts.push(
<span key="bot" className="wk-mcp-detail__owner" title={owner.botName}>
<Bot className="wk-mcp-card__owner-bot-icon" size={13} aria-hidden="true" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import React from "react";
import ReactDOM from "react-dom";
import { act } from "react-dom/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import McpCard from "../McpCard";
import McpDetailModal from "../McpDetailModal";
import type { McpDetail, McpListItem } from "../../types/mcp";

const fetchMcpDetail = vi.fn();

vi.mock("../../api/mcpService", () => ({
deleteMcp: vi.fn(),
fetchMcpDetail: (...args: unknown[]) => fetchMcpDetail(...args),
}));
vi.mock("../../api/quickStartTemplates", () => ({
buildQuickStartTabs: () => [],
TOKEN_PLACEHOLDER_RE: /$^/g,
}));
vi.mock("../../utils/icon", () => ({ IconGlyph: () => null }));
vi.mock("@douyinfe/semi-ui", () => ({
Spin: () => null,
Toast: { success: vi.fn(), error: vi.fn() },
Tooltip: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@octo/base", () => ({
t: (key: string) => (key === "mcp.card.officialPublisher" ? "官方发布" : key),
WKButton: ({ children }: { children: React.ReactNode }) =>
React.createElement("button", null, children),
WKModal: ({
children,
header,
}: {
children: React.ReactNode;
header?: React.ReactNode;
}) => React.createElement("div", null, header, children),
wkConfirm: vi.fn(),
}));

let container: HTMLDivElement | null = null;

afterEach(() => {
if (container) {
ReactDOM.unmountComponentAtNode(container);
container.remove();
container = null;
}
vi.clearAllMocks();
});

function render(element: React.ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
act(() => {
ReactDOM.render(element, container);
});
return container;
}

const baseItem: McpListItem = {
id: "mcp-1",
name: "Official MCP",
slogan: "Test MCP",
category: "dev",
tags: [],
toolCount: 1,
icon: "",
visibility: "system",
source: "system",
creatorName: "Internal Admin",
matchReasons: ["creator:Internal Admin", "tool:search"],
};

describe("official MCP publisher", () => {
it("shows official publisher on cards without leaking creator identity", () => {
const root = render(<McpCard item={baseItem} onClick={vi.fn()} />);

expect(root.querySelector(".wk-mcp-card--official")).not.toBeNull();
expect(root.textContent).toContain("官方发布");
expect(root.textContent).not.toContain("Internal Admin");
expect(root.textContent).toContain("search");
});

it("keeps normal publisher rendering for non-system MCPs", () => {
const root = render(
<McpCard
item={{ ...baseItem, visibility: "public", source: "system" }}
onClick={vi.fn()}
/>
);

expect(root.querySelector(".wk-mcp-card--official")).toBeNull();
expect(root.textContent).toContain("Internal Admin");
expect(root.textContent).not.toContain("官方发布");
});

it("keeps card keyboard activation for official MCPs", () => {
const onClick = vi.fn();
const root = render(<McpCard item={baseItem} onClick={onClick} />);
const card = root.querySelector(".wk-mcp-card") as HTMLElement;

act(() => {
card.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
);
});

expect(onClick).toHaveBeenCalledWith(baseItem);
});

it("shows the same official publisher in details", async () => {
const detail: McpDetail = {
...baseItem,
quickStart: { transport: "streamable-http", serverName: "Official MCP" },
tools: [],
usageExamples: [],
faqs: [],
notes: [],
};
fetchMcpDetail.mockResolvedValue(detail);

let root!: HTMLElement;
await act(async () => {
root = render(<McpDetailModal mcpId="mcp-1" onClose={vi.fn()} />);
await Promise.resolve();
await Promise.resolve();
});

expect(root.textContent).toContain("官方发布");
expect(root.textContent).not.toContain("Internal Admin");
});
});
1 change: 1 addition & 0 deletions packages/dmworkmcp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"clear": "Clear"
},
"card": {
"officialPublisher": "Official publisher",
"matchReason": {
"name": "Matched name",
"description": "Matched summary",
Expand Down
1 change: 1 addition & 0 deletions packages/dmworkmcp/src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"clear": "清空"
},
"card": {
"officialPublisher": "官方发布",
"matchReason": {
"name": "命中名称",
"description": "命中简介",
Expand Down
23 changes: 20 additions & 3 deletions packages/dmworkmcp/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@
border-radius: var(--wk-r-full);
background: var(--wk-bg-surface);
color: var(--wk-text-secondary);
font: 400 var(--wk-text-size-sm)/1 var(--wk-font-sans);
font: 400 var(--wk-text-size-sm) / 1 var(--wk-font-sans);
cursor: pointer;
white-space: nowrap;
transition: all var(--wk-dur-fast) var(--wk-ease);
Expand Down Expand Up @@ -651,6 +651,16 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}

.wk-mcp-card--official {
border-color: var(--wk-ai-border);
background: var(--wk-ai-surface);
}

.wk-mcp-card--official:hover,
.wk-mcp-card--official:focus-visible {
border-color: var(--wk-color-accent);
}

/* Row 1: icon + header column. Matches `.skill-market-card__top`. */
.wk-mcp-card__top {
display: flex;
Expand Down Expand Up @@ -751,10 +761,16 @@
}

.wk-mcp-card__owner-bot-icon,
.wk-mcp-card__owner-user-icon {
.wk-mcp-card__owner-user-icon,
.wk-mcp-card__owner-official-icon {
flex: 0 0 auto;
}

.wk-mcp-card__owner--official,
.wk-mcp-detail__owner--official {
color: var(--wk-text-accent);
}

.wk-mcp-card__meta-separator {
flex: 0 0 auto;
color: var(--wk-text-quaternary, var(--wk-text-tertiary));
Expand Down Expand Up @@ -845,7 +861,8 @@
color: #1f2937;
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.06);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12),
0 2px 6px rgba(15, 23, 42, 0.06);
}

/* Content already sits inside the wrapper's box; kill the redundant shadow
Expand Down
Loading
Loading