feat: add edit and refresh functionality for MCP servers in settings tab (#680)

* feat: add edit and refresh functionality for MCP servers in settings tab

* feat: fix lint error and enhance MCP server dialog with validation and error handling

* fix: add missing newline at the end of en.json file

* feat: only refreshing specific servers

* feat: add validation messages for MCP server configuration and improve server update logic
This commit is contained in:
Qiyuan Jiao
2025-11-06 10:38:45 +08:00
committed by GitHub
parent fea585ae3d
commit a38c8584d7
4 changed files with 351 additions and 12 deletions

View File

@@ -52,8 +52,15 @@
"learnMore": "learn more about MCP.",
"enableDisable": "Enable/disable server",
"deleteServer": "Delete server",
"editServer": "Edit server",
"refreshServer": "Refresh server",
"editServerDescription": "Edit the MCP server configuration",
"editServerNote": "Update the server configuration in JSON format",
"disabled": "Disabled",
"new": "New"
"new": "New",
"invalidJson": "Invalid JSON format",
"validationFailed": "Validation failed",
"missingServerName": "Missing server name"
},
"about": {
"title": "About"
@@ -81,9 +88,9 @@
},
"chat": {
"page": {
"loading": "Loading DeerFlow...",
"welcomeUser": "Welcome, {username}",
"starOnGitHub": "Star DeerFlow on GitHub"
"loading": "Loading DeerFlow...",
"welcomeUser": "Welcome, {username}",
"starOnGitHub": "Star DeerFlow on GitHub"
},
"welcome": {
"greeting": "👋 Hello, there!",
@@ -231,4 +238,4 @@
"contributeNow": "Contribute Now"
}
}
}
}

View File

@@ -52,8 +52,15 @@
"learnMore": "了解更多关于 MCP 的信息。",
"enableDisable": "启用/禁用服务器",
"deleteServer": "删除服务器",
"editServer": "编辑服务器",
"refreshServer": "刷新服务器",
"editServerDescription": "编辑 MCP 服务器配置",
"editServerNote": "以 JSON 格式更新服务器配置",
"disabled": "已禁用",
"new": "新增"
"new": "新增",
"invalidJson": "无效的 JSON 格式",
"validationFailed": "验证失败",
"missingServerName": "缺少服务器名称"
},
"about": {
"title": "关于"

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Textarea } from "~/components/ui/textarea";
import type { MCPServerMetadata } from "~/core/mcp";
import { MCPConfigSchema } from "~/core/mcp";
export function EditMCPServerDialog({
server,
onSave,
open,
onOpenChange,
}: {
server: MCPServerMetadata;
onSave: (config: string) => Promise<boolean>;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations("settings.mcp");
const commonT = useTranslations("common");
const [config, setConfig] = useState(
JSON.stringify(
{
mcpServers: {
[server.name]: server.transport === 'stdio'
? {
command: server.command,
args: server.args,
env: server.env,
}
: {
transport: server.transport,
url: server.url,
headers: server.headers,
},
},
},
null,
2
)
);
const [processing, setProcessing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const handleChange = useCallback((value: string) => {
setConfig(value);
setValidationError(null);
if (!value.trim()) {
return;
}
try {
const parsed = JSON.parse(value);
if (!("mcpServers" in parsed)) {
setValidationError("Missing `mcpServers` in JSON");
return;
}
const result = MCPConfigSchema.safeParse(parsed);
if (!result.success) {
if (result.error.errors[0]) {
const error = result.error.errors[0];
if (error.code === "invalid_union") {
if (error.unionErrors[0]?.errors[0]) {
setValidationError(error.unionErrors[0].errors[0].message);
return;
}
}
setValidationError(error.message || t("validationFailed"));
return;
}
}
const keys = Object.keys(parsed.mcpServers);
if (keys.length === 0) {
setValidationError(t("missingServerName"));
}
} catch {
setValidationError(t("invalidJson"));
}
}, [t]);
const handleSave = useCallback(async () => {
setProcessing(true);
try {
const success = await onSave(config);
if (success) {
onOpenChange(false);
}
} catch (error) {
console.error('Failed to save server configuration:', error);
} finally {
setProcessing(false);
}
}, [config, onSave, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("editServer")}</DialogTitle>
<DialogDescription>
{t("editServerDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Textarea
className="h-[360px] font-mono text-sm"
value={config}
onChange={(e) => handleChange(e.target.value)}
/>
{validationError && (
<div className="text-sm text-red-500 mt-2">
{validationError}
</div>
)}
</div>
<DialogFooter>
<div className="flex w-full justify-between items-center">
<div className="text-sm text-muted-foreground">
{t("editServerNote")}
</div>
<div className="space-x-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
{commonT("cancel")}
</Button>
<Button
onClick={handleSave}
disabled={processing || !!validationError}
>
{processing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{commonT("save")}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,17 +2,19 @@
// SPDX-License-Identifier: MIT
import { motion } from "framer-motion";
import { Blocks, PencilRuler, Trash } from "lucide-react";
import { Blocks, Edit2, PencilRuler, RefreshCw, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Tooltip } from "~/components/deer-flow/tooltip";
import { Button } from "~/components/ui/button";
import { Switch } from "~/components/ui/switch";
import { queryMCPServerMetadata } from "~/core/api";
import type { MCPServerMetadata } from "~/core/mcp";
import { cn } from "~/lib/utils";
import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
import { EditMCPServerDialog } from "../dialogs/edit-mcp-server-dialog";
import type { Tab } from "./types";
@@ -22,6 +24,7 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
settings.mcp.servers,
);
const [newlyAdded, setNewlyAdded] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServerMetadata | null>(null);
const handleAddServers = useCallback(
(servers: MCPServerMetadata[]) => {
const merged = mergeServers(settings.mcp.servers, servers);
@@ -50,16 +53,134 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
},
[onChange, settings],
);
const handleEditServer = useCallback(async (config: string) => {
if (!editingServer) return false;
try {
const parsedConfig = JSON.parse(config) as { mcpServers?: Record<string, MCPServerMetadata> };
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
console.error('Invalid configuration format: mcpServers not found');
return false;
}
const serverEntries = Object.entries(parsedConfig.mcpServers);
if (serverEntries.length === 0) {
console.error('No server configuration found in mcpServers');
return false;
}
const firstEntry = serverEntries[0];
if (!firstEntry) {
console.error('Failed to get server configuration');
return false;
}
const [serverName, serverConfig] = firstEntry;
// Update the server configuration
const updatedServers = settings.mcp.servers.map(server =>
server.name === editingServer.name
? {
...server,
...serverConfig,
name: serverName, // Allow renaming the server
updatedAt: Date.now(),
}
: server
);
setServers(updatedServers);
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
return true;
} catch (error) {
console.error('Failed to update server configuration:', error);
return false;
}
}, [editingServer, onChange, settings]);
const handleRefreshServers = useCallback(async (serverName?: string) => {
try {
// Create a new array with the updated server
const updatedServers = await Promise.all(
settings.mcp.servers.map(async (server) => {
// Skip if this is not the server we want to refresh
if (serverName && server.name !== serverName) {
return server;
}
// Skip disabled servers unless explicitly requested
if (!server.enabled && server.name !== serverName) {
return server;
}
try {
// Get the latest metadata
const metadata = await queryMCPServerMetadata(server);
// Create a new server object with preserved properties
return {
...server, // Keep all existing properties
...metadata, // Apply metadata updates
name: server.name, // Ensure name is preserved
enabled: server.enabled, // Preserve the enabled state
createdAt: server.createdAt, // Keep the original creation time
updatedAt: Date.now(), // Update the last updated time
};
} catch (error) {
console.error(`Failed to refresh server ${server.name}:`, error);
// Return the original server if refresh fails
return server;
}
})
);
// Update the servers list
setServers(updatedServers);
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
} catch (error) {
console.error('Failed to refresh MCP servers:', error);
}
}, [onChange, settings]);
const handleToggleServer = useCallback(
(name: string, enabled: boolean) => {
async (name: string, enabled: boolean) => {
const merged = settings.mcp.servers.map((server) =>
server.name === name ? { ...server, enabled } : server,
);
setServers(merged);
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
// Refresh server metadata when enabling a server
if (enabled) {
try {
const server = merged.find(s => s.name === name);
if (server) {
const metadata = await queryMCPServerMetadata(server);
const updatedServers = merged.map(s =>
s.name === name
? {
...s,
...metadata,
name: s.name,
enabled: true,
createdAt: s.createdAt,
updatedAt: Date.now(),
}
: s
);
setServers(updatedServers);
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
}
} catch (error) {
console.error(`Failed to refresh server ${name}:`, error);
}
}
},
[onChange, settings],
);
const animationProps = {
initial: { backgroundColor: "gray" },
animate: { backgroundColor: "transparent" },
@@ -107,20 +228,50 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
id="airplane-mode"
checked={server.enabled}
onCheckedChange={(checked) => {
handleToggleServer(server.name, checked);
void handleToggleServer(server.name, checked);
}}
/>
</div>
</Tooltip>
</div>
<div className="absolute top-1 right-12 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="absolute top-1 right-12 flex gap-1 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip title={t("editServer")}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
setEditingServer(server);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
</Tooltip>
<Tooltip title={t("refreshServer")}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
void handleRefreshServers(server.name);
}}
>
<RefreshCw className="h-4 w-4" />
</Button>
</Tooltip>
<Tooltip title={t("deleteServer")}>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteServer(server.name)}
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleDeleteServer(server.name);
}}
>
<Trash />
<Trash className="h-4 w-4" />
</Button>
</Tooltip>
</div>
@@ -175,6 +326,20 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
})}
</ul>
</main>
{editingServer && (
<EditMCPServerDialog
server={editingServer}
open={!!editingServer}
onOpenChange={(open) => !open && setEditingServer(null)}
onSave={async (config) => {
const success = await handleEditServer(config);
if (success) {
setEditingServer(null);
}
return success;
}}
/>
)}
</div>
);
};