mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-08 16:24:45 +08:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,15 @@
|
||||
"learnMore": "了解更多关于 MCP 的信息。",
|
||||
"enableDisable": "启用/禁用服务器",
|
||||
"deleteServer": "删除服务器",
|
||||
"editServer": "编辑服务器",
|
||||
"refreshServer": "刷新服务器",
|
||||
"editServerDescription": "编辑 MCP 服务器配置",
|
||||
"editServerNote": "以 JSON 格式更新服务器配置",
|
||||
"disabled": "已禁用",
|
||||
"new": "新增"
|
||||
"new": "新增",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"validationFailed": "验证失败",
|
||||
"missingServerName": "缺少服务器名称"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于"
|
||||
|
||||
160
web/src/app/settings/dialogs/edit-mcp-server-dialog.tsx
Normal file
160
web/src/app/settings/dialogs/edit-mcp-server-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user