feat(frontend): implement unified MCP panel for v3.7.0
Complete Phase 3 (P0) frontend implementation for unified MCP management:
**New Files:**
- src/hooks/useMcp.ts: React Query hooks for unified MCP operations
- src/components/mcp/UnifiedMcpPanel.tsx: Unified MCP management panel
- src/components/ui/checkbox.tsx: Checkbox component from shadcn/ui
**Features:**
- Unified panel with three-column layout: server info + app checkboxes + actions
- Multi-app control: Claude/Codex/Gemini checkboxes for each server
- Real-time stats: Show enabled server counts per app
- Full CRUD operations: Add, edit, delete, sync all servers
**Integration:**
- Replace old app-specific McpPanel with UnifiedMcpPanel in App.tsx
- Update McpFormModal to support unified mode with apps field
- Add i18n support: mcp.unifiedPanel namespace (zh/en)
**Type Safety:**
- Ensure McpServer.apps field always initialized
- Fix all test files to include apps field
- TypeScript type check passes ✅
**Architecture:**
- Single source of truth: mcp.servers manages all MCP configs
- Per-server app control: apps.claude/codex/gemini boolean flags
- Backward compatible: McpFormModal supports both unified and legacy modes
Next: P1 tasks (import dialogs, sub-components, tests)
This commit is contained in:
@@ -20,7 +20,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|||||||
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import McpPanel from "@/components/mcp/McpPanel";
|
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
|
||||||
import PromptPanel from "@/components/prompts/PromptPanel";
|
import PromptPanel from "@/components/prompts/PromptPanel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -302,10 +302,9 @@ function App() {
|
|||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<McpPanel
|
<UnifiedMcpPanel
|
||||||
open={isMcpOpen}
|
open={isMcpOpen}
|
||||||
onOpenChange={setIsMcpOpen}
|
onOpenChange={setIsMcpOpen}
|
||||||
appId={activeApp}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "@/utils/tomlUtils";
|
} from "@/utils/tomlUtils";
|
||||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||||
import { useMcpValidation } from "./useMcpValidation";
|
import { useMcpValidation } from "./useMcpValidation";
|
||||||
|
import { useUpsertMcpServer } from "@/hooks/useMcp";
|
||||||
|
|
||||||
interface McpFormModalProps {
|
interface McpFormModalProps {
|
||||||
appId: AppId;
|
appId: AppId;
|
||||||
@@ -46,6 +47,7 @@ interface McpFormModalProps {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
existingIds?: string[];
|
existingIds?: string[];
|
||||||
|
unified?: boolean; // 统一模式:使用 useUpsertMcpServer 而非传统回调
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,11 +62,15 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
existingIds = [],
|
existingIds = [],
|
||||||
|
unified = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||||
useMcpValidation();
|
useMcpValidation();
|
||||||
|
|
||||||
|
// 统一模式下使用 mutation
|
||||||
|
const upsertMutation = useUpsertMcpServer();
|
||||||
|
|
||||||
const [formId, setFormId] = useState(
|
const [formId, setFormId] = useState(
|
||||||
() => editingId || initialData?.id || "",
|
() => editingId || initialData?.id || "",
|
||||||
);
|
);
|
||||||
@@ -361,24 +367,32 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
// 先处理 name 字段(必填)
|
||||||
|
const nameTrimmed = (formName || trimmedId).trim();
|
||||||
|
const finalName = nameTrimmed || trimmedId;
|
||||||
|
|
||||||
const entry: McpServer = {
|
const entry: McpServer = {
|
||||||
...(initialData ? { ...initialData } : {}),
|
...(initialData ? { ...initialData } : {}),
|
||||||
id: trimmedId,
|
id: trimmedId,
|
||||||
|
name: finalName,
|
||||||
server: serverSpec,
|
server: serverSpec,
|
||||||
|
// 确保 apps 字段始终存在(v3.7.0 新架构必需)
|
||||||
|
apps: initialData?.apps || { claude: false, codex: false, gemini: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修复:新增 MCP 时默认启用(enabled=true)
|
// 统一模式下无需再初始化 apps(上面已经处理)
|
||||||
// 编辑模式下保留原有的 enabled 状态
|
// 传统模式需要设置 enabled 字段
|
||||||
if (initialData?.enabled !== undefined) {
|
if (!unified) {
|
||||||
entry.enabled = initialData.enabled;
|
// 传统模式:新增 MCP 时默认启用(enabled=true)
|
||||||
} else {
|
// 编辑模式下保留原有的 enabled 状态
|
||||||
// 新增模式:默认启用
|
if (initialData?.enabled !== undefined) {
|
||||||
entry.enabled = true;
|
entry.enabled = initialData.enabled;
|
||||||
|
} else {
|
||||||
|
// 新增模式:默认启用
|
||||||
|
entry.enabled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameTrimmed = (formName || trimmedId).trim();
|
|
||||||
entry.name = nameTrimmed || trimmedId;
|
|
||||||
|
|
||||||
const descriptionTrimmed = formDescription.trim();
|
const descriptionTrimmed = formDescription.trim();
|
||||||
if (descriptionTrimmed) {
|
if (descriptionTrimmed) {
|
||||||
entry.description = descriptionTrimmed;
|
entry.description = descriptionTrimmed;
|
||||||
@@ -410,8 +424,16 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
delete entry.tags;
|
delete entry.tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显式等待父组件保存流程
|
// 显式等待保存流程
|
||||||
await onSave(trimmedId, entry, { syncOtherSide });
|
if (unified) {
|
||||||
|
// 统一模式:调用 useUpsertMcpServer mutation
|
||||||
|
await upsertMutation.mutateAsync(entry);
|
||||||
|
toast.success(t("common.success"));
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// 传统模式:调用父组件回调
|
||||||
|
await onSave(trimmedId, entry, { syncOtherSide });
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const detail = extractErrorMessage(error);
|
const detail = extractErrorMessage(error);
|
||||||
const mapped = translateMcpBackendError(detail, t);
|
const mapped = translateMcpBackendError(detail, t);
|
||||||
|
|||||||
397
src/components/mcp/UnifiedMcpPanel.tsx
Normal file
397
src/components/mcp/UnifiedMcpPanel.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, Server, Check, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { useAllMcpServers, useToggleMcpApp, useSyncAllMcpServers } from "@/hooks/useMcp";
|
||||||
|
import type { McpServer } from "@/types";
|
||||||
|
import type { AppId } from "@/lib/api/types";
|
||||||
|
import McpFormModal from "./McpFormModal";
|
||||||
|
import { ConfirmDialog } from "../ConfirmDialog";
|
||||||
|
import { useDeleteMcpServer } from "@/hooks/useMcp";
|
||||||
|
import { Edit3, Trash2 } from "lucide-react";
|
||||||
|
import { settingsApi } from "@/lib/api";
|
||||||
|
import { mcpPresets } from "@/config/mcpPresets";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UnifiedMcpPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一 MCP 管理面板
|
||||||
|
* v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端
|
||||||
|
*/
|
||||||
|
const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Queries and Mutations
|
||||||
|
const { data: serversMap, isLoading } = useAllMcpServers();
|
||||||
|
const toggleAppMutation = useToggleMcpApp();
|
||||||
|
const deleteServerMutation = useDeleteMcpServer();
|
||||||
|
const syncAllMutation = useSyncAllMcpServers();
|
||||||
|
|
||||||
|
// Convert serversMap to array for easier rendering
|
||||||
|
const serverEntries = useMemo((): Array<[string, McpServer]> => {
|
||||||
|
if (!serversMap) return [];
|
||||||
|
return Object.entries(serversMap);
|
||||||
|
}, [serversMap]);
|
||||||
|
|
||||||
|
// Count enabled servers per app
|
||||||
|
const enabledCounts = useMemo(() => {
|
||||||
|
const counts = { claude: 0, codex: 0, gemini: 0 };
|
||||||
|
serverEntries.forEach(([_, server]) => {
|
||||||
|
if (server.apps.claude) counts.claude++;
|
||||||
|
if (server.apps.codex) counts.codex++;
|
||||||
|
if (server.apps.gemini) counts.gemini++;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [serverEntries]);
|
||||||
|
|
||||||
|
const handleToggleApp = async (
|
||||||
|
serverId: string,
|
||||||
|
app: AppId,
|
||||||
|
enabled: boolean,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
await toggleAppMutation.mutateAsync({ serverId, app, enabled });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("common.error"), {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
title: t("mcp.unifiedPanel.deleteServer"),
|
||||||
|
message: t("mcp.unifiedPanel.deleteConfirm", { id }),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteServerMutation.mutateAsync(id);
|
||||||
|
setConfirmDialog(null);
|
||||||
|
toast.success(t("common.success"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("common.error"), {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncAll = async () => {
|
||||||
|
try {
|
||||||
|
await syncAllMutation.mutateAsync();
|
||||||
|
toast.success(t("mcp.unifiedPanel.syncAllSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("common.error"), {
|
||||||
|
description: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between pr-8">
|
||||||
|
<DialogTitle>{t("mcp.unifiedPanel.title")}</DialogTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSyncAll}
|
||||||
|
disabled={syncAllMutation.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
size={16}
|
||||||
|
className={syncAllMutation.isPending ? "animate-spin" : ""}
|
||||||
|
/>
|
||||||
|
{t("mcp.unifiedPanel.syncAll")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="mcp" onClick={handleAdd}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("mcp.unifiedPanel.addServer")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t("mcp.serverCount", { count: serverEntries.length })} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "}
|
||||||
|
{t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - Scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pb-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
{t("mcp.loading")}
|
||||||
|
</div>
|
||||||
|
) : serverEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||||
|
<Server
|
||||||
|
size={24}
|
||||||
|
className="text-gray-400 dark:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{t("mcp.unifiedPanel.noServers")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{t("mcp.emptyDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{serverEntries.map(([id, server]) => (
|
||||||
|
<UnifiedMcpListItem
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
server={server}
|
||||||
|
onToggleApp={handleToggleApp}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="mcp"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
{t("common.done")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Form Modal */}
|
||||||
|
{isFormOpen && (
|
||||||
|
<McpFormModal
|
||||||
|
appId="claude" // Default to claude for unified panel
|
||||||
|
editingId={editingId || undefined}
|
||||||
|
initialData={editingId && serversMap ? serversMap[editingId] : undefined}
|
||||||
|
existingIds={serversMap ? Object.keys(serversMap) : []}
|
||||||
|
onSave={async () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
unified
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Dialog */}
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDialog.isOpen}
|
||||||
|
title={confirmDialog.title}
|
||||||
|
message={confirmDialog.message}
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一 MCP 列表项组件
|
||||||
|
* 展示服务器名称、描述,以及三个应用的复选框
|
||||||
|
*/
|
||||||
|
interface UnifiedMcpListItemProps {
|
||||||
|
id: string;
|
||||||
|
server: McpServer;
|
||||||
|
onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void;
|
||||||
|
onEdit: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||||
|
id,
|
||||||
|
server,
|
||||||
|
onToggleApp,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const name = server.name || id;
|
||||||
|
const description = server.description || "";
|
||||||
|
|
||||||
|
// 匹配预设元信息
|
||||||
|
const meta = mcpPresets.find((p) => p.id === id);
|
||||||
|
const docsUrl = server.docs || meta?.docs;
|
||||||
|
const homepageUrl = server.homepage || meta?.homepage;
|
||||||
|
const tags = server.tags || meta?.tags;
|
||||||
|
|
||||||
|
const openDocs = async () => {
|
||||||
|
const url = docsUrl || homepageUrl;
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await settingsApi.openExternal(url);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-16 rounded-lg border border-border-default bg-card p-4 transition-[border-color,box-shadow] duration-200 hover:border-border-hover hover:shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 左侧:服务器信息 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!description && tags && tags.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
||||||
|
{tags.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间:应用复选框 */}
|
||||||
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-claude`}
|
||||||
|
checked={server.apps.claude}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "claude", checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-claude`}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.claude")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-codex`}
|
||||||
|
checked={server.apps.codex}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "codex", checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-codex`}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.codex")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${id}-gemini`}
|
||||||
|
checked={server.apps.gemini}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
onToggleApp(id, "gemini", checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${id}-gemini`}
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.gemini")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:操作按钮 */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{docsUrl && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={openDocs}
|
||||||
|
title={t("mcp.presets.docs")}
|
||||||
|
>
|
||||||
|
{t("mcp.presets.docs")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onEdit(id)}
|
||||||
|
title={t("common.edit")}
|
||||||
|
>
|
||||||
|
<Edit3 size={16} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onDelete(id)}
|
||||||
|
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||||
|
title={t("common.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnifiedMcpPanel;
|
||||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("grid place-content-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
70
src/hooks/useMcp.ts
Normal file
70
src/hooks/useMcp.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { mcpApi } from '@/lib/api/mcp';
|
||||||
|
import type { McpServer } from '@/types';
|
||||||
|
import type { AppId } from '@/lib/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有 MCP 服务器(统一管理)
|
||||||
|
*/
|
||||||
|
export function useAllMcpServers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['mcp', 'all'],
|
||||||
|
queryFn: () => mcpApi.getAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加或更新 MCP 服务器
|
||||||
|
*/
|
||||||
|
export function useUpsertMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 MCP 服务器在特定应用的启用状态
|
||||||
|
*/
|
||||||
|
export function useToggleMcpApp() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
serverId,
|
||||||
|
app,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
app: AppId;
|
||||||
|
enabled: boolean;
|
||||||
|
}) => mcpApi.toggleApp(serverId, app, enabled),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除 MCP 服务器
|
||||||
|
*/
|
||||||
|
export function useDeleteMcpServer() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步所有启用的 MCP 服务器到各应用的 live 配置
|
||||||
|
*/
|
||||||
|
export function useSyncAllMcpServers() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => mcpApi.syncAllServers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -441,6 +441,22 @@
|
|||||||
"claudeTitle": "Claude Code MCP Management",
|
"claudeTitle": "Claude Code MCP Management",
|
||||||
"codexTitle": "Codex MCP Management",
|
"codexTitle": "Codex MCP Management",
|
||||||
"geminiTitle": "Gemini MCP Management",
|
"geminiTitle": "Gemini MCP Management",
|
||||||
|
"unifiedPanel": {
|
||||||
|
"title": "MCP Server Management",
|
||||||
|
"addServer": "Add Server",
|
||||||
|
"editServer": "Edit Server",
|
||||||
|
"deleteServer": "Delete Server",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.",
|
||||||
|
"syncAll": "Sync All",
|
||||||
|
"syncAllSuccess": "All enabled servers have been synced to application configs",
|
||||||
|
"noServers": "No servers yet",
|
||||||
|
"enabledApps": "Enabled Apps",
|
||||||
|
"apps": {
|
||||||
|
"claude": "Claude",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userLevelPath": "User-level MCP path",
|
"userLevelPath": "User-level MCP path",
|
||||||
"serverList": "Servers",
|
"serverList": "Servers",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|||||||
@@ -441,6 +441,22 @@
|
|||||||
"claudeTitle": "Claude Code MCP 管理",
|
"claudeTitle": "Claude Code MCP 管理",
|
||||||
"codexTitle": "Codex MCP 管理",
|
"codexTitle": "Codex MCP 管理",
|
||||||
"geminiTitle": "Gemini MCP 管理",
|
"geminiTitle": "Gemini MCP 管理",
|
||||||
|
"unifiedPanel": {
|
||||||
|
"title": "MCP 服务器管理",
|
||||||
|
"addServer": "添加服务器",
|
||||||
|
"editServer": "编辑服务器",
|
||||||
|
"deleteServer": "删除服务器",
|
||||||
|
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
|
||||||
|
"syncAll": "同步全部",
|
||||||
|
"syncAllSuccess": "已同步所有启用的服务器到各应用配置",
|
||||||
|
"noServers": "暂无服务器",
|
||||||
|
"enabledApps": "启用的应用",
|
||||||
|
"apps": {
|
||||||
|
"claude": "Claude",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
}
|
||||||
|
},
|
||||||
"userLevelPath": "用户级 MCP 配置路径",
|
"userLevelPath": "用户级 MCP 配置路径",
|
||||||
"serverList": "服务器列表",
|
"serverList": "服务器列表",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const createServer = (overrides: Partial<McpServer> = {}): McpServer => ({
|
|||||||
name: "Test Server",
|
name: "Test Server",
|
||||||
description: "desc",
|
description: "desc",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
apps: { claude: false, codex: false, gemini: false },
|
||||||
server: {
|
server: {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "run.sh",
|
command: "run.sh",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const baseServers: Record<string, McpServer> = {
|
|||||||
id: "sample",
|
id: "sample",
|
||||||
name: "Sample Claude Server",
|
name: "Sample Claude Server",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
apps: { claude: true, codex: false, gemini: false },
|
||||||
server: {
|
server: {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "claude-server",
|
command: "claude-server",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ let mcpConfigs: McpConfigState = {
|
|||||||
id: "sample",
|
id: "sample",
|
||||||
name: "Sample Claude Server",
|
name: "Sample Claude Server",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
apps: { claude: true, codex: false, gemini: false },
|
||||||
server: {
|
server: {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "claude-server",
|
command: "claude-server",
|
||||||
@@ -93,6 +94,7 @@ let mcpConfigs: McpConfigState = {
|
|||||||
id: "httpServer",
|
id: "httpServer",
|
||||||
name: "HTTP Codex Server",
|
name: "HTTP Codex Server",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
apps: { claude: false, codex: true, gemini: false },
|
||||||
server: {
|
server: {
|
||||||
type: "http",
|
type: "http",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
@@ -123,6 +125,7 @@ export const resetProviderState = () => {
|
|||||||
id: "sample",
|
id: "sample",
|
||||||
name: "Sample Claude Server",
|
name: "Sample Claude Server",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
apps: { claude: true, codex: false, gemini: false },
|
||||||
server: {
|
server: {
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "claude-server",
|
command: "claude-server",
|
||||||
@@ -134,6 +137,7 @@ export const resetProviderState = () => {
|
|||||||
id: "httpServer",
|
id: "httpServer",
|
||||||
name: "HTTP Codex Server",
|
name: "HTTP Codex Server",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
apps: { claude: false, codex: true, gemini: false },
|
||||||
server: {
|
server: {
|
||||||
type: "http",
|
type: "http",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user