Files
NetMesh/components/settings/tabs/ai/ExternalMcpCard.tsx

929 lines
35 KiB
TypeScript
Raw Normal View History

import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor";
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor"];
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
type ExternalMcpStatus = {
ok: boolean;
enabled?: boolean;
state?: string;
host?: string;
port?: number | null;
discoveryPath?: string | null;
launcherPath?: string | null;
exposedSessionCount?: number;
mode?: ExternalMcpMode;
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
permissionMode?: string;
error?: string | null;
};
type ClientSetupStatus = {
ok: boolean;
state?: string;
launcherPath?: string | null;
command?: string;
existingCommand?: string | null;
error?: string | null;
};
type StatusView = {
labelKey: string;
className: string;
};
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
if (!enabled) {
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
if (!status || !status.ok) {
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
}
if (status.state === "running") {
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
}
if (status.state === "starting") {
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
}
if (status.state === "error") {
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
}
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
/** Map bridge permissionMode to a Safety i18n key for display. */
function getPermissionModeLabelKey(mode: string | null | undefined): string {
switch (mode) {
case "observer":
return "ai.safety.permissionMode.observer";
case "auto":
return "ai.safety.permissionMode.auto";
case "confirm":
return "ai.safety.permissionMode.confirm";
default:
return "ai.externalMcp.permissionMode.unknown";
}
}
function getPermissionModeToneClass(mode: string | null | undefined): string {
switch (mode) {
case "auto":
return "text-emerald-500";
case "observer":
return "text-amber-500";
case "confirm":
return "text-foreground";
default:
return "text-muted-foreground";
}
}
function getCodexStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "codex_not_found":
return { labelKey: "ai.externalMcp.status.codexNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function getClaudeStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "claude_not_found":
return { labelKey: "ai.externalMcp.status.claudeNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function getGrokStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "grok_not_found":
return { labelKey: "ai.externalMcp.status.grokNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function escapeTomlBasicString(value: string) {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function quoteShellArg(value: string) {
if (!value) return '""';
if (!/[\s"'\\]/.test(value)) return value;
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
}
export const EXTERNAL_MCP_DISCOVERY_ENV_VAR = "NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE";
export function formatCodexAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` --env ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `codex mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
export function formatClaudeAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `claude mcp add -s user netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
export function formatGrokAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `grok mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
function buildTomlEnvBlock(discoveryPath?: string | null) {
if (!discoveryPath) return "";
return `\nenv = { ${EXTERNAL_MCP_DISCOVERY_ENV_VAR} = "${escapeTomlBasicString(discoveryPath)}" }`;
}
export function buildCodexTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
return `[mcp_servers.netcatty-external]
command = "${escapeTomlBasicString(launcherPath)}"
args = []${buildTomlEnvBlock(discoveryPath)}`;
}
export function buildGrokTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
return `[mcp_servers.netcatty-external]
command = "${escapeTomlBasicString(launcherPath)}"
args = []${buildTomlEnvBlock(discoveryPath)}`;
}
function buildJsonServerEntry(launcherPath: string, discoveryPath?: string | null) {
const entry: {
command: string;
args: string[];
env?: Record<string, string>;
} = {
command: launcherPath,
args: [],
};
if (discoveryPath) {
entry.env = { [EXTERNAL_MCP_DISCOVERY_ENV_VAR]: discoveryPath };
}
return entry;
}
export function buildClaudeSnippet(launcherPath: string, discoveryPath?: string | null) {
return JSON.stringify({
mcpServers: {
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
},
}, null, 2);
}
export function buildCursorSnippet(launcherPath: string, discoveryPath?: string | null) {
return JSON.stringify({
mcpServers: {
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
},
}, null, 2);
}
export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n();
const { enabled, setEnabled } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
const [grokStatus, setGrokStatus] = useState<ClientSetupStatus | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAddingCodex, setIsAddingCodex] = useState(false);
const [isAddingClaude, setIsAddingClaude] = useState(false);
const [isAddingGrok, setIsAddingGrok] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
const pushConfig = useCallback((nextMode: ExternalMcpMode, nextIdle: number, nextSessionIdle: number) => {
void getBridge()?.externalMcpSetConfig?.({
mode: nextMode,
idleTimeoutMinutes: nextIdle,
sessionIdleTimeoutMinutes: nextSessionIdle,
});
}, []);
const setMode = useCallback((nextMode: ExternalMcpMode) => {
const normalized = writeExternalMcpMode(nextMode);
setModeRaw(normalized);
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
setIdleTimeoutRaw(normalized);
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = writeSessionIdleTimeoutMinutes(minutes);
setSessionIdleTimeoutRaw(normalized);
pushConfig(mode, idleTimeoutMinutes, normalized);
}, [idleTimeoutMinutes, mode, pushConfig]);
const setFocusOnHostOpen = useCallback((nextFocusOnHostOpen: boolean) => {
setFocusOnHostOpenRaw(nextFocusOnHostOpen);
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
}, []);
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
setSilentSessionsRaw(nextSilentSessions);
writeExternalMcpSilentSessions(nextSilentSessions);
}, []);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge();
const includeClients = options?.clients !== false;
if (
!bridge?.externalMcpGetStatus
|| !bridge?.externalMcpCodexGetStatus
|| !bridge?.externalMcpClaudeGetStatus
|| !bridge?.externalMcpGrokGetStatus
) {
setStatus({
ok: false,
enabled,
state: "unavailable",
discoveryPath: null,
launcherPath: null,
exposedSessionCount: 0,
// Bridge default when IPC is missing; keeps the permission row from
// looking blank while Safety settings remain the source of truth.
permissionMode: "confirm",
error: bridgeUnavailableMessage,
});
const unavailableClientStatus: ClientSetupStatus = {
ok: true,
state: "error",
launcherPath: null,
command: "",
existingCommand: null,
error: bridgeUnavailableMessage,
};
setCodexStatus(unavailableClientStatus);
setClaudeStatus(unavailableClientStatus);
setGrokStatus(unavailableClientStatus);
return;
}
if (!options?.quiet) setIsRefreshing(true);
try {
if (includeClients) {
const [nextStatus, nextCodexStatus, nextClaudeStatus, nextGrokStatus] = await Promise.all([
bridge.externalMcpGetStatus(),
bridge.externalMcpCodexGetStatus(),
bridge.externalMcpClaudeGetStatus(),
bridge.externalMcpGrokGetStatus(),
]);
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
setCodexStatus(nextCodexStatus as ClientSetupStatus);
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
setGrokStatus(nextGrokStatus as ClientSetupStatus);
} else {
const nextStatus = await bridge.externalMcpGetStatus();
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
} finally {
if (!options?.quiet) setIsRefreshing(false);
}
}, [bridgeUnavailableMessage, enabled, setEnabled]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
useEffect(() => {
if (!enabled) return;
// Quiet polling only refreshes bridge runtime status. Spawning Codex/Claude/Grok
// CLIs every few seconds is too expensive for a settings page keep-alive.
const intervalId = window.setInterval(() => {
void refreshStatus({ quiet: true, clients: false });
}, 3000);
return () => window.clearInterval(intervalId);
}, [enabled, refreshStatus]);
useEffect(() => {
pushConfig(mode, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- sync stored config once on mount
const bridgeStatusView = useMemo(() => getBridgeStatusView(status, enabled), [enabled, status]);
const exposedSessionCount = enabled ? status?.exposedSessionCount ?? 0 : 0;
const codexStatusView = useMemo(() => getCodexStatusView(codexStatus), [codexStatus]);
const claudeStatusView = useMemo(() => getClaudeStatusView(claudeStatus), [claudeStatus]);
const grokStatusView = useMemo(() => getGrokStatusView(grokStatus), [grokStatus]);
const launcherPath = status?.launcherPath
|| codexStatus?.launcherPath
|| claudeStatus?.launcherPath
|| grokStatus?.launcherPath
|| null;
const discoveryPath = status?.discoveryPath || null;
// Prefer backend status.command so desktop-resolved absolute CLI paths
// (outside PATH) survive into the copyable setup command.
const codexCommand = (codexStatus?.command || "").trim()
|| (launcherPath ? formatCodexAddCommand(launcherPath, discoveryPath) : "");
const claudeCommand = (claudeStatus?.command || "").trim()
|| (launcherPath ? formatClaudeAddCommand(launcherPath, discoveryPath) : "");
const grokCommand = (grokStatus?.command || "").trim()
|| (launcherPath ? formatGrokAddCommand(launcherPath, discoveryPath) : "");
const codexTomlSnippet = launcherPath ? buildCodexTomlSnippet(launcherPath, discoveryPath) : "";
const grokTomlSnippet = launcherPath ? buildGrokTomlSnippet(launcherPath, discoveryPath) : "";
const claudeSnippet = launcherPath ? buildClaudeSnippet(launcherPath, discoveryPath) : "";
const cursorSnippet = launcherPath ? buildCursorSnippet(launcherPath, discoveryPath) : "";
const canAddToCodex = codexStatus?.state === "not_configured";
const canAddToClaude = claudeStatus?.state === "not_configured";
const canAddToGrok = grokStatus?.state === "not_configured";
const copyText = useCallback(async (key: string, text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(key);
window.setTimeout(() => {
setCopied((current) => (current === key ? null : current));
}, 1200);
} catch {
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
}
}, [t]);
const handleAddToCodex = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpCodexAdd) return;
setActionMessage(null);
setIsAddingCodex(true);
try {
const result = await bridge.externalMcpCodexAdd() as ClientSetupStatus;
setCodexStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.codexAdded") });
} else if (result.state === "codex_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installCodex") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingCodex(false);
}
}, [refreshStatus, t]);
const handleAddToClaude = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpClaudeAdd) return;
setActionMessage(null);
setIsAddingClaude(true);
try {
const result = await bridge.externalMcpClaudeAdd() as ClientSetupStatus;
setClaudeStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.claudeAdded") });
} else if (result.state === "claude_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installClaude") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingClaude(false);
}
}, [refreshStatus, t]);
const handleAddToGrok = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpGrokAdd) return;
setActionMessage(null);
setIsAddingGrok(true);
try {
const result = await bridge.externalMcpGrokAdd() as ClientSetupStatus;
setGrokStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.grokAdded") });
} else if (result.state === "grok_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installGrok") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingGrok(false);
}
}, [refreshStatus, t]);
const selectedClientMeta = useMemo(() => {
if (selectedClient === "cursor") {
return {
kind: "snippet" as const,
statusView: null as StatusView | null,
command: "",
snippet: cursorSnippet,
canAdd: false,
isAdding: false,
addLabelKey: "",
onAdd: null as (() => void) | null,
};
}
if (selectedClient === "claude") {
return {
kind: "installable" as const,
statusView: claudeStatusView,
command: claudeCommand,
snippet: claudeSnippet,
canAdd: canAddToClaude,
isAdding: isAddingClaude,
addLabelKey: "ai.externalMcp.addToClaude",
onAdd: () => { void handleAddToClaude(); },
};
}
if (selectedClient === "grok") {
return {
kind: "installable" as const,
statusView: grokStatusView,
command: grokCommand,
snippet: grokTomlSnippet,
canAdd: canAddToGrok,
isAdding: isAddingGrok,
addLabelKey: "ai.externalMcp.addToGrok",
onAdd: () => { void handleAddToGrok(); },
};
}
return {
kind: "installable" as const,
statusView: codexStatusView,
command: codexCommand,
snippet: codexTomlSnippet,
canAdd: canAddToCodex,
isAdding: isAddingCodex,
addLabelKey: "ai.externalMcp.addToCodex",
onAdd: () => { void handleAddToCodex(); },
};
}, [
canAddToClaude,
canAddToCodex,
canAddToGrok,
claudeCommand,
claudeSnippet,
claudeStatusView,
codexCommand,
codexStatusView,
codexTomlSnippet,
cursorSnippet,
grokCommand,
grokStatusView,
grokTomlSnippet,
handleAddToClaude,
handleAddToCodex,
handleAddToGrok,
isAddingClaude,
isAddingCodex,
isAddingGrok,
selectedClient,
]);
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex items-start gap-1.5">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.description")}
</p>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="relative -top-px mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label={t("ai.externalMcp.help.ariaLabel")}
>
<HelpCircle size={13} />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-[320px] space-y-1.5 bg-popover text-popover-foreground border border-border px-3 py-2.5 text-left text-xs leading-relaxed shadow-md"
>
<div className="font-medium text-foreground">{t("ai.externalMcp.usage.title")}</div>
<p>{t("ai.externalMcp.usage.keepRunning")}</p>
<p>{t("ai.externalMcp.usage.localhost")}</p>
<p>{t("ai.externalMcp.usage.permissions")}</p>
<p>{t("ai.externalMcp.usage.capabilities")}</p>
<div className="pt-1 font-medium text-foreground">{t("ai.externalMcp.security")}</div>
<p>{t("ai.externalMcp.security.description")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
{t(bridgeStatusView.labelKey)}
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
<div className="text-xs text-muted-foreground">
{t("ai.externalMcp.sessionsExposed", { count: String(exposedSessionCount) })}
</div>
</div>
<Toggle
checked={enabled}
onChange={(nextEnabled) => {
setActionMessage(null);
setEnabled(nextEnabled);
window.setTimeout(() => { void refreshStatus(); }, 0);
}}
/>
</div>
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
users see why write tools may still prompt (confirm) or run freely (auto). */}
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
<div className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.permissionMode.hint")}
</div>
</div>
<div
className={cn(
"shrink-0 text-xs font-medium text-right max-w-[12rem]",
getPermissionModeToneClass(status?.permissionMode),
)}
data-testid="external-mcp-permission-mode"
>
{t(getPermissionModeLabelKey(status?.permissionMode))}
</div>
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
label={t("ai.externalMcp.mode")}
description={t("ai.externalMcp.mode.description")}
>
<Select
value={mode}
options={[
{ value: "temporary", label: t("ai.externalMcp.mode.temporary") },
{ value: "persistent", label: t("ai.externalMcp.mode.persistent") },
]}
onChange={(value) => setMode(value === "persistent" ? "persistent" : "temporary")}
className="w-36"
/>
</SettingRow>
{mode === "temporary" ? (
<SettingRow
label={t("ai.externalMcp.idleTimeout")}
description={t("ai.externalMcp.idleTimeout.description")}
>
<div className="flex items-center gap-2 shrink-0">
<input
type="number"
aria-label={t("ai.externalMcp.idleTimeout")}
min={1}
max={24 * 60}
value={idleTimeoutMinutes}
onChange={(event) => {
const minutes = Number.parseInt(event.currentTarget.value, 10);
if (!Number.isFinite(minutes)) return;
setIdleTimeoutMinutes(minutes);
}}
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
) : null}
<SettingRow
label={t("ai.externalMcp.focusOnHostOpen")}
description={t("ai.externalMcp.focusOnHostOpen.description")}
>
<Toggle
checked={focusOnHostOpen}
onChange={setFocusOnHostOpen}
ariaLabel={t("ai.externalMcp.focusOnHostOpen")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.silentSessions")}
description={t("ai.externalMcp.silentSessions.description")}
>
<Toggle
checked={silentSessions}
onChange={setSilentSessions}
ariaLabel={t("ai.externalMcp.silentSessions")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.sessionIdleTimeout")}
description={t("ai.externalMcp.sessionIdleTimeout.description")}
>
<div className="flex items-center gap-2 shrink-0">
<input
type="number"
aria-label={t("ai.externalMcp.sessionIdleTimeout")}
min={1}
max={24 * 60}
value={sessionIdleTimeoutMinutes}
onChange={(event) => {
const minutes = Number.parseInt(event.currentTarget.value, 10);
if (!Number.isFinite(minutes)) return;
setSessionIdleTimeoutMinutes(minutes);
}}
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
</SettingCard>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<div className="text-sm font-semibold text-foreground">
{t("ai.externalMcp.clientConfiguration")}
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.clientConfiguration.description")}
</p>
</div>
<div
role="tablist"
aria-label={t("ai.externalMcp.clientConfiguration")}
className="grid grid-cols-4 gap-1 rounded-md bg-muted p-1"
>
{CLIENT_TABS.map((client) => {
const active = selectedClient === client;
return (
<button
key={client}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setSelectedClient(client);
setActionMessage(null);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`ai.externalMcp.client.${client}`)}
</button>
);
})}
</div>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
<div className="flex items-center justify-between gap-3">
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
{t(selectedClientMeta.statusView.labelKey)}
</div>
<Button
size="sm"
disabled={
!selectedClientMeta.canAdd
|| selectedClientMeta.isAdding
|| !enabled
|| !launcherPath
}
onClick={() => selectedClientMeta.onAdd?.()}
>
{t(selectedClientMeta.addLabelKey)}
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.cursor.description")}
</p>
)}
{selectedClientMeta.kind === "installable" ? (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.cliCommand")}
value={selectedClientMeta.command}
copyKey="command"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="snippet"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</>
) : (
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="cursor"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
)}
</div>
</div>
{actionMessage ? (
<div
className={cn(
"text-xs",
actionMessage.tone === "success" && "text-emerald-500",
actionMessage.tone === "warning" && "text-amber-500",
actionMessage.tone === "error" && "text-destructive",
)}
>
{actionMessage.text}
</div>
) : null}
{status?.error ? (
<div className="text-xs text-destructive">{status.error}</div>
) : null}
</div>
);
};