feat: Added McpMarketplaceModal to the CoworkToolCard for improved plugin management.

This commit is contained in:
decolua 2026-05-09 09:53:06 +07:00
parent c734913b3f
commit 145f588cc0
8 changed files with 550 additions and 1210 deletions

View file

@ -0,0 +1,255 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Modal from "./Modal";
const REGISTRY_ENDPOINT = "/api/cli-tools/cowork-mcp-registry";
const TOOLS_ENDPOINT = "/api/cli-tools/cowork-mcp-tools";
export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames = [] }) {
const [servers, setServers] = useState([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const [error, setError] = useState(null);
const [expandedUrl, setExpandedUrl] = useState(null);
const [toolsCache, setToolsCache] = useState({});
const [toolsLoading, setToolsLoading] = useState({});
const [toolSelection, setToolSelection] = useState({});
useEffect(() => {
if (!isOpen) return;
if (servers.length > 0) return;
setLoading(true);
fetch(REGISTRY_ENDPOINT)
.then((r) => r.json())
.then((d) => {
if (d.error) setError(d.error);
else setServers(d.servers || []);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [isOpen]);
const addedSet = useMemo(() => new Set(addedNames), [addedNames]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return servers.filter((s) => {
if (filter === "authless" && s.oauth) return false;
if (filter === "oauth" && !s.oauth) return false;
if (!q) return true;
return (
(s.title || "").toLowerCase().includes(q) ||
(s.description || "").toLowerCase().includes(q) ||
(s.name || "").toLowerCase().includes(q)
);
});
}, [servers, search, filter]);
const fetchTools = async (server) => {
if (toolsCache[server.url]) return;
setToolsLoading((p) => ({ ...p, [server.url]: true }));
try {
const r = await fetch(TOOLS_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: server.url }),
});
const d = await r.json();
const tools = d.tools || [];
const fallback = Array.isArray(server.toolNames) ? server.toolNames : [];
const toolNames = tools.length > 0 ? tools.map((t) => t.name) : fallback;
setToolsCache((p) => ({ ...p, [server.url]: { tools, requiresAuth: !!d.requiresAuth, error: d.error } }));
// Default: all checked
setToolSelection((p) => ({ ...p, [server.url]: Object.fromEntries(toolNames.map((t) => [t, true])) }));
} catch (e) {
setToolsCache((p) => ({ ...p, [server.url]: { tools: [], error: e.message } }));
} finally {
setToolsLoading((p) => ({ ...p, [server.url]: false }));
}
};
const expandServer = (server) => {
if (expandedUrl === server.url) {
setExpandedUrl(null);
return;
}
setExpandedUrl(server.url);
fetchTools(server);
};
const toggleTool = (url, tool) => {
setToolSelection((prev) => ({ ...prev, [url]: { ...prev[url], [tool]: !prev[url]?.[tool] } }));
};
const setAllTools = (url, value) => {
const sel = toolSelection[url] || {};
setToolSelection((prev) => ({ ...prev, [url]: Object.fromEntries(Object.keys(sel).map((t) => [t, value])) }));
};
const confirmAdd = (server) => {
const sel = toolSelection[server.url] || {};
const enabled = Object.keys(sel).filter((t) => sel[t]);
onAdd?.({
name: server.slug || server.name,
title: server.title,
description: server.description,
url: server.url,
transport: server.transport,
oauth: server.oauth,
toolNames: enabled,
});
setExpandedUrl(null);
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Browse MCP Marketplace" size="lg">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or description..."
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
<option value="all">All</option>
<option value="authless">Authless</option>
<option value="oauth">OAuth</option>
</select>
</div>
{error && (
<div className="px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600">{error}</div>
)}
{loading && (
<div className="flex items-center gap-2 text-text-muted text-xs py-4 justify-center">
<span className="material-symbols-outlined animate-spin text-[18px]">progress_activity</span>
<span>Loading registry...</span>
</div>
)}
{!loading && (
<div className="flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
{filtered.length === 0 && (
<div className="text-center text-xs text-text-muted py-6">No servers match filter</div>
)}
{filtered.map((s) => {
const added = addedSet.has(s.slug || s.name);
const expanded = expandedUrl === s.url;
const cache = toolsCache[s.url];
const isLoadingTools = toolsLoading[s.url];
const sel = toolSelection[s.url] || {};
const toolKeys = Object.keys(sel);
const selectedCount = Object.values(sel).filter(Boolean).length;
return (
<div key={s.url} className="rounded border border-transparent hover:border-border">
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
{s.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} />
) : (
<div className="size-7 rounded bg-surface shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-xs">{s.title}</span>
{s.oauth ? (
<span className="px-1 py-0.5 text-[9px] rounded bg-amber-500/10 text-amber-600">OAuth</span>
) : (
<span className="px-1 py-0.5 text-[9px] rounded bg-green-500/10 text-green-600">Authless</span>
)}
{s.toolCount > 0 && (
<span className="text-[10px] text-text-muted">{s.toolCount} tools</span>
)}
</div>
{s.description && (
<p className="text-[10px] text-text-muted line-clamp-2 mt-0.5">{s.description}</p>
)}
</div>
<button
onClick={() => added ? null : expandServer(s)}
disabled={added}
className={`shrink-0 px-2 py-1 rounded text-[10px] font-medium transition-colors ${
added
? "bg-green-500/10 text-green-600 cursor-default"
: expanded
? "bg-surface border border-border text-text-muted hover:bg-black/5"
: "bg-primary/10 border border-primary/40 text-primary hover:bg-primary/20"
}`}
>
{added ? "Added" : expanded ? "Cancel" : "+ Add"}
</button>
</div>
{expanded && (
<div className="px-3 py-2 bg-surface/40 border-t border-border flex flex-col gap-2">
{isLoadingTools && (
<div className="flex items-center gap-2 text-text-muted text-[10px] py-1">
<span className="material-symbols-outlined animate-spin text-[14px]">progress_activity</span>
<span>Probing server for tools...</span>
</div>
)}
{!isLoadingTools && cache?.requiresAuth && (
<p className="text-[10px] text-amber-600 bg-amber-500/10 px-2 py-1 rounded">
🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.
</p>
)}
{!isLoadingTools && cache?.error && !cache?.requiresAuth && (
<p className="text-[10px] text-red-600 bg-red-500/10 px-2 py-1 rounded">Probe failed: {cache.error}</p>
)}
{!isLoadingTools && toolKeys.length === 0 && !cache?.requiresAuth && !cache?.error && (
<p className="text-[10px] text-text-muted">No tools advertised by server.</p>
)}
{!isLoadingTools && toolKeys.length > 0 && (
<>
<div className="flex items-center justify-between">
<span className="text-[10px] text-text-muted">{selectedCount}/{toolKeys.length} tools enabled</span>
<div className="flex gap-1">
<button onClick={() => setAllTools(s.url, true)} className="text-[10px] text-primary hover:underline">All</button>
<span className="text-[10px] text-text-muted">·</span>
<button onClick={() => setAllTools(s.url, false)} className="text-[10px] text-primary hover:underline">None</button>
</div>
</div>
<div className="grid grid-cols-2 gap-1 max-h-40 overflow-y-auto">
{toolKeys.map((t) => (
<label key={t} className="flex items-center gap-1.5 text-[10px] cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 px-1 rounded">
<input
type="checkbox"
checked={!!sel[t]}
onChange={() => toggleTool(s.url, t)}
className="size-3"
/>
<span className="truncate">{t}</span>
</label>
))}
</div>
</>
)}
<button
onClick={() => confirmAdd(s)}
className="self-end px-2 py-1 rounded text-[10px] font-medium bg-primary text-white hover:bg-primary/90"
>
Confirm Add
</button>
</div>
)}
</div>
);
})}
</div>
)}
<div className="text-[10px] text-text-muted text-right">
{filtered.length} of {servers.length} servers
</div>
</div>
</Modal>
);
}

View file

@ -17,6 +17,7 @@ export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as ComboFormModal } from "./ComboFormModal";
export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
export { default as UsageStats } from "./UsageStats";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as NineRemoteButton } from "./NineRemoteButton";