mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-22 13:44:46 +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.",
|
"learnMore": "learn more about MCP.",
|
||||||
"enableDisable": "Enable/disable server",
|
"enableDisable": "Enable/disable server",
|
||||||
"deleteServer": "Delete 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",
|
"disabled": "Disabled",
|
||||||
"new": "New"
|
"new": "New",
|
||||||
|
"invalidJson": "Invalid JSON format",
|
||||||
|
"validationFailed": "Validation failed",
|
||||||
|
"missingServerName": "Missing server name"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About"
|
"title": "About"
|
||||||
|
|||||||
@@ -52,8 +52,15 @@
|
|||||||
"learnMore": "了解更多关于 MCP 的信息。",
|
"learnMore": "了解更多关于 MCP 的信息。",
|
||||||
"enableDisable": "启用/禁用服务器",
|
"enableDisable": "启用/禁用服务器",
|
||||||
"deleteServer": "删除服务器",
|
"deleteServer": "删除服务器",
|
||||||
|
"editServer": "编辑服务器",
|
||||||
|
"refreshServer": "刷新服务器",
|
||||||
|
"editServerDescription": "编辑 MCP 服务器配置",
|
||||||
|
"editServerNote": "以 JSON 格式更新服务器配置",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"new": "新增"
|
"new": "新增",
|
||||||
|
"invalidJson": "无效的 JSON 格式",
|
||||||
|
"validationFailed": "验证失败",
|
||||||
|
"missingServerName": "缺少服务器名称"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "关于"
|
"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
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
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 { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
import { Tooltip } from "~/components/deer-flow/tooltip";
|
import { Tooltip } from "~/components/deer-flow/tooltip";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
|
import { queryMCPServerMetadata } from "~/core/api";
|
||||||
import type { MCPServerMetadata } from "~/core/mcp";
|
import type { MCPServerMetadata } from "~/core/mcp";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
|
import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
|
||||||
|
import { EditMCPServerDialog } from "../dialogs/edit-mcp-server-dialog";
|
||||||
|
|
||||||
import type { Tab } from "./types";
|
import type { Tab } from "./types";
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
|
|||||||
settings.mcp.servers,
|
settings.mcp.servers,
|
||||||
);
|
);
|
||||||
const [newlyAdded, setNewlyAdded] = useState(false);
|
const [newlyAdded, setNewlyAdded] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<MCPServerMetadata | null>(null);
|
||||||
const handleAddServers = useCallback(
|
const handleAddServers = useCallback(
|
||||||
(servers: MCPServerMetadata[]) => {
|
(servers: MCPServerMetadata[]) => {
|
||||||
const merged = mergeServers(settings.mcp.servers, servers);
|
const merged = mergeServers(settings.mcp.servers, servers);
|
||||||
@@ -50,16 +53,134 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
|
|||||||
},
|
},
|
||||||
[onChange, settings],
|
[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(
|
const handleToggleServer = useCallback(
|
||||||
(name: string, enabled: boolean) => {
|
async (name: string, enabled: boolean) => {
|
||||||
const merged = settings.mcp.servers.map((server) =>
|
const merged = settings.mcp.servers.map((server) =>
|
||||||
server.name === name ? { ...server, enabled } : server,
|
server.name === name ? { ...server, enabled } : server,
|
||||||
);
|
);
|
||||||
setServers(merged);
|
setServers(merged);
|
||||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: 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],
|
[onChange, settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
const animationProps = {
|
const animationProps = {
|
||||||
initial: { backgroundColor: "gray" },
|
initial: { backgroundColor: "gray" },
|
||||||
animate: { backgroundColor: "transparent" },
|
animate: { backgroundColor: "transparent" },
|
||||||
@@ -107,20 +228,50 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
|
|||||||
id="airplane-mode"
|
id="airplane-mode"
|
||||||
checked={server.enabled}
|
checked={server.enabled}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
handleToggleServer(server.name, checked);
|
void handleToggleServer(server.name, checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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")}>
|
<Tooltip title={t("deleteServer")}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +326,20 @@ export const MCPTab: Tab = ({ settings, onChange }) => {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user