mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 23:29:16 +03:00
feat(webui): add MCP management dialog
This commit is contained in:
@@ -56,7 +56,6 @@ _MCP_ATTACHMENT_KEYS = (
|
||||
"status",
|
||||
"configured",
|
||||
)
|
||||
_MAX_TEST_TOOLS = 16
|
||||
_DEFAULT_TEST_TIMEOUT = 20
|
||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||
@@ -1097,7 +1096,7 @@ async def mcp_presets_test_action(
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||
"""Connect to an enabled MCP preset and report its complete tool surface."""
|
||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||
|
||||
name = (_query_first(query, "name") or "").strip()
|
||||
@@ -1157,9 +1156,10 @@ async def mcp_presets_test_action(
|
||||
|
||||
registry = ToolRegistry()
|
||||
stacks: dict[str, Any] = {}
|
||||
inspection_cfg = cfg.model_copy(update={"enabled_tools": ["*"]})
|
||||
try:
|
||||
stacks = await asyncio.wait_for(
|
||||
connect_mcp_servers({name: cfg}, registry),
|
||||
connect_mcp_servers({name: inspection_cfg}, registry),
|
||||
timeout=_test_timeout(cfg),
|
||||
)
|
||||
tool_prefix = f"mcp_{name}_"
|
||||
@@ -1178,7 +1178,7 @@ async def mcp_presets_test_action(
|
||||
else f"{display_name} connected, but reported no tools."
|
||||
),
|
||||
"tool_count": len(tool_names),
|
||||
"tool_names": tool_names[:_MAX_TEST_TOOLS],
|
||||
"tool_names": tool_names,
|
||||
"checked_at": _checked_at(),
|
||||
}
|
||||
else:
|
||||
|
||||
@@ -10,7 +10,7 @@ from mcp.shared.auth import OAuthToken
|
||||
|
||||
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.webui.mcp_presets_api import (
|
||||
McpPresetError,
|
||||
custom_mcp_action,
|
||||
@@ -454,6 +454,46 @@ def test_test_mcp_preset_connects_and_reports_tools(
|
||||
assert payload["last_action"]["tool_names"] == ["mcp_playwright_browser_navigate"]
|
||||
|
||||
|
||||
def test_test_mcp_preset_inspects_tools_outside_the_enabled_allowlist(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_config(tmp_path, monkeypatch)
|
||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||
config = load_config()
|
||||
config.tools.mcp_servers["playwright"].enabled_tools = [
|
||||
"mcp_playwright_browser_navigate",
|
||||
]
|
||||
save_config(config)
|
||||
|
||||
class FakeStack:
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_connect(servers, registry):
|
||||
assert servers["playwright"].enabled_tools == ["*"]
|
||||
|
||||
class FakeTool:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def to_schema(self):
|
||||
return {"name": self.name, "description": "", "parameters": {}}
|
||||
|
||||
for index in range(20):
|
||||
registry.register(FakeTool(f"mcp_playwright_tool_{index:02d}"))
|
||||
return {"playwright": FakeStack()}
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", fake_connect)
|
||||
|
||||
payload = asyncio.run(mcp_presets_test_action({"name": ["playwright"]}))
|
||||
|
||||
assert payload["last_action"]["tool_count"] == 20
|
||||
assert len(payload["last_action"]["tool_names"]) == 20
|
||||
row = next(item for item in payload["presets"] if item["name"] == "playwright")
|
||||
assert row["enabled_tools"] == ["mcp_playwright_browser_navigate"]
|
||||
|
||||
|
||||
def test_test_mcp_preset_scrubs_connection_errors(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
PauseCircle,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
@@ -36,6 +34,10 @@ import {
|
||||
SETTINGS_SEARCH_INPUT_CLASS,
|
||||
SettingsSectionTitle,
|
||||
} from "@/components/settings/shared/SettingsControls";
|
||||
import {
|
||||
McpManagementDialog,
|
||||
type McpManagementTab,
|
||||
} from "@/components/settings/system/McpManagementDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -521,8 +523,8 @@ function McpAppsCatalogRow({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [managementTab, setManagementTab] = useState<McpManagementTab>("overview");
|
||||
const enableBusy = actionKey === `enable:${preset.name}`;
|
||||
const disableBusy = actionKey === `disable:${preset.name}`;
|
||||
const removeBusy = actionKey === `remove:${preset.name}`;
|
||||
@@ -546,18 +548,14 @@ function McpAppsCatalogRow({
|
||||
const statusLabel = toggleable
|
||||
? tx("settings.nanobotFeatures.enabled", "Enabled")
|
||||
: runtimeConnected
|
||||
? tx("settings.mcp.connected", "Connected.")
|
||||
? tx("connection.open", "Connected")
|
||||
: mcpPresetStatusLabel(preset.status, tx);
|
||||
const failureLabel = tx("settings.mcp.connectionFailed", "Connection failed.");
|
||||
const reconnectLabel = tx("settings.mcp.reconnect", "Reconnect");
|
||||
const canEnable =
|
||||
preset.install_supported &&
|
||||
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
||||
const toolNames = preset.tool_names ?? [];
|
||||
const enabledTools = preset.enabled_tools ?? ["*"];
|
||||
const allowAllTools = enabledTools.includes("*");
|
||||
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
|
||||
const description = preset.description || preset.note || preset.name;
|
||||
const failureStatusLabel = failureLabel.replace(/[.!。!]+$/u, "");
|
||||
const description = tx(
|
||||
`settings.mcp.presetDescriptions.${preset.name}`,
|
||||
preset.description || preset.note || preset.name,
|
||||
);
|
||||
const detail = agentPlugin && preset.requires
|
||||
? `${description} · ${preset.requires}`
|
||||
: description || preset.requires;
|
||||
@@ -567,32 +565,21 @@ function McpAppsCatalogRow({
|
||||
const callbackHelpId = `${callbackInputId}-help`;
|
||||
const callbackErrorId = `${callbackInputId}-error`;
|
||||
|
||||
useEffect(() => {
|
||||
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
||||
}, [preset.configured, preset.install_supported]);
|
||||
|
||||
const enableOrOpenSetup = () => {
|
||||
if (isOAuth) {
|
||||
onOAuthConnect(preset.name);
|
||||
return;
|
||||
}
|
||||
if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
|
||||
setSetupOpen(true);
|
||||
setManagementTab("connection");
|
||||
setManagementOpen(true);
|
||||
return;
|
||||
}
|
||||
onAction("enable", preset.name, values);
|
||||
};
|
||||
const submitSetup = () => {
|
||||
if (!canEnable) return;
|
||||
onAction("enable", preset.name, values);
|
||||
};
|
||||
const setTools = (next: string[]) => onToolsChange(preset.name, next);
|
||||
const toggleTool = (toolName: string) => {
|
||||
const next = new Set(allowAllTools ? toolNames : enabledTools);
|
||||
if (next.has(toolName)) next.delete(toolName);
|
||||
else next.add(toolName);
|
||||
const nextValues = Array.from(next);
|
||||
setTools(nextValues.length === toolNames.length ? ["*"] : nextValues);
|
||||
const openManagement = (tab: McpManagementTab = "overview") => {
|
||||
setManagementTab(tab);
|
||||
setManagementOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -660,112 +647,56 @@ function McpAppsCatalogRow({
|
||||
</AppsActionButton>
|
||||
</>
|
||||
) : runtimeFailed && configuredInstalled ? (
|
||||
<>
|
||||
<AppsActionButton
|
||||
ariaLabel={t("settings.mcp.reconnectTitle", {
|
||||
name: preset.display_name,
|
||||
defaultValue: "Reconnect {{name}}",
|
||||
})}
|
||||
visibleLabel={reconnectLabel}
|
||||
busy={isOAuth ? oauthBusy : reconnectBusy}
|
||||
disabled={anotherOAuthBusy || (busy && !oauthBusy && !reconnectBusy)}
|
||||
onClick={() => {
|
||||
if (isOAuth) onOAuthConnect(preset.name, true);
|
||||
else onAction("reconnect", preset.name);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<AppsActionButton
|
||||
ariaLabel={t("settings.mcp.actionsTitle", {
|
||||
name: preset.display_name,
|
||||
defaultValue: "Actions for {{name}}",
|
||||
})}
|
||||
busy={testBusy || toolsBusy || removeBusy}
|
||||
disabled={busy}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.mcp.test", "Test")}
|
||||
</DropdownMenuItem>
|
||||
{toolNames.length ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||
<SlidersHorizontal aria-hidden />
|
||||
{tx("settings.mcp.toolScope", "Tools")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onAction("remove", preset.name)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
{tx("settings.mcp.remove", "Remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
<AppsActionButton
|
||||
ariaLabel={t("settings.mcp.manageTitle", {
|
||||
name: preset.display_name,
|
||||
defaultValue: "Manage {{name}}",
|
||||
})}
|
||||
visibleLabel={tx("settings.mcp.fixConnection", "Fix connection")}
|
||||
disabled={anotherOAuthBusy}
|
||||
onClick={() => openManagement("connection")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
) : readyInstalled ? (
|
||||
<>
|
||||
toggleable ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<AppsActionButton
|
||||
ariaLabel={`${preset.display_name}: ${statusLabel}`}
|
||||
visibleLabel={statusLabel}
|
||||
busy={testBusy || toolsBusy || disableBusy}
|
||||
busy={disableBusy}
|
||||
disabled={busy}
|
||||
tone={toggleable || runtimeConnected ? "installed" : "default"}
|
||||
tone="installed"
|
||||
>
|
||||
{toggleable || runtimeConnected ? (
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<Server className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!toggleable ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.mcp.test", "Test")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{!toggleable && toolNames.length ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||
<SlidersHorizontal aria-hidden />
|
||||
{tx("settings.mcp.toolScope", "Tools")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
tone={toggleable ? undefined : "destructive"}
|
||||
disabled={busy}
|
||||
onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)}
|
||||
onClick={() => onAction("disable", preset.name)}
|
||||
>
|
||||
{toggleable ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||
{toggleable
|
||||
? tx("settings.nanobotFeatures.disable", "Disable")
|
||||
: tx("settings.mcp.remove", "Remove")}
|
||||
<PauseCircle aria-hidden />
|
||||
{tx("settings.nanobotFeatures.disable", "Disable")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!toggleable ? (
|
||||
<AppsActionButton
|
||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||
busy={removeBusy}
|
||||
disabled={busy && !removeBusy}
|
||||
tone="danger"
|
||||
onClick={() => onAction("remove", preset.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<AppsActionButton
|
||||
ariaLabel={t("settings.mcp.manageTitle", {
|
||||
name: preset.display_name,
|
||||
defaultValue: "Manage {{name}}",
|
||||
})}
|
||||
visibleLabel={tx("settings.mcp.manage", "Manage")}
|
||||
busy={testBusy || toolsBusy || removeBusy || reconnectBusy}
|
||||
disabled={busy}
|
||||
tone={runtimeConnected ? "installed" : "default"}
|
||||
onClick={() => openManagement("overview")}
|
||||
>
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
</AppsActionButton>
|
||||
)
|
||||
) : preset.enabled === false ? (
|
||||
<AppsActionButton
|
||||
ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
|
||||
@@ -790,7 +721,7 @@ function McpAppsCatalogRow({
|
||||
visibleLabel={hasFields ? tx("settings.mcp.configure", "Connect") : tx("settings.mcp.enable", "Enable")}
|
||||
busy={enableBusy}
|
||||
onClick={() => {
|
||||
if (hasFields) setSetupOpen(true);
|
||||
if (hasFields) openManagement("connection");
|
||||
else onAction("enable", preset.name, values);
|
||||
}}
|
||||
/>
|
||||
@@ -920,128 +851,22 @@ function McpAppsCatalogRow({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{setupOpen && preset.install_supported && hasFields ? (
|
||||
<div className="mx-3 mb-3 rounded-[14px] bg-background/55 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12.5px] font-semibold text-foreground">
|
||||
{t("settings.mcp.connectTitle", {
|
||||
name: preset.display_name,
|
||||
defaultValue: "Connect {{name}}",
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{tx("settings.mcp.connectHint", "Add the key from your account settings.")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setSetupOpen(false)}
|
||||
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold text-muted-foreground"
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2">
|
||||
{preset.required_fields.map((field) => (
|
||||
<label key={field.name} className="min-w-0">
|
||||
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
{field.configured ? (
|
||||
<span className="ml-1 font-normal text-emerald-600 dark:text-emerald-300">
|
||||
{tx("settings.mcp.configured", "configured")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Input
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={values[field.name] ?? ""}
|
||||
onChange={(event) => onFieldChange(preset.name, field.name, event.target.value)}
|
||||
placeholder={
|
||||
field.configured
|
||||
? tx("settings.mcp.keepExisting", "Leave blank to keep existing")
|
||||
: field.placeholder
|
||||
}
|
||||
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !canEnable}
|
||||
onClick={submitSetup}
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{enableBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{preset.installed
|
||||
? tx("settings.mcp.updateSetup", "Update setup")
|
||||
: tx("settings.mcp.saveAndEnable", "Save and enable")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{toolsOpen && configuredInstalled && toolNames.length ? (
|
||||
<div className="mx-3 mb-3 rounded-[14px] bg-background/55 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[11.5px] font-medium text-muted-foreground">
|
||||
{tx("settings.mcp.toolScope", "Tools")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={allowAllTools ? "default" : "outline"}
|
||||
disabled={toolsBusy}
|
||||
onClick={() => setTools(["*"])}
|
||||
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
|
||||
>
|
||||
{tx("settings.mcp.allTools", "All")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={!allowAllTools && enabledSet.size === 0 ? "default" : "outline"}
|
||||
disabled={toolsBusy}
|
||||
onClick={() => setTools([])}
|
||||
className="h-7 rounded-full px-2.5 text-[11.5px] font-semibold"
|
||||
>
|
||||
{tx("settings.mcp.noTools", "None")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{toolNames.map((toolName) => {
|
||||
const selected = enabledSet.has(toolName);
|
||||
return (
|
||||
<button
|
||||
key={toolName}
|
||||
type="button"
|
||||
disabled={toolsBusy}
|
||||
onClick={() => toggleTool(toolName)}
|
||||
className={cn(
|
||||
"max-w-full rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors",
|
||||
selected
|
||||
? "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300"
|
||||
: "border-border/55 bg-muted/30 text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<span className="block max-w-[220px] truncate">{toolName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{managementOpen ? (
|
||||
<McpManagementDialog
|
||||
preset={preset}
|
||||
values={values}
|
||||
actionKey={actionKey}
|
||||
statusLabel={runtimeFailed && configuredInstalled ? failureStatusLabel : statusLabel}
|
||||
statusTone={runtimeFailed ? "warning" : configuredInstalled ? "success" : "neutral"}
|
||||
tab={managementTab}
|
||||
icon={<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} compact />}
|
||||
onTabChange={setManagementTab}
|
||||
onOpenChange={setManagementOpen}
|
||||
onFieldChange={onFieldChange}
|
||||
onAction={onAction}
|
||||
onOAuthConnect={onOAuthConnect}
|
||||
onToolsChange={onToolsChange}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
@@ -1502,7 +1327,15 @@ function mcpPresetStatusLabel(
|
||||
}
|
||||
}
|
||||
|
||||
function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; showBrandLogos: boolean }) {
|
||||
function McpPresetLogo({
|
||||
preset,
|
||||
showBrandLogos,
|
||||
compact = false,
|
||||
}: {
|
||||
preset: McpPresetInfo;
|
||||
showBrandLogos: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const bg = preset.brand_color || "hsl(var(--muted))";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
@@ -1517,14 +1350,17 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
||||
if ((showBrandLogos || packagedLogo) && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center border border-border/45 bg-background",
|
||||
compact ? "h-10 w-10 rounded-[10px]" : "h-11 w-11 rounded-[8px]",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-6 w-6 object-contain"
|
||||
className={cn("object-contain", compact ? "h-[22px] w-[22px]" : "h-6 w-6")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
@@ -1533,7 +1369,12 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center font-semibold text-white",
|
||||
compact
|
||||
? "h-10 w-10 rounded-[10px] text-[12px]"
|
||||
: "h-11 w-11 rounded-[8px] text-[13px]",
|
||||
)}
|
||||
style={{ backgroundColor: bg }}
|
||||
>
|
||||
{initials}
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import type { McpPresetInfo } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type McpManagementTab = "overview" | "tools" | "connection";
|
||||
|
||||
type McpAction = "enable" | "disable" | "remove" | "test" | "reconnect";
|
||||
|
||||
interface McpManagementDialogProps {
|
||||
preset: McpPresetInfo;
|
||||
values: Record<string, string>;
|
||||
actionKey: string | null;
|
||||
statusLabel: string;
|
||||
statusTone: "success" | "warning" | "neutral";
|
||||
tab: McpManagementTab;
|
||||
icon: ReactNode;
|
||||
onTabChange: (tab: McpManagementTab) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
||||
onAction: (action: McpAction, name: string, values?: Record<string, string>) => void;
|
||||
onOAuthConnect: (name: string, reset?: boolean) => void;
|
||||
onToolsChange: (name: string, enabledTools: string[]) => void;
|
||||
}
|
||||
|
||||
export function McpManagementDialog({
|
||||
preset,
|
||||
values,
|
||||
actionKey,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
tab,
|
||||
icon,
|
||||
onTabChange,
|
||||
onOpenChange,
|
||||
onFieldChange,
|
||||
onAction,
|
||||
onOAuthConnect,
|
||||
onToolsChange,
|
||||
}: McpManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const inventoryRef = useRef(preset.tool_names ?? []);
|
||||
if (preset.tool_names?.length) inventoryRef.current = preset.tool_names;
|
||||
const toolNames = inventoryRef.current;
|
||||
const [toolQuery, setToolQuery] = useState("");
|
||||
const [draftEnabledTools, setDraftEnabledTools] = useState<string[]>(
|
||||
preset.enabled_tools ?? ["*"],
|
||||
);
|
||||
const testBusy = actionKey === `test:${preset.name}`;
|
||||
const reconnectBusy = actionKey === `reconnect:${preset.name}`;
|
||||
const removeBusy = actionKey === `remove:${preset.name}`;
|
||||
const enableBusy = actionKey === `enable:${preset.name}`;
|
||||
const toolsBusy = actionKey === `tools:${preset.name}`;
|
||||
const oauthBusy = actionKey === `oauth:${preset.name}`;
|
||||
const busy = testBusy || reconnectBusy || removeBusy || enableBusy || toolsBusy || oauthBusy;
|
||||
const configuredInstalled = preset.installed && preset.configured;
|
||||
const isOAuth = preset.auth === "oauth";
|
||||
const inspectionRequestedRef = useRef(false);
|
||||
const allowAllTools = draftEnabledTools.includes("*");
|
||||
const selectedTools = new Set(allowAllTools ? toolNames : draftEnabledTools);
|
||||
const normalizedQuery = toolQuery.trim().toLowerCase();
|
||||
const filteredTools = useMemo(
|
||||
() => toolNames.filter((name) => name.toLowerCase().includes(normalizedQuery)),
|
||||
[normalizedQuery, toolNames],
|
||||
);
|
||||
const knownToolCount = preset.tool_count ?? toolNames.length;
|
||||
const selectedToolCount = allowAllTools ? knownToolCount : selectedTools.size;
|
||||
const requiredFieldsComplete = preset.required_fields
|
||||
.filter((field) => field.required && !field.configured)
|
||||
.every((field) => Boolean(values[field.name]?.trim()));
|
||||
const initialTools = normalizeTools(preset.enabled_tools ?? ["*"]);
|
||||
const draftTools = normalizeTools(draftEnabledTools);
|
||||
const description = tx(
|
||||
`settings.mcp.presetDescriptions.${preset.name}`,
|
||||
preset.description || preset.note || preset.name,
|
||||
);
|
||||
const toolsDirty = initialTools.join("\n") !== draftTools.join("\n");
|
||||
const tabs: Array<{ value: McpManagementTab; label: ReactNode }> = [
|
||||
{ value: "overview", label: tx("settings.mcp.overviewTab", "Overview") },
|
||||
{
|
||||
value: "tools",
|
||||
label: tx("settings.mcp.toolScope", "Tools"),
|
||||
},
|
||||
{ value: "connection", label: tx("settings.mcp.connectionTab", "Connection") },
|
||||
];
|
||||
const activePanelLabel = tab === "overview"
|
||||
? tx("settings.mcp.overviewTab", "Overview")
|
||||
: tab === "tools"
|
||||
? tx("settings.mcp.toolScope", "Tools")
|
||||
: tx("settings.mcp.connectionTab", "Connection");
|
||||
|
||||
const toggleTool = (toolName: string) => {
|
||||
const next = new Set(allowAllTools ? toolNames : draftEnabledTools);
|
||||
if (next.has(toolName)) next.delete(toolName);
|
||||
else next.add(toolName);
|
||||
setDraftEnabledTools(next.size === toolNames.length ? ["*"] : Array.from(next));
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
onOpenChange(false);
|
||||
if (isOAuth) {
|
||||
onOAuthConnect(preset.name, configuredInstalled);
|
||||
return;
|
||||
}
|
||||
onAction(configuredInstalled ? "reconnect" : "enable", preset.name, values);
|
||||
};
|
||||
|
||||
const inspectTools = () => {
|
||||
inspectionRequestedRef.current = true;
|
||||
onAction("test", preset.name);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
tab !== "tools" ||
|
||||
!configuredInstalled ||
|
||||
toolNames.length ||
|
||||
busy ||
|
||||
preset.error ||
|
||||
inspectionRequestedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
inspectionRequestedRef.current = true;
|
||||
onAction("test", preset.name);
|
||||
}, [busy, configuredInstalled, onAction, preset.error, preset.name, tab, toolNames.length]);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"flex h-[min(34rem,calc(100dvh-2rem))] w-[calc(100vw-2rem)] max-w-[42rem] flex-col gap-0 overflow-hidden p-0",
|
||||
"rounded-[22px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border/45 px-5 py-3.5 sm:px-6">
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate text-[17px] leading-6 tracking-[-0.01em]">
|
||||
{preset.display_name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<StatusPill tone={statusTone}>{statusLabel}</StatusPill>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={tx("common.close", "Close")}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="-mr-2 h-10 w-10 shrink-0 rounded-full text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-b border-border/45 px-5 py-3 sm:px-6">
|
||||
<SegmentedControl
|
||||
value={tab}
|
||||
options={tabs}
|
||||
onChange={onTabChange}
|
||||
mode="tabs"
|
||||
ariaLabel={tx("settings.mcp.manageTabs", "MCP management sections")}
|
||||
className="w-full max-w-[22rem]"
|
||||
itemClassName="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
aria-label={activePanelLabel}
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5 scrollbar-thin scrollbar-track-transparent sm:px-6"
|
||||
>
|
||||
{tab === "overview" ? (
|
||||
<OverviewPanel
|
||||
preset={preset}
|
||||
description={description}
|
||||
statusLabel={statusLabel}
|
||||
knownToolCount={knownToolCount}
|
||||
selectedToolCount={selectedToolCount}
|
||||
/>
|
||||
) : tab === "tools" ? (
|
||||
<ToolsPanel
|
||||
preset={preset}
|
||||
toolNames={toolNames}
|
||||
filteredTools={filteredTools}
|
||||
query={toolQuery}
|
||||
selectedTools={selectedTools}
|
||||
selectedToolCount={selectedToolCount}
|
||||
toolsBusy={toolsBusy}
|
||||
testBusy={testBusy}
|
||||
configuredInstalled={configuredInstalled}
|
||||
onQueryChange={setToolQuery}
|
||||
onToggleTool={toggleTool}
|
||||
onSelectAll={() => setDraftEnabledTools(["*"])}
|
||||
onClear={() => setDraftEnabledTools([])}
|
||||
onTest={inspectTools}
|
||||
onOpenConnection={() => onTabChange("connection")}
|
||||
/>
|
||||
) : (
|
||||
<ConnectionPanel
|
||||
preset={preset}
|
||||
values={values}
|
||||
busy={busy}
|
||||
connectBusy={reconnectBusy || enableBusy || oauthBusy}
|
||||
removeBusy={removeBusy}
|
||||
requiredFieldsComplete={requiredFieldsComplete}
|
||||
onFieldChange={onFieldChange}
|
||||
onConnect={connect}
|
||||
onRemove={() => {
|
||||
onOpenChange(false);
|
||||
onAction("remove", preset.name);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tab === "tools" && toolsDirty ? (
|
||||
<div className="flex shrink-0 items-center justify-end border-t border-border/45 bg-background/95 px-5 py-3 sm:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={toolsBusy || !toolNames.length}
|
||||
onClick={() => onToolsChange(preset.name, draftEnabledTools)}
|
||||
className="h-9 rounded-full px-4 text-[13px] font-semibold"
|
||||
>
|
||||
{toolsBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden />
|
||||
) : (
|
||||
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{tx("settings.mcp.applyChanges", "Apply changes")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewPanel({
|
||||
preset,
|
||||
description,
|
||||
statusLabel,
|
||||
knownToolCount,
|
||||
selectedToolCount,
|
||||
}: {
|
||||
preset: McpPresetInfo;
|
||||
description: string;
|
||||
statusLabel: string;
|
||||
knownToolCount: number;
|
||||
selectedToolCount: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const previewTools = (preset.tool_names ?? []).slice(0, 4);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-[16px] border border-border/55 px-4 py-3.5">
|
||||
<h3 className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.mcp.about", "About this MCP")}
|
||||
</h3>
|
||||
<p className="mt-1.5 max-w-[62ch] text-[14px] leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
{preset.docs_url ? (
|
||||
<a
|
||||
href={preset.docs_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex min-h-9 items-center gap-1.5 rounded-full text-[13px] font-semibold text-foreground/75 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{tx("settings.mcp.openDocs", "Open docs")}
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<dl className={cn("grid grid-cols-1 gap-2.5", knownToolCount ? "sm:grid-cols-3" : "sm:grid-cols-2")}>
|
||||
<MetricCard label={tx("settings.mcp.statusLabel", "Status")} value={statusLabel} />
|
||||
<MetricCard label={tx("settings.mcp.transportLabel", "Transport")} value={formatTransport(preset.transport)} />
|
||||
{knownToolCount ? (
|
||||
<MetricCard
|
||||
label={tx("settings.mcp.toolScope", "Tools")}
|
||||
value={`${selectedToolCount} / ${knownToolCount}`}
|
||||
/>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{previewTools.length ? (
|
||||
<section className="rounded-[16px] bg-muted/45 p-4">
|
||||
<h3 className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.mcp.toolPreview", "Tools")}
|
||||
</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{previewTools.map((tool) => (
|
||||
<code key={tool} className="max-w-full truncate rounded-full bg-background px-2.5 py-1 text-[12.5px] text-foreground/75">
|
||||
{displayToolName(tool, preset.name)}
|
||||
</code>
|
||||
))}
|
||||
{knownToolCount > previewTools.length ? (
|
||||
<span className="rounded-full bg-background px-2.5 py-1 text-[12.5px] text-muted-foreground">
|
||||
+{knownToolCount - previewTools.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsPanel({
|
||||
preset,
|
||||
toolNames,
|
||||
filteredTools,
|
||||
query,
|
||||
selectedTools,
|
||||
selectedToolCount,
|
||||
toolsBusy,
|
||||
testBusy,
|
||||
configuredInstalled,
|
||||
onQueryChange,
|
||||
onToggleTool,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onTest,
|
||||
onOpenConnection,
|
||||
}: {
|
||||
preset: McpPresetInfo;
|
||||
toolNames: string[];
|
||||
filteredTools: string[];
|
||||
query: string;
|
||||
selectedTools: Set<string>;
|
||||
selectedToolCount: number;
|
||||
toolsBusy: boolean;
|
||||
testBusy: boolean;
|
||||
configuredInstalled: boolean;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleTool: (name: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClear: () => void;
|
||||
onTest: () => void;
|
||||
onOpenConnection: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
if (!toolNames.length) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-[16px] border px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between",
|
||||
preset.error ? "border-destructive/25 bg-destructive/5" : "border-border/55 bg-muted/25",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-background text-muted-foreground">
|
||||
{testBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden />
|
||||
) : (
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
role={preset.error ? "alert" : undefined}
|
||||
className={cn("min-w-0 text-[14px] font-medium", preset.error ? "text-destructive" : "text-foreground")}
|
||||
>
|
||||
{testBusy
|
||||
? tx("common.loading", "Loading…")
|
||||
: preset.error || tx("settings.mcp.noToolsAvailable", "No tools available")}
|
||||
</p>
|
||||
</div>
|
||||
{!testBusy ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={configuredInstalled ? onTest : onOpenConnection}
|
||||
className="h-9 shrink-0 rounded-full px-4 text-[13px] font-semibold"
|
||||
>
|
||||
{configuredInstalled ? (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Server className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{configuredInstalled
|
||||
? tx("settings.mcp.reloadTools", "Reload tools")
|
||||
: tx("settings.mcp.setup", "Connect")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<span className="sr-only">{tx("settings.mcp.searchTools", "Search tools")}</span>
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={tx("settings.mcp.searchToolsPlaceholder", "Search tools")}
|
||||
className="h-10 rounded-full bg-muted/45 pl-9 text-[12.5px]"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
||||
<span className="mr-auto text-[13px] tabular-nums text-muted-foreground sm:mr-1">
|
||||
{tx("settings.mcp.selectedCount", "{{count}} selected").replace("{{count}}", String(selectedToolCount))}
|
||||
</span>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={toolsBusy} onClick={onSelectAll} className="h-8 rounded-full px-2.5 text-[12.5px] font-semibold">
|
||||
{tx("settings.mcp.allTools", "All")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={toolsBusy} onClick={onClear} className="h-8 rounded-full px-2.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
{tx("settings.mcp.noTools", "None")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[16px] border border-border/55">
|
||||
{filteredTools.length ? filteredTools.map((toolName) => {
|
||||
const selected = selectedTools.has(toolName);
|
||||
return (
|
||||
<label
|
||||
key={toolName}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-3 border-b border-border/45 px-3.5 py-2.5 transition-colors last:border-b-0 hover:bg-muted/35 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-inset has-[:focus-visible]:ring-ring"
|
||||
>
|
||||
<span className="relative h-5 w-5 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
disabled={toolsBusy}
|
||||
onChange={() => onToggleTool(toolName)}
|
||||
className="peer absolute inset-0 z-10 h-5 w-5 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 grid place-items-center rounded-[7px] border transition-colors",
|
||||
selected
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border bg-background text-transparent",
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" strokeWidth={2.75} />
|
||||
</span>
|
||||
</span>
|
||||
<code className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-foreground">
|
||||
{displayToolName(toolName, preset.name)}
|
||||
</code>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<div className="px-4 py-10 text-center text-[12.5px] text-muted-foreground">
|
||||
{tx("settings.mcp.noMatchingTools", "No tools match this search.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionPanel({
|
||||
preset,
|
||||
values,
|
||||
busy,
|
||||
connectBusy,
|
||||
removeBusy,
|
||||
requiredFieldsComplete,
|
||||
onFieldChange,
|
||||
onConnect,
|
||||
onRemove,
|
||||
}: {
|
||||
preset: McpPresetInfo;
|
||||
values: Record<string, string>;
|
||||
busy: boolean;
|
||||
connectBusy: boolean;
|
||||
removeBusy: boolean;
|
||||
requiredFieldsComplete: boolean;
|
||||
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
||||
onConnect: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const configuredInstalled = preset.installed && preset.configured;
|
||||
const hasFields = preset.required_fields.length > 0;
|
||||
const authentication = preset.auth === "oauth"
|
||||
? "OAuth"
|
||||
: hasFields
|
||||
? tx("settings.mcp.credentials", "Credentials")
|
||||
: tx("settings.mcp.none", "None");
|
||||
const connectionLabel = preset.transport === "stdio"
|
||||
? tx("settings.mcp.command", "Command")
|
||||
: tx("settings.mcp.endpointLabel", "Endpoint");
|
||||
const connectLabel = configuredInstalled
|
||||
? tx("settings.mcp.reconnect", "Reconnect")
|
||||
: tx("settings.mcp.setup", "Connect");
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section aria-labelledby={`mcp-connection-${preset.name}`}>
|
||||
<h3 id={`mcp-connection-${preset.name}`} className="sr-only">
|
||||
{tx("settings.mcp.connectionDetails", "Connection details")}
|
||||
</h3>
|
||||
<div className="rounded-[16px] bg-muted/40 px-4 py-3.5">
|
||||
{preset.connection_summary ? (
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12.5px] font-medium text-muted-foreground">{connectionLabel}</p>
|
||||
<code className="mt-1 block break-all text-[12.5px] leading-5 text-foreground">
|
||||
{preset.connection_summary}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn(
|
||||
"flex flex-wrap items-center gap-x-2 gap-y-1 text-[12.5px] text-muted-foreground",
|
||||
preset.connection_summary && "mt-3 border-t border-border/45 pt-3",
|
||||
)}>
|
||||
<span>{formatTransport(preset.transport)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{authentication}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{preset.docs_url ? (
|
||||
<a
|
||||
href={preset.docs_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-h-8 items-center gap-1 rounded-full px-2 font-semibold text-foreground/65 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{tx("settings.mcp.openDocs", "Open docs")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
{configuredInstalled && preset.install_supported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={onConnect}
|
||||
className="h-8 rounded-full bg-background px-3 text-[12.5px] font-semibold"
|
||||
>
|
||||
{connectBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{connectLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{preset.error ? (
|
||||
<div role="alert" className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[12.5px] leading-5 text-destructive">
|
||||
{preset.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasFields ? (
|
||||
<section>
|
||||
<h3 className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.mcp.credentials", "Credentials")}
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{preset.required_fields.map((field) => {
|
||||
const inputId = `mcp-manage-${preset.name}-${field.name}`;
|
||||
return (
|
||||
<label key={field.name} htmlFor={inputId} className="min-w-0">
|
||||
<span className="mb-1.5 flex items-center gap-1 text-[12.5px] font-medium text-muted-foreground">
|
||||
{field.label}
|
||||
{field.configured ? (
|
||||
<span className="font-normal text-emerald-600 dark:text-emerald-300">
|
||||
· {tx("settings.mcp.configured", "configured")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Input
|
||||
id={inputId}
|
||||
type={field.secret ? "password" : "text"}
|
||||
value={values[field.name] ?? ""}
|
||||
onChange={(event) => onFieldChange(preset.name, field.name, event.target.value)}
|
||||
placeholder={field.configured ? tx("settings.mcp.keepExisting", "Leave blank to keep existing") : field.placeholder}
|
||||
className="h-10 rounded-full bg-muted/35 text-[12.5px]"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!configuredInstalled && preset.install_supported ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || !requiredFieldsComplete}
|
||||
onClick={onConnect}
|
||||
className="h-9 rounded-full px-4 text-[13px] font-semibold"
|
||||
>
|
||||
{connectBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden />
|
||||
) : (
|
||||
<Server className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{connectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preset.installed && preset.enabled === undefined ? (
|
||||
<section className="pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={onRemove}
|
||||
className="h-9 rounded-full px-3 text-[12.5px] font-semibold text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{removeBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" aria-hidden />
|
||||
) : (
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{tx("settings.mcp.dangerZone", "Remove connection")}
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[14px] bg-muted/45 px-3.5 py-3">
|
||||
<dt className="text-[12px] font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-1 truncate text-[14px] font-semibold text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ children, tone }: { children: ReactNode; tone: "success" | "warning" | "neutral" }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
tone === "warning" && "bg-destructive/10 text-destructive",
|
||||
tone === "neutral" && "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full bg-current", tone === "neutral" && "opacity-55")} aria-hidden />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTools(tools: string[]): string[] {
|
||||
return tools.includes("*") ? ["*"] : [...tools].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function displayToolName(toolName: string, serverName: string): string {
|
||||
const wrappedPrefix = `mcp_${serverName}_`;
|
||||
return toolName.startsWith(wrappedPrefix) ? toolName.slice(wrappedPrefix.length) : toolName;
|
||||
}
|
||||
|
||||
function formatTransport(transport: string): string {
|
||||
if (transport === "streamableHttp") return "Streamable HTTP";
|
||||
if (transport === "stdio") return "STDIO";
|
||||
if (transport === "sse") return "SSE";
|
||||
return transport || "MCP";
|
||||
}
|
||||
@@ -384,10 +384,49 @@
|
||||
"statusComingSoon": "Coming soon",
|
||||
"comingSoon": "Coming soon",
|
||||
"statusNotInstalled": "Not enabled",
|
||||
"manage": "Manage",
|
||||
"manageTitle": "Manage {{name}}",
|
||||
"fixConnection": "Fix connection",
|
||||
"overviewTab": "Overview",
|
||||
"connectionTab": "Connection",
|
||||
"manageTabs": "MCP management sections",
|
||||
"about": "About",
|
||||
"statusLabel": "Status",
|
||||
"transportLabel": "Transport",
|
||||
"endpointLabel": "Endpoint",
|
||||
"toolPreview": "Tools",
|
||||
"noToolsAvailable": "No tools available",
|
||||
"searchTools": "Search tools",
|
||||
"searchToolsPlaceholder": "Search tools",
|
||||
"selectedCount": "{{count}} enabled",
|
||||
"noMatchingTools": "No tools match this search.",
|
||||
"applyChanges": "Save changes",
|
||||
"connectionDetails": "Connection details",
|
||||
"credentials": "Credentials",
|
||||
"none": "None",
|
||||
"dangerZone": "Remove connection",
|
||||
"toolScope": "Tools",
|
||||
"allTools": "All",
|
||||
"noTools": "None",
|
||||
"testForTools": "Run Test to inspect and choose individual tools."
|
||||
"allTools": "Enable all",
|
||||
"noTools": "Disable all",
|
||||
"reloadTools": "Reload tools",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Automate cloud browsers with Browserbase.",
|
||||
"playwright": "Inspect and automate local browsers with Playwright.",
|
||||
"context7": "Fetch current library docs and code examples.",
|
||||
"firecrawl": "Scrape, crawl, search, and extract web content.",
|
||||
"parallel-search": "Search the web and read relevant pages.",
|
||||
"exa": "Search the web and extract clean page content.",
|
||||
"microsoft-learn": "Search and read Microsoft Learn documentation.",
|
||||
"aws-docs": "Search AWS documentation and service guidance.",
|
||||
"brave-search": "Search the web, news, images, videos, and local results with Brave Search.",
|
||||
"postman": "Inspect and manage Postman APIs, collections, and workspaces.",
|
||||
"figma": "Read Figma design context through the local Dev Mode MCP.",
|
||||
"xmind": "Create, read, and edit Xmind cloud mind maps.",
|
||||
"notion": "Read and update your Notion workspace.",
|
||||
"linear": "Find and manage Linear issues, projects, and comments.",
|
||||
"github": "Manage GitHub repositories, issues, and pull requests.",
|
||||
"supabase": "Inspect and manage Supabase projects."
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"light": "Light",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "Próximamente",
|
||||
"comingSoon": "Próximamente",
|
||||
"statusNotInstalled": "No habilitado",
|
||||
"manage": "Administrar",
|
||||
"manageTitle": "Administrar {{name}}",
|
||||
"fixConnection": "Corregir conexión",
|
||||
"overviewTab": "Resumen",
|
||||
"connectionTab": "Conexión",
|
||||
"manageTabs": "Administración de MCP",
|
||||
"about": "Información",
|
||||
"statusLabel": "Estado",
|
||||
"transportLabel": "Transporte",
|
||||
"endpointLabel": "URL",
|
||||
"toolPreview": "Herramientas",
|
||||
"noToolsAvailable": "No hay herramientas disponibles",
|
||||
"searchTools": "Buscar herramientas",
|
||||
"searchToolsPlaceholder": "Buscar herramientas",
|
||||
"selectedCount": "{{count}} activadas",
|
||||
"noMatchingTools": "No hay herramientas coincidentes",
|
||||
"applyChanges": "Guardar cambios",
|
||||
"connectionDetails": "Detalles de conexión",
|
||||
"credentials": "Credenciales",
|
||||
"none": "Ninguna",
|
||||
"dangerZone": "Eliminar conexión",
|
||||
"toolScope": "Herramientas",
|
||||
"allTools": "Todas",
|
||||
"noTools": "Ninguna",
|
||||
"testForTools": "Ejecuta Probar para inspeccionar y elegir herramientas individuales."
|
||||
"allTools": "Activar todas",
|
||||
"noTools": "Desactivar todas",
|
||||
"reloadTools": "Recargar herramientas",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Automatiza navegadores en la nube con Browserbase.",
|
||||
"playwright": "Inspecciona y automatiza navegadores locales con Playwright.",
|
||||
"context7": "Obtén documentación actual y ejemplos de código.",
|
||||
"firecrawl": "Rastrea, busca y extrae contenido web.",
|
||||
"parallel-search": "Busca en la web y lee páginas relevantes.",
|
||||
"exa": "Busca en la web y extrae contenido limpio.",
|
||||
"microsoft-learn": "Busca y lee documentación de Microsoft Learn.",
|
||||
"aws-docs": "Busca documentación y guías de servicios de AWS.",
|
||||
"brave-search": "Busca web, noticias, imágenes, vídeos y resultados locales con Brave Search.",
|
||||
"postman": "Consulta y administra API, colecciones y espacios de trabajo de Postman.",
|
||||
"figma": "Lee el contexto de diseño de Figma mediante el MCP local de Dev Mode.",
|
||||
"xmind": "Crea, lee y edita mapas mentales de Xmind en la nube.",
|
||||
"notion": "Lee y actualiza tu espacio de trabajo de Notion.",
|
||||
"linear": "Busca y administra incidencias, proyectos y comentarios de Linear.",
|
||||
"github": "Administra repositorios, incidencias y pull requests de GitHub.",
|
||||
"supabase": "Consulta y administra proyectos de Supabase."
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "Servidor API", "openaiCompatible": "API compatible con OpenAI",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "Bientôt disponible",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
"statusNotInstalled": "Non activé",
|
||||
"manage": "Gérer",
|
||||
"manageTitle": "Gérer {{name}}",
|
||||
"fixConnection": "Réparer la connexion",
|
||||
"overviewTab": "Aperçu",
|
||||
"connectionTab": "Connexion",
|
||||
"manageTabs": "Gestion MCP",
|
||||
"about": "À propos",
|
||||
"statusLabel": "État",
|
||||
"transportLabel": "Transport",
|
||||
"endpointLabel": "URL",
|
||||
"toolPreview": "Outils",
|
||||
"noToolsAvailable": "Aucun outil disponible",
|
||||
"searchTools": "Rechercher des outils",
|
||||
"searchToolsPlaceholder": "Rechercher des outils",
|
||||
"selectedCount": "{{count}} activés",
|
||||
"noMatchingTools": "Aucun outil correspondant",
|
||||
"applyChanges": "Enregistrer",
|
||||
"connectionDetails": "Détails de connexion",
|
||||
"credentials": "Identifiants",
|
||||
"none": "Aucun",
|
||||
"dangerZone": "Supprimer la connexion",
|
||||
"toolScope": "Outils",
|
||||
"allTools": "Tous",
|
||||
"noTools": "Aucun",
|
||||
"testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
|
||||
"allTools": "Tout activer",
|
||||
"noTools": "Tout désactiver",
|
||||
"reloadTools": "Recharger les outils",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Automatisez des navigateurs cloud avec Browserbase.",
|
||||
"playwright": "Inspectez et automatisez les navigateurs locaux avec Playwright.",
|
||||
"context7": "Obtenez la documentation à jour et des exemples de code.",
|
||||
"firecrawl": "Explorez, recherchez et extrayez du contenu web.",
|
||||
"parallel-search": "Recherchez sur le web et lisez les pages pertinentes.",
|
||||
"exa": "Recherchez sur le web et extrayez du contenu propre.",
|
||||
"microsoft-learn": "Recherchez et consultez la documentation Microsoft Learn.",
|
||||
"aws-docs": "Recherchez la documentation et les guides de services AWS.",
|
||||
"brave-search": "Recherchez le web, les actualités, les images, les vidéos et les résultats locaux avec Brave Search.",
|
||||
"postman": "Consultez et gérez les API, collections et espaces de travail Postman.",
|
||||
"figma": "Lisez le contexte de conception Figma via le MCP Dev Mode local.",
|
||||
"xmind": "Créez, consultez et modifiez des cartes mentales Xmind dans le cloud.",
|
||||
"notion": "Consultez et mettez à jour votre espace de travail Notion.",
|
||||
"linear": "Recherchez et gérez les tickets, projets et commentaires Linear.",
|
||||
"github": "Gérez les dépôts, tickets et pull requests GitHub.",
|
||||
"supabase": "Consultez et gérez les projets Supabase."
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "Serveur API", "openaiCompatible": "API compatible OpenAI", "description": "Connectez des SDK et agents via un endpoint /v1 local.",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "Segera hadir",
|
||||
"comingSoon": "Segera hadir",
|
||||
"statusNotInstalled": "Tidak aktif",
|
||||
"manage": "Kelola",
|
||||
"manageTitle": "Kelola {{name}}",
|
||||
"fixConnection": "Perbaiki koneksi",
|
||||
"overviewTab": "Ringkasan",
|
||||
"connectionTab": "Koneksi",
|
||||
"manageTabs": "Pengelolaan MCP",
|
||||
"about": "Tentang",
|
||||
"statusLabel": "Status",
|
||||
"transportLabel": "Transport",
|
||||
"endpointLabel": "Endpoint",
|
||||
"toolPreview": "Alat",
|
||||
"noToolsAvailable": "Tidak ada alat yang tersedia",
|
||||
"searchTools": "Cari alat",
|
||||
"searchToolsPlaceholder": "Cari alat",
|
||||
"selectedCount": "{{count}} aktif",
|
||||
"noMatchingTools": "Tidak ada alat yang cocok",
|
||||
"applyChanges": "Simpan perubahan",
|
||||
"connectionDetails": "Detail koneksi",
|
||||
"credentials": "Kredensial",
|
||||
"none": "Tidak ada",
|
||||
"dangerZone": "Hapus koneksi",
|
||||
"toolScope": "Alat",
|
||||
"allTools": "Semua",
|
||||
"noTools": "Tidak ada",
|
||||
"testForTools": "Jalankan Uji untuk memeriksa dan memilih alat individual."
|
||||
"allTools": "Aktifkan semua",
|
||||
"noTools": "Nonaktifkan semua",
|
||||
"reloadTools": "Muat ulang alat",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Otomatiskan browser cloud dengan Browserbase.",
|
||||
"playwright": "Periksa dan otomatiskan browser lokal dengan Playwright.",
|
||||
"context7": "Ambil dokumentasi pustaka terbaru dan contoh kode.",
|
||||
"firecrawl": "Jelajahi, cari, dan ekstrak konten web.",
|
||||
"parallel-search": "Cari di web dan baca halaman yang relevan.",
|
||||
"exa": "Cari di web dan ekstrak konten halaman yang bersih.",
|
||||
"microsoft-learn": "Cari dan baca dokumentasi Microsoft Learn.",
|
||||
"aws-docs": "Cari dokumentasi dan panduan layanan AWS.",
|
||||
"brave-search": "Cari web, berita, gambar, video, dan hasil lokal dengan Brave Search.",
|
||||
"postman": "Periksa dan kelola API, koleksi, dan ruang kerja Postman.",
|
||||
"figma": "Baca konteks desain Figma melalui MCP Dev Mode lokal.",
|
||||
"xmind": "Buat, baca, dan edit peta pikiran cloud Xmind.",
|
||||
"notion": "Baca dan perbarui ruang kerja Notion Anda.",
|
||||
"linear": "Cari dan kelola isu, proyek, dan komentar Linear.",
|
||||
"github": "Kelola repositori, isu, dan pull request GitHub.",
|
||||
"supabase": "Periksa dan kelola proyek Supabase."
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "Server API", "openaiCompatible": "API kompatibel OpenAI", "description": "Hubungkan SDK dan agen melalui endpoint /v1 lokal.",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "近日公開",
|
||||
"comingSoon": "近日公開",
|
||||
"statusNotInstalled": "未有効",
|
||||
"manage": "管理",
|
||||
"manageTitle": "{{name}} を管理",
|
||||
"fixConnection": "接続を修復",
|
||||
"overviewTab": "概要",
|
||||
"connectionTab": "接続",
|
||||
"manageTabs": "MCP 管理",
|
||||
"about": "概要",
|
||||
"statusLabel": "状態",
|
||||
"transportLabel": "転送方式",
|
||||
"endpointLabel": "エンドポイント",
|
||||
"toolPreview": "ツール",
|
||||
"noToolsAvailable": "利用可能なツールはありません",
|
||||
"searchTools": "ツールを検索",
|
||||
"searchToolsPlaceholder": "ツールを検索",
|
||||
"selectedCount": "{{count}} 件有効",
|
||||
"noMatchingTools": "一致するツールはありません",
|
||||
"applyChanges": "変更を保存",
|
||||
"connectionDetails": "接続情報",
|
||||
"credentials": "認証情報",
|
||||
"none": "なし",
|
||||
"dangerZone": "接続を削除",
|
||||
"toolScope": "ツール",
|
||||
"allTools": "すべて",
|
||||
"noTools": "なし",
|
||||
"testForTools": "テストを実行して個別のツールを確認・選択します。"
|
||||
"allTools": "すべて有効化",
|
||||
"noTools": "すべて無効化",
|
||||
"reloadTools": "ツールを再読込",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Browserbase でクラウドブラウザーを自動操作します。",
|
||||
"playwright": "Playwright でローカルブラウザーを検査・自動操作します。",
|
||||
"context7": "最新のライブラリ資料とコード例を取得します。",
|
||||
"firecrawl": "Web コンテンツをクロール、検索、抽出します。",
|
||||
"parallel-search": "Web を検索して関連ページを読み込みます。",
|
||||
"exa": "Web を検索して整形済みのページ内容を抽出します。",
|
||||
"microsoft-learn": "Microsoft Learn の資料を検索・閲覧します。",
|
||||
"aws-docs": "AWS の資料とサービスガイドを検索します。",
|
||||
"brave-search": "Brave Search で Web、ニュース、画像、動画、ローカル情報を検索します。",
|
||||
"postman": "Postman の API、コレクション、ワークスペースを確認・管理します。",
|
||||
"figma": "ローカルの Dev Mode MCP から Figma のデザイン情報を読み込みます。",
|
||||
"xmind": "Xmind のクラウドマインドマップを作成、閲覧、編集します。",
|
||||
"notion": "Notion ワークスペースを閲覧・更新します。",
|
||||
"linear": "Linear の課題、プロジェクト、コメントを検索・管理します。",
|
||||
"github": "GitHub のリポジトリ、Issue、Pull Request を管理します。",
|
||||
"supabase": "Supabase プロジェクトを確認・管理します。"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API サーバー", "openaiCompatible": "OpenAI 互換 API", "description": "ローカルの /v1 エンドポイントから SDK やエージェントを接続します。",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "곧 제공",
|
||||
"comingSoon": "곧 제공",
|
||||
"statusNotInstalled": "비활성",
|
||||
"manage": "관리",
|
||||
"manageTitle": "{{name}} 관리",
|
||||
"fixConnection": "연결 복구",
|
||||
"overviewTab": "개요",
|
||||
"connectionTab": "연결",
|
||||
"manageTabs": "MCP 관리",
|
||||
"about": "정보",
|
||||
"statusLabel": "상태",
|
||||
"transportLabel": "전송 방식",
|
||||
"endpointLabel": "엔드포인트",
|
||||
"toolPreview": "도구",
|
||||
"noToolsAvailable": "사용 가능한 도구가 없습니다",
|
||||
"searchTools": "도구 검색",
|
||||
"searchToolsPlaceholder": "도구 검색",
|
||||
"selectedCount": "{{count}}개 활성화",
|
||||
"noMatchingTools": "일치하는 도구가 없습니다",
|
||||
"applyChanges": "변경 사항 저장",
|
||||
"connectionDetails": "연결 정보",
|
||||
"credentials": "인증 정보",
|
||||
"none": "없음",
|
||||
"dangerZone": "연결 삭제",
|
||||
"toolScope": "도구",
|
||||
"allTools": "전체",
|
||||
"noTools": "없음",
|
||||
"testForTools": "테스트를 실행해 개별 도구를 확인하고 선택하세요."
|
||||
"allTools": "모두 활성화",
|
||||
"noTools": "모두 비활성화",
|
||||
"reloadTools": "도구 다시 불러오기",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Browserbase로 클라우드 브라우저를 자동화합니다.",
|
||||
"playwright": "Playwright로 로컬 브라우저를 검사하고 자동화합니다.",
|
||||
"context7": "최신 라이브러리 문서와 코드 예제를 가져옵니다.",
|
||||
"firecrawl": "웹 콘텐츠를 크롤링, 검색, 추출합니다.",
|
||||
"parallel-search": "웹을 검색하고 관련 페이지를 읽습니다.",
|
||||
"exa": "웹을 검색하고 정리된 페이지 콘텐츠를 추출합니다.",
|
||||
"microsoft-learn": "Microsoft Learn 문서를 검색하고 읽습니다.",
|
||||
"aws-docs": "AWS 문서와 서비스 가이드를 검색합니다.",
|
||||
"brave-search": "Brave Search로 웹, 뉴스, 이미지, 동영상, 지역 정보를 검색합니다.",
|
||||
"postman": "Postman API, 컬렉션, 작업 공간을 확인하고 관리합니다.",
|
||||
"figma": "로컬 Dev Mode MCP를 통해 Figma 디자인 컨텍스트를 읽습니다.",
|
||||
"xmind": "Xmind 클라우드 마인드맵을 만들고 읽고 편집합니다.",
|
||||
"notion": "Notion 작업 공간을 읽고 업데이트합니다.",
|
||||
"linear": "Linear 이슈, 프로젝트, 댓글을 찾고 관리합니다.",
|
||||
"github": "GitHub 저장소, 이슈, 풀 리퀘스트를 관리합니다.",
|
||||
"supabase": "Supabase 프로젝트를 확인하고 관리합니다."
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API 서버", "openaiCompatible": "OpenAI 호환 API", "description": "로컬 /v1 엔드포인트로 SDK와 에이전트를 연결합니다.",
|
||||
|
||||
@@ -384,10 +384,49 @@
|
||||
"statusComingSoon": "Em breve",
|
||||
"comingSoon": "Em breve",
|
||||
"statusNotInstalled": "Não habilitado",
|
||||
"manage": "Gerenciar",
|
||||
"manageTitle": "Gerenciar {{name}}",
|
||||
"fixConnection": "Corrigir conexão",
|
||||
"overviewTab": "Visão geral",
|
||||
"connectionTab": "Conexão",
|
||||
"manageTabs": "Gerenciamento de MCP",
|
||||
"about": "Sobre",
|
||||
"statusLabel": "Status",
|
||||
"transportLabel": "Transporte",
|
||||
"endpointLabel": "Endpoint",
|
||||
"toolPreview": "Ferramentas",
|
||||
"noToolsAvailable": "Nenhuma ferramenta disponível",
|
||||
"searchTools": "Buscar ferramentas",
|
||||
"searchToolsPlaceholder": "Buscar ferramentas",
|
||||
"selectedCount": "{{count}} ativadas",
|
||||
"noMatchingTools": "Nenhuma ferramenta encontrada",
|
||||
"applyChanges": "Salvar alterações",
|
||||
"connectionDetails": "Detalhes da conexão",
|
||||
"credentials": "Credenciais",
|
||||
"none": "Nenhuma",
|
||||
"dangerZone": "Remover conexão",
|
||||
"toolScope": "Ferramentas",
|
||||
"allTools": "Todas",
|
||||
"noTools": "Nenhuma",
|
||||
"testForTools": "Execute Testar para inspecionar e escolher ferramentas individuais."
|
||||
"allTools": "Ativar todas",
|
||||
"noTools": "Desativar todas",
|
||||
"reloadTools": "Recarregar ferramentas",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Automatize navegadores na nuvem com o Browserbase.",
|
||||
"playwright": "Inspecione e automatize navegadores locais com o Playwright.",
|
||||
"context7": "Obtenha documentação atualizada e exemplos de código.",
|
||||
"firecrawl": "Rastreie, pesquise e extraia conteúdo da web.",
|
||||
"parallel-search": "Pesquise na web e leia páginas relevantes.",
|
||||
"exa": "Pesquise na web e extraia conteúdo limpo das páginas.",
|
||||
"microsoft-learn": "Pesquise e leia a documentação do Microsoft Learn.",
|
||||
"aws-docs": "Pesquise a documentação e os guias de serviços da AWS.",
|
||||
"brave-search": "Pesquise web, notícias, imagens, vídeos e resultados locais com o Brave Search.",
|
||||
"postman": "Inspecione e gerencie APIs, coleções e espaços de trabalho do Postman.",
|
||||
"figma": "Leia o contexto de design do Figma pelo MCP local do Dev Mode.",
|
||||
"xmind": "Crie, leia e edite mapas mentais do Xmind na nuvem.",
|
||||
"notion": "Leia e atualize seu espaço de trabalho do Notion.",
|
||||
"linear": "Encontre e gerencie issues, projetos e comentários do Linear.",
|
||||
"github": "Gerencie repositórios, issues e pull requests do GitHub.",
|
||||
"supabase": "Inspecione e gerencie projetos do Supabase."
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"light": "Claro",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "Sắp ra mắt",
|
||||
"comingSoon": "Sắp ra mắt",
|
||||
"statusNotInstalled": "Chưa bật",
|
||||
"manage": "Quản lý",
|
||||
"manageTitle": "Quản lý {{name}}",
|
||||
"fixConnection": "Khắc phục kết nối",
|
||||
"overviewTab": "Tổng quan",
|
||||
"connectionTab": "Kết nối",
|
||||
"manageTabs": "Quản lý MCP",
|
||||
"about": "Giới thiệu",
|
||||
"statusLabel": "Trạng thái",
|
||||
"transportLabel": "Giao thức",
|
||||
"endpointLabel": "Điểm cuối",
|
||||
"toolPreview": "Công cụ",
|
||||
"noToolsAvailable": "Không có công cụ khả dụng",
|
||||
"searchTools": "Tìm công cụ",
|
||||
"searchToolsPlaceholder": "Tìm công cụ",
|
||||
"selectedCount": "Đã bật {{count}}",
|
||||
"noMatchingTools": "Không có công cụ phù hợp",
|
||||
"applyChanges": "Lưu thay đổi",
|
||||
"connectionDetails": "Chi tiết kết nối",
|
||||
"credentials": "Thông tin xác thực",
|
||||
"none": "Không có",
|
||||
"dangerZone": "Xóa kết nối",
|
||||
"toolScope": "Công cụ",
|
||||
"allTools": "Tất cả",
|
||||
"noTools": "Không có",
|
||||
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
|
||||
"allTools": "Bật tất cả",
|
||||
"noTools": "Tắt tất cả",
|
||||
"reloadTools": "Tải lại công cụ",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "Tự động hóa trình duyệt đám mây bằng Browserbase.",
|
||||
"playwright": "Kiểm tra và tự động hóa trình duyệt cục bộ bằng Playwright.",
|
||||
"context7": "Tải tài liệu thư viện mới nhất và ví dụ mã.",
|
||||
"firecrawl": "Thu thập, tìm kiếm và trích xuất nội dung web.",
|
||||
"parallel-search": "Tìm kiếm trên web và đọc các trang liên quan.",
|
||||
"exa": "Tìm kiếm trên web và trích xuất nội dung trang đã làm sạch.",
|
||||
"microsoft-learn": "Tìm kiếm và đọc tài liệu Microsoft Learn.",
|
||||
"aws-docs": "Tìm kiếm tài liệu và hướng dẫn dịch vụ AWS.",
|
||||
"brave-search": "Tìm kiếm web, tin tức, hình ảnh, video và kết quả địa phương bằng Brave Search.",
|
||||
"postman": "Kiểm tra và quản lý API, bộ sưu tập và không gian làm việc Postman.",
|
||||
"figma": "Đọc ngữ cảnh thiết kế Figma qua MCP Dev Mode cục bộ.",
|
||||
"xmind": "Tạo, đọc và chỉnh sửa sơ đồ tư duy Xmind trên đám mây.",
|
||||
"notion": "Đọc và cập nhật không gian làm việc Notion.",
|
||||
"linear": "Tìm và quản lý vấn đề, dự án và bình luận Linear.",
|
||||
"github": "Quản lý kho mã, issue và pull request GitHub.",
|
||||
"supabase": "Kiểm tra và quản lý dự án Supabase."
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "Máy chủ API", "openaiCompatible": "API tương thích OpenAI", "description": "Kết nối SDK và tác nhân qua điểm cuối /v1 cục bộ.",
|
||||
|
||||
@@ -384,10 +384,49 @@
|
||||
"statusComingSoon": "暂不支持",
|
||||
"comingSoon": "即将推出",
|
||||
"statusNotInstalled": "未启用",
|
||||
"manage": "管理",
|
||||
"manageTitle": "管理 {{name}}",
|
||||
"fixConnection": "修复连接",
|
||||
"overviewTab": "概览",
|
||||
"connectionTab": "连接",
|
||||
"manageTabs": "MCP 管理",
|
||||
"about": "简介",
|
||||
"statusLabel": "状态",
|
||||
"transportLabel": "传输方式",
|
||||
"endpointLabel": "端点",
|
||||
"toolPreview": "工具",
|
||||
"noToolsAvailable": "没有可用工具",
|
||||
"searchTools": "搜索工具",
|
||||
"searchToolsPlaceholder": "搜索工具",
|
||||
"selectedCount": "已启用 {{count}} 个",
|
||||
"noMatchingTools": "没有匹配的工具",
|
||||
"applyChanges": "保存更改",
|
||||
"connectionDetails": "连接详情",
|
||||
"credentials": "凭据",
|
||||
"none": "无",
|
||||
"dangerZone": "移除连接",
|
||||
"toolScope": "工具",
|
||||
"allTools": "全部",
|
||||
"noTools": "不暴露",
|
||||
"testForTools": "运行测试后,可以查看并选择单个工具。"
|
||||
"allTools": "全选",
|
||||
"noTools": "全部停用",
|
||||
"reloadTools": "重新读取",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "使用 Browserbase 自动操作云端浏览器。",
|
||||
"playwright": "使用 Playwright 检查和自动操作本地浏览器。",
|
||||
"context7": "获取最新的库文档和代码示例。",
|
||||
"firecrawl": "抓取、遍历、搜索并提取网页内容。",
|
||||
"parallel-search": "搜索网页并读取相关页面。",
|
||||
"exa": "搜索网页并提取整洁的页面内容。",
|
||||
"microsoft-learn": "搜索并读取 Microsoft Learn 文档。",
|
||||
"aws-docs": "搜索 AWS 文档和服务指南。",
|
||||
"brave-search": "使用 Brave Search 搜索网页、新闻、图片、视频和本地信息。",
|
||||
"postman": "查看并管理 Postman API、集合和工作区。",
|
||||
"figma": "通过本地 Dev Mode MCP 读取 Figma 设计上下文。",
|
||||
"xmind": "创建、读取和编辑 Xmind 云端思维导图。",
|
||||
"notion": "读取和更新 Notion 工作区。",
|
||||
"linear": "查找和管理 Linear 议题、项目和评论。",
|
||||
"github": "管理 GitHub 仓库、议题和拉取请求。",
|
||||
"supabase": "查看和管理 Supabase 项目。"
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"light": "浅色",
|
||||
|
||||
@@ -569,10 +569,49 @@
|
||||
"statusComingSoon": "即將推出",
|
||||
"comingSoon": "即將推出",
|
||||
"statusNotInstalled": "未啟用",
|
||||
"manage": "管理",
|
||||
"manageTitle": "管理 {{name}}",
|
||||
"fixConnection": "修復連線",
|
||||
"overviewTab": "概覽",
|
||||
"connectionTab": "連線",
|
||||
"manageTabs": "MCP 管理",
|
||||
"about": "簡介",
|
||||
"statusLabel": "狀態",
|
||||
"transportLabel": "傳輸方式",
|
||||
"endpointLabel": "端點",
|
||||
"toolPreview": "工具",
|
||||
"noToolsAvailable": "沒有可用工具",
|
||||
"searchTools": "搜尋工具",
|
||||
"searchToolsPlaceholder": "搜尋工具",
|
||||
"selectedCount": "已啟用 {{count}} 個",
|
||||
"noMatchingTools": "找不到符合的工具",
|
||||
"applyChanges": "儲存變更",
|
||||
"connectionDetails": "連線詳細資料",
|
||||
"credentials": "憑證",
|
||||
"none": "無",
|
||||
"dangerZone": "移除連線",
|
||||
"toolScope": "工具",
|
||||
"allTools": "全部",
|
||||
"noTools": "無",
|
||||
"testForTools": "執行 [測試] 以檢視並選擇個別工具。"
|
||||
"allTools": "全選",
|
||||
"noTools": "全部停用",
|
||||
"reloadTools": "重新讀取",
|
||||
"presetDescriptions": {
|
||||
"browserbase": "使用 Browserbase 自動操作雲端瀏覽器。",
|
||||
"playwright": "使用 Playwright 檢查和自動操作本機瀏覽器。",
|
||||
"context7": "取得最新的程式庫文件和程式碼範例。",
|
||||
"firecrawl": "擷取、檢索、搜尋並提取網頁內容。",
|
||||
"parallel-search": "搜尋網頁並讀取相關頁面。",
|
||||
"exa": "搜尋網頁並提取乾淨的頁面內容。",
|
||||
"microsoft-learn": "搜尋並讀取 Microsoft Learn 文件。",
|
||||
"aws-docs": "搜尋 AWS 文件和服務指南。",
|
||||
"brave-search": "使用 Brave Search 搜尋網頁、新聞、圖片、影片和本地資訊。",
|
||||
"postman": "查看並管理 Postman API、集合和工作區。",
|
||||
"figma": "透過本機 Dev Mode MCP 讀取 Figma 設計內容。",
|
||||
"xmind": "建立、讀取和編輯 Xmind 雲端心智圖。",
|
||||
"notion": "讀取和更新 Notion 工作區。",
|
||||
"linear": "尋找和管理 Linear 議題、專案和留言。",
|
||||
"github": "管理 GitHub 儲存庫、議題和 Pull Request。",
|
||||
"supabase": "查看並管理 Supabase 專案。"
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"title": "API 伺服器", "openaiCompatible": "OpenAI 相容 API", "description": "讓 SDK 與其他智能體透過本機 /v1 端點連線 nanobot。",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
McpManagementDialog,
|
||||
type McpManagementTab,
|
||||
} from "@/components/settings/system/McpManagementDialog";
|
||||
import i18n from "@/i18n";
|
||||
import type { McpPresetInfo } from "@/lib/types";
|
||||
|
||||
const connectedPreset: McpPresetInfo = {
|
||||
name: "docs",
|
||||
display_name: "Docs MCP",
|
||||
category: "productivity",
|
||||
description: "Search and maintain the team knowledge base.",
|
||||
docs_url: "https://example.com/docs-mcp",
|
||||
transport: "streamableHttp",
|
||||
auth: null,
|
||||
requires: "",
|
||||
note: "",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
status: "configured",
|
||||
runtime_status: "connected",
|
||||
required_fields: [],
|
||||
connection_summary: "https://mcp.example.com/mcp",
|
||||
tool_count: 3,
|
||||
tool_names: ["search_docs", "write_note", "list_sources"],
|
||||
enabled_tools: ["*"],
|
||||
checked_at: "2026-08-12T08:00:00Z",
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
describe("McpManagementDialog", () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
it("keeps tool scope changes as a draft until Save changes", () => {
|
||||
const onToolsChange = vi.fn();
|
||||
renderDialog({ initialTab: "tools", onToolsChange });
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Docs MCP" });
|
||||
expect(dialog).toHaveClass("h-[min(34rem,calc(100dvh-2rem))]");
|
||||
expect(within(dialog).getByRole("tab", { name: /Tools/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(dialog).getByText("3 enabled")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Close")).not.toBeInTheDocument();
|
||||
|
||||
const searchDocs = within(dialog).getByRole("checkbox", { name: /search_docs/ });
|
||||
expect(searchDocs).toBeChecked();
|
||||
fireEvent.click(searchDocs);
|
||||
|
||||
expect(searchDocs).not.toBeChecked();
|
||||
expect(within(dialog).getByText("2 enabled")).toBeInTheDocument();
|
||||
expect(onToolsChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Save changes" }));
|
||||
expect(onToolsChange).toHaveBeenCalledWith("docs", ["write_note", "list_sources"]);
|
||||
});
|
||||
|
||||
it("searches the inventory and exposes connection management in the same modal", () => {
|
||||
const onAction = vi.fn();
|
||||
renderDialog({ initialTab: "tools", onAction });
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Docs MCP" });
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search tools" }), {
|
||||
target: { value: "write" },
|
||||
});
|
||||
expect(within(dialog).getByRole("checkbox", { name: /write_note/ })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("checkbox", { name: /search_docs/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("tab", { name: "Connection" }));
|
||||
expect(within(dialog).getByText("https://mcp.example.com/mcp")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Streamable HTTP")).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("link", { name: "Open docs" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("button", { name: "Remove connection" })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Last checked")).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Connection actions apply immediately.")).not.toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Reconnect" }));
|
||||
expect(onAction).toHaveBeenCalledWith("reconnect", "docs", {});
|
||||
});
|
||||
|
||||
it("loads tools on entry and replaces passive inspection copy with recovery", async () => {
|
||||
const onAction = vi.fn();
|
||||
renderDialog({
|
||||
initialTab: "tools",
|
||||
onAction,
|
||||
preset: { ...connectedPreset, tool_count: 0, tool_names: [], enabled_tools: ["*"] },
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Docs MCP" });
|
||||
await waitFor(() => expect(onAction).toHaveBeenCalledWith("test", "docs"));
|
||||
expect(within(dialog).queryByText("Not inspected")).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByText("No tools available")).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Reload tools" }));
|
||||
expect(onAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
function renderDialog({
|
||||
initialTab,
|
||||
onAction = vi.fn(),
|
||||
onToolsChange = vi.fn(),
|
||||
preset = connectedPreset,
|
||||
}: {
|
||||
initialTab: McpManagementTab;
|
||||
onAction?: ReturnType<typeof vi.fn>;
|
||||
onToolsChange?: ReturnType<typeof vi.fn>;
|
||||
preset?: McpPresetInfo;
|
||||
}) {
|
||||
function Harness() {
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
return (
|
||||
<McpManagementDialog
|
||||
preset={preset}
|
||||
values={{}}
|
||||
actionKey={null}
|
||||
statusLabel="Connected"
|
||||
statusTone="success"
|
||||
tab={tab}
|
||||
icon={<span aria-hidden>DM</span>}
|
||||
onTabChange={setTab}
|
||||
onOpenChange={vi.fn()}
|
||||
onFieldChange={vi.fn()}
|
||||
onAction={onAction}
|
||||
onOAuthConnect={vi.fn()}
|
||||
onToolsChange={onToolsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return render(<Harness />);
|
||||
}
|
||||
@@ -128,8 +128,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
|
||||
.toHaveTextContent("Configured");
|
||||
expect(await screen.findByRole("button", { name: "Manage Xmind" }, { timeout: 2500 }))
|
||||
.toHaveTextContent("Manage");
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Xmind connected.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/some servers did not connect: notion/i)).not.toBeInTheDocument();
|
||||
@@ -182,22 +182,21 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(failed.closest("button")).toBeNull();
|
||||
expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull();
|
||||
expect(row?.querySelector(".lucide-check")).toBeNull();
|
||||
expect(within(row as HTMLElement).getByRole("button", { name: "Reconnect Xmind" })).toHaveTextContent(
|
||||
"Reconnect",
|
||||
expect(within(row as HTMLElement).getByRole("button", { name: "Manage Xmind" })).toHaveTextContent(
|
||||
"Fix connection",
|
||||
);
|
||||
const actions = within(row as HTMLElement).getByRole("button", { name: "Actions for Xmind" });
|
||||
expect(actions).toHaveAttribute("aria-haspopup", "menu");
|
||||
fireEvent.pointerDown(actions, { button: 0, ctrlKey: false });
|
||||
expect(await screen.findByRole("menuitem", { name: "Test" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await act(() => i18n.changeLanguage("zh-CN"));
|
||||
expect(within(row as HTMLElement).getByText("连接失败。")).toBeInTheDocument();
|
||||
expect(within(row as HTMLElement).getByRole("button", { name: "Xmind 操作" }))
|
||||
.toBeInTheDocument();
|
||||
const reconnect = screen.getByRole("button", { name: "重新连接 Xmind" });
|
||||
expect(reconnect).toHaveTextContent("重新连接");
|
||||
const manage = within(row as HTMLElement).getByRole("button", { name: "管理 Xmind" });
|
||||
expect(manage).toHaveTextContent("修复连接");
|
||||
fireEvent.click(manage);
|
||||
const dialog = screen.getByRole("dialog", { name: "Xmind" });
|
||||
expect(within(dialog).getByRole("tab", { name: "连接" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(dialog).getByText("创建、读取和编辑 Xmind 云端思维导图。")).toHaveClass("sr-only");
|
||||
expect(within(dialog).getByText("连接失败", { exact: true })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("button", { name: "移除连接" })).toBeInTheDocument();
|
||||
const reconnect = within(dialog).getByRole("button", { name: "重新连接" });
|
||||
fireEvent.click(reconnect);
|
||||
|
||||
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
@@ -246,9 +245,9 @@ describe("SettingsView Apps catalog", () => {
|
||||
.toHaveTextContent("Connecting…");
|
||||
expect(await screen.findByRole(
|
||||
"button",
|
||||
{ name: "Xmind: Connected." },
|
||||
{ name: "Manage Xmind" },
|
||||
{ timeout: 2_500 },
|
||||
)).toHaveTextContent("Connected.");
|
||||
)).toHaveTextContent("Manage");
|
||||
expect(mcpPresetRequests).toBe(2);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
|
||||
@@ -299,15 +298,17 @@ describe("SettingsView Apps catalog", () => {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Manage team-docs" }));
|
||||
fireEvent.click(within(screen.getByRole("dialog", { name: "team-docs" }))
|
||||
.getByRole("button", { name: "Reconnect" }));
|
||||
|
||||
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.reconnect",
|
||||
{ name: "team-docs" },
|
||||
20_000,
|
||||
));
|
||||
const connected = await screen.findByRole("button", { name: "team-docs: Connected." });
|
||||
expect(connected).toHaveTextContent("Connected.");
|
||||
const connected = await screen.findByRole("button", { name: "Manage team-docs" });
|
||||
expect(connected).toHaveTextContent("Manage");
|
||||
expect(connected.querySelector(".lucide-check")).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
|
||||
const readyHeading = await screen.findByRole("heading", { name: "team-docs" });
|
||||
@@ -500,8 +501,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
{ flow_id: "flow-manual", callback_url: callbackUrl },
|
||||
20_000,
|
||||
));
|
||||
expect(await screen.findByRole("button", { name: "Xmind: Configured" }, { timeout: 2500 }))
|
||||
.toHaveTextContent("Configured");
|
||||
expect(await screen.findByRole("button", { name: "Manage Xmind" }, { timeout: 2500 }))
|
||||
.toHaveTextContent("Manage");
|
||||
expect(screen.queryByRole("textbox", { name: "Full callback URL" })).not.toBeInTheDocument();
|
||||
expect(popup.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -610,7 +611,10 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
renderSettingsView({ initialSection: "apps" });
|
||||
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Manage Xmind" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Xmind" });
|
||||
fireEvent.click(within(dialog).getByRole("tab", { name: "Connection" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Remove connection" }));
|
||||
|
||||
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
|
||||
"settings.mcp.remove",
|
||||
|
||||
Reference in New Issue
Block a user