diff --git a/docs/mcp_integrations.md b/docs/mcp_integrations.md
index 5148108..2ac7a5e 100644
--- a/docs/mcp_integrations.md
+++ b/docs/mcp_integrations.md
@@ -1,5 +1,21 @@
# MCP Integrations
+## Example of MCP Server Configuration
+
+```json
+{
+ "mcpServers": {
+ "mcp-github-trending": {
+ "transport": "stdio",
+ "command": "uvx",
+ "args": [
+ "mcp-github-trending"
+ ]
+ }
+ }
+}
+```
+
## APIs
### Get Information of MCP Server
diff --git a/web/src/app/_components/input-box.tsx b/web/src/app/_components/input-box.tsx
index b3b8197..f8a6b12 100644
--- a/web/src/app/_components/input-box.tsx
+++ b/web/src/app/_components/input-box.tsx
@@ -30,7 +30,7 @@ export function InputBox({
size?: "large" | "normal";
responding?: boolean;
feedback?: { option: Option } | null;
- onSend?: (message: string, feedback: { option: Option } | null) => void;
+ onSend?: (message: string, options?: { interruptFeedback?: string }) => void;
onCancel?: () => void;
onRemoveFeedback?: () => void;
}) {
@@ -63,7 +63,9 @@ export function InputBox({
return;
}
if (onSend) {
- onSend(message, feedback ?? null);
+ onSend(message, {
+ interruptFeedback: feedback?.option.value,
+ });
setMessage("");
onRemoveFeedback?.();
}
diff --git a/web/src/app/_components/message-list-view.tsx b/web/src/app/_components/message-list-view.tsx
index 79344e0..9eae019 100644
--- a/web/src/app/_components/message-list-view.tsx
+++ b/web/src/app/_components/message-list-view.tsx
@@ -17,7 +17,6 @@ import {
import type { Message, Option } from "~/core/messages";
import {
openResearch,
- sendMessage,
useMessage,
useResearchTitle,
useStore,
@@ -35,9 +34,14 @@ import { Tooltip } from "./tooltip";
export function MessageListView({
className,
onFeedback,
+ onSendMessage,
}: {
className?: string;
onFeedback?: (feedback: { option: Option }) => void;
+ onSendMessage?: (
+ message: string,
+ options?: { interruptFeedback?: string },
+ ) => void;
}) {
const messageIds = useStore((state) => state.messageIds);
const interruptMessage = useStore((state) => {
@@ -81,6 +85,7 @@ export function MessageListView({
waitForFeedback={waitingForFeedbackMessageId === messageId}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
+ onSendMessage={onSendMessage}
/>
))}
@@ -96,14 +101,19 @@ function MessageListItem({
className,
messageId,
waitForFeedback,
- onFeedback,
interruptMessage,
+ onFeedback,
+ onSendMessage,
}: {
className?: string;
messageId: string;
waitForFeedback?: boolean;
onFeedback?: (feedback: { option: Option }) => void;
interruptMessage?: Message | null;
+ onSendMessage?: (
+ message: string,
+ options?: { interruptFeedback?: string },
+ ) => void;
}) {
const message = useMessage(messageId);
const startOfResearch = useStore((state) =>
@@ -126,6 +136,7 @@ function MessageListItem({
waitForFeedback={waitForFeedback}
interruptMessage={interruptMessage}
onFeedback={onFeedback}
+ onSendMessage={onSendMessage}
/>
);
@@ -269,11 +280,16 @@ function PlanCard({
interruptMessage,
onFeedback,
waitForFeedback,
+ onSendMessage,
}: {
className?: string;
message: Message;
interruptMessage?: Message | null;
onFeedback?: (feedback: { option: Option }) => void;
+ onSendMessage?: (
+ message: string,
+ options?: { interruptFeedback?: string },
+ ) => void;
waitForFeedback?: boolean;
}) {
const plan = useMemo<{
@@ -284,13 +300,15 @@ function PlanCard({
return parseJSON(message.content ?? "", {});
}, [message.content]);
const handleAccept = useCallback(async () => {
- await sendMessage(
- `${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
- {
- interruptFeedback: "accepted",
- },
- );
- }, []);
+ if (onSendMessage) {
+ onSendMessage(
+ `${GREETINGS[Math.floor(Math.random() * GREETINGS.length)]}! ${Math.random() > 0.5 ? "Let's get started." : "Let's start."}`,
+ {
+ interruptFeedback: "accepted",
+ },
+ );
+ }
+ }, [onSendMessage]);
return (
diff --git a/web/src/app/_components/messages-block.tsx b/web/src/app/_components/messages-block.tsx
index 367d640..5706647 100644
--- a/web/src/app/_components/messages-block.tsx
+++ b/web/src/app/_components/messages-block.tsx
@@ -17,16 +17,15 @@ export function MessagesBlock({ className }: { className?: string }) {
const abortControllerRef = useRef(null);
const [feedback, setFeedback] = useState<{ option: Option } | null>(null);
const handleSend = useCallback(
- async (message: string) => {
+ async (message: string, options?: { interruptFeedback?: string }) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
await sendMessage(
message,
{
- maxPlanIterations: 1,
- maxStepNum: 3,
- interruptFeedback: feedback?.option.value,
+ interruptFeedback:
+ options?.interruptFeedback ?? feedback?.option.value,
},
{
abortSignal: abortController.signal,
@@ -37,6 +36,7 @@ export function MessagesBlock({ className }: { className?: string }) {
[feedback],
);
const handleCancel = useCallback(() => {
+ console.info("cancel");
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
@@ -51,7 +51,11 @@ export function MessagesBlock({ className }: { className?: string }) {
}, [setFeedback]);
return (
-
+
{!responding && messageCount === 0 && (
;
} else if (toolCall.name === "python_repl_tool") {
return
;
+ } else {
+ return
;
}
}
}
@@ -142,7 +152,7 @@ function WebSearchToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
className="flex items-center"
animated={searchResults === undefined}
>
-
+
Searching for
{(toolCall.args as { query: string }).query}
@@ -229,12 +239,12 @@ function CrawlToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
const title = useMemo(() => __pageCache.get(url), [url]);
return (
-
+
-
+
Reading
@@ -264,15 +274,22 @@ function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
}, [toolCall.args]);
return (
-
+
-
-
+
+
{code}
@@ -280,3 +297,43 @@ function PythonToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
);
}
+
+function MCPToolCall({ toolCall }: { toolCall: ToolCallRuntime }) {
+ const tool = useMemo(() => findMCPTool(toolCall.name), [toolCall.name]);
+ return (
+
+
+
+
+
+
+
+
+
+ Running {toolCall.name ? toolCall.name + "()" : "MCP tool"}
+
+
+
+
+
+ {toolCall.result && (
+
+
+ {toolCall.result}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/_components/tooltip.tsx b/web/src/app/_components/tooltip.tsx
index de2bd06..bc5b45c 100644
--- a/web/src/app/_components/tooltip.tsx
+++ b/web/src/app/_components/tooltip.tsx
@@ -17,7 +17,7 @@ export function Tooltip({
title?: React.ReactNode;
}) {
return (
-
+
{children}
{title}
diff --git a/web/src/app/_dialogs/settings-dialog.tsx b/web/src/app/_dialogs/settings-dialog.tsx
deleted file mode 100644
index c663549..0000000
--- a/web/src/app/_dialogs/settings-dialog.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import { zodResolver } from "@hookform/resolvers/zod";
-import { BadgeInfo, Blocks, Settings, type LucideIcon } from "lucide-react";
-import {
- type FunctionComponent,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
-
-import { Button } from "~/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "~/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "~/components/ui/form";
-import { Input } from "~/components/ui/input";
-import { Tabs, TabsContent } from "~/components/ui/tabs";
-import {
- type SettingsState,
- changeSettings,
- saveSettings,
- useSettingsStore,
-} from "~/core/store";
-import { cn } from "~/lib/utils";
-
-import { Markdown } from "../_components/markdown";
-import { Tooltip } from "../_components/tooltip";
-
-import about from "./about.md";
-
-export function SettingsDialog() {
- const [activeTabId, setActiveTabId] = useState(SETTINGS_TABS[0]!.id);
- const [open, setOpen] = useState(false);
- const [settings, setSettings] = useState(useSettingsStore.getState());
- const changes = useRef>({});
-
- const handleTabChange = useCallback((newChanges: Partial) => {
- changes.current = {
- ...changes.current,
- ...newChanges,
- };
- }, []);
-
- const handleSave = useCallback(() => {
- if (Object.keys(changes.current).length > 0) {
- const newSettings: SettingsState = {
- ...settings,
- ...changes.current,
- };
- setSettings(newSettings);
- changes.current = {};
- changeSettings(newSettings);
- saveSettings();
- }
- setOpen(false);
- }, [settings, changes]);
-
- return (
-
-
-
-
-
-
-
-
-
-
- DeerFlow Settings
-
- Manage your DeerFlow settings here.
-
-
-
-
-
-
-
- {SETTINGS_TABS.map((tab) => (
-
-
-
- ))}
-
-
-
-
-
- setOpen(false)}>
- Cancel
-
-
- Save
-
-
-
-
- );
-}
-
-type Tab = FunctionComponent<{
- settings: SettingsState;
- onChange: (changes: Partial) => void;
-}> & {
- displayName?: string;
- icon?: LucideIcon;
-};
-
-const generalFormSchema = z.object({
- maxPlanIterations: z.number().min(1, {
- message: "Max plan iterations must be at least 1.",
- }),
- maxStepNum: z.number().min(1, {
- message: "Max step number must be at least 1.",
- }),
-});
-
-const GeneralTab: Tab = ({
- settings,
- onChange,
-}: {
- settings: SettingsState;
- onChange: (changes: Partial) => void;
-}) => {
- const generalSettings = useMemo(() => settings.general, [settings]);
- const form = useForm>({
- resolver: zodResolver(generalFormSchema, undefined, undefined),
- defaultValues: generalSettings,
- });
-
- const currentSettings = form.watch();
- useEffect(() => {
- let hasChanges = false;
- for (const key in currentSettings) {
- if (
- currentSettings[key as keyof typeof currentSettings] !==
- settings.general[key as keyof SettingsState["general"]]
- ) {
- hasChanges = true;
- break;
- }
- }
- if (hasChanges) {
- onChange({ general: currentSettings });
- }
- }, [currentSettings, onChange, settings]);
-
- return (
-
-
- );
-};
-GeneralTab.displayName = "GeneralTab";
-GeneralTab.icon = Settings;
-
-const MCPTab: Tab = () => {
- return (
-
- );
-};
-MCPTab.icon = Blocks;
-
-const AboutTab: Tab = () => {
- return {about} ;
-};
-AboutTab.icon = BadgeInfo;
-
-const SETTINGS_TABS = [GeneralTab, MCPTab, AboutTab].map((tab) => {
- const name = tab.name ?? tab.displayName;
- return {
- ...tab,
- id: name.replace(/Tab$/, "").toLocaleLowerCase(),
- label: name.replace(/Tab$/, ""),
- icon: (tab.icon ?? ) as LucideIcon,
- component: tab,
- };
-});
diff --git a/web/src/app/_settings/dialogs/add-mcp-server-dialog.tsx b/web/src/app/_settings/dialogs/add-mcp-server-dialog.tsx
new file mode 100644
index 0000000..98c0527
--- /dev/null
+++ b/web/src/app/_settings/dialogs/add-mcp-server-dialog.tsx
@@ -0,0 +1,175 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { Loader2 } from "lucide-react";
+import { useCallback, useState } from "react";
+
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { Textarea } from "~/components/ui/textarea";
+import { queryMCPServerMetadata } from "~/core/api";
+import {
+ MCPConfigSchema,
+ type MCPServerMetadata,
+ type SimpleMCPServerMetadata,
+ type SimpleSSEMCPServerMetadata,
+ type SimpleStdioMCPServerMetadata,
+} from "~/core/mcp";
+
+export function AddMCPServerDialog({
+ onAdd,
+}: {
+ onAdd?: (servers: MCPServerMetadata[]) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const [input, setInput] = useState("");
+ const [validationError, setValidationError] = useState("");
+ const [error, setError] = useState(null);
+ const [processing, setProcessing] = useState(false);
+ const handleChange = useCallback((value: string) => {
+ setInput(value);
+ if (!value.trim()) {
+ setValidationError(null);
+ return;
+ }
+ setValidationError(null);
+ try {
+ const parsed = JSON.parse(value);
+ if (!("mcpServers" in parsed)) {
+ setValidationError("Missing `mcpServers` in JSON");
+ return;
+ }
+ } catch {
+ setValidationError("Invalid JSON");
+ return;
+ }
+ const result = MCPConfigSchema.safeParse(JSON.parse(value));
+ 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;
+ }
+ }
+ }
+ const errorMessage =
+ result.error.errors[0]?.message ?? "Validation failed";
+ setValidationError(errorMessage);
+ return;
+ }
+
+ const keys = Object.keys(result.data.mcpServers);
+ if (keys.length === 0) {
+ setValidationError("Missing server name in `mcpServers`");
+ return;
+ }
+ }, []);
+ const handleAdd = useCallback(async () => {
+ const config = MCPConfigSchema.parse(JSON.parse(input));
+ setInput(JSON.stringify(config, null, 2));
+ const addingServers: SimpleMCPServerMetadata[] = [];
+ for (const [key, server] of Object.entries(config.mcpServers)) {
+ if ("command" in server) {
+ const metadata: SimpleStdioMCPServerMetadata = {
+ transport: "stdio",
+ name: key,
+ command: server.command,
+ args: server.args,
+ env: server.env,
+ };
+ addingServers.push(metadata);
+ } else if ("url" in server) {
+ const metadata: SimpleSSEMCPServerMetadata = {
+ transport: "sse",
+ name: key,
+ url: server.url,
+ };
+ addingServers.push(metadata);
+ }
+ }
+ setProcessing(true);
+
+ const results: MCPServerMetadata[] = [];
+ let processingServer: string | null = null;
+ try {
+ setError(null);
+ for (const server of addingServers) {
+ processingServer = server.name;
+ const metadata = await queryMCPServerMetadata(server);
+ results.push({ ...metadata, name: server.name, enabled: true });
+ }
+ if (results.length > 0) {
+ onAdd?.(results);
+ }
+ setInput("");
+ setOpen(false);
+ } catch (e) {
+ console.error(e);
+ setError(`Failed to add server: ${processingServer}`);
+ } finally {
+ setProcessing(false);
+ }
+ }, [input, onAdd]);
+
+ return (
+
+
+ Add New Server
+
+
+
+ Add New MCP Server
+
+
+ DeerFlow uses the standard JSON MCP config to create a new server.
+
+ Paste your config below and click "Add" to create a new
+ server.
+
+
+
+
+
+
+
+
+ {validationError ?? error}
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ {processing && }
+ Add
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/_settings/dialogs/settings-dialog.tsx b/web/src/app/_settings/dialogs/settings-dialog.tsx
new file mode 100644
index 0000000..c0dc92e
--- /dev/null
+++ b/web/src/app/_settings/dialogs/settings-dialog.tsx
@@ -0,0 +1,163 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { Settings } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { Tabs, TabsContent } from "~/components/ui/tabs";
+import {
+ type SettingsState,
+ changeSettings,
+ saveSettings,
+ useSettingsStore,
+} from "~/core/store";
+import { cn } from "~/lib/utils";
+
+import { Tooltip } from "../../_components/tooltip";
+import { SETTINGS_TABS } from "../tabs";
+
+export function SettingsDialog() {
+ const [activeTabId, setActiveTabId] = useState(SETTINGS_TABS[0]!.id);
+ const [open, setOpen] = useState(false);
+ const [settings, setSettings] = useState(useSettingsStore.getState());
+ const [changes, setChanges] = useState>({});
+
+ const handleTabChange = useCallback(
+ (newChanges: Partial) => {
+ setTimeout(() => {
+ if (open) {
+ setChanges((prev) => ({
+ ...prev,
+ ...newChanges,
+ }));
+ }
+ }, 0);
+ },
+ [open],
+ );
+
+ const handleSave = useCallback(() => {
+ if (Object.keys(changes).length > 0) {
+ const newSettings: SettingsState = {
+ ...settings,
+ ...changes,
+ };
+ setSettings(newSettings);
+ setChanges({});
+ changeSettings(newSettings);
+ saveSettings();
+ }
+ setOpen(false);
+ }, [settings, changes]);
+
+ const handleOpen = useCallback(() => {
+ setSettings(useSettingsStore.getState());
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setChanges({});
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ handleOpen();
+ } else {
+ handleClose();
+ }
+ }, [open, handleOpen, handleClose]);
+
+ const mergedSettings = useMemo(() => {
+ return {
+ ...settings,
+ ...changes,
+ };
+ }, [settings, changes]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ DeerFlow Settings
+
+ Manage your DeerFlow settings here.
+
+
+
+
+
+
+ {SETTINGS_TABS.map((tab) => (
+
setActiveTabId(tab.id)}
+ >
+
+ {tab.label}
+ {tab.badge && (
+
+ {tab.badge}
+
+ )}
+
+ ))}
+
+
+
+
+ {SETTINGS_TABS.map((tab) => (
+
+
+
+ ))}
+
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+}
diff --git a/web/src/app/_settings/tabs/about-tab.tsx b/web/src/app/_settings/tabs/about-tab.tsx
new file mode 100644
index 0000000..4be2b9f
--- /dev/null
+++ b/web/src/app/_settings/tabs/about-tab.tsx
@@ -0,0 +1,14 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { BadgeInfo } from "lucide-react";
+
+import { Markdown } from "~/app/_components/markdown";
+
+import about from "./about.md";
+import type { Tab } from "./types";
+
+export const AboutTab: Tab = () => {
+ return {about} ;
+};
+AboutTab.icon = BadgeInfo;
diff --git a/web/src/app/_dialogs/about.md b/web/src/app/_settings/tabs/about.md
similarity index 100%
rename from web/src/app/_dialogs/about.md
rename to web/src/app/_settings/tabs/about.md
diff --git a/web/src/app/_settings/tabs/general-tab.tsx b/web/src/app/_settings/tabs/general-tab.tsx
new file mode 100644
index 0000000..1c02876
--- /dev/null
+++ b/web/src/app/_settings/tabs/general-tab.tsx
@@ -0,0 +1,127 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Settings } from "lucide-react";
+import { useEffect, useMemo } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "~/components/ui/form";
+import { Input } from "~/components/ui/input";
+import type { SettingsState } from "~/core/store";
+
+import type { Tab } from "./types";
+
+const generalFormSchema = z.object({
+ maxPlanIterations: z.number().min(1, {
+ message: "Max plan iterations must be at least 1.",
+ }),
+ maxStepNum: z.number().min(1, {
+ message: "Max step number must be at least 1.",
+ }),
+});
+
+export const GeneralTab: Tab = ({
+ settings,
+ onChange,
+}: {
+ settings: SettingsState;
+ onChange: (changes: Partial) => void;
+}) => {
+ const generalSettings = useMemo(() => settings.general, [settings]);
+ const form = useForm>({
+ resolver: zodResolver(generalFormSchema, undefined, undefined),
+ values: generalSettings,
+ });
+
+ const currentSettings = form.watch();
+ useEffect(() => {
+ let hasChanges = false;
+ for (const key in currentSettings) {
+ if (
+ currentSettings[key as keyof typeof currentSettings] !==
+ settings.general[key as keyof SettingsState["general"]]
+ ) {
+ hasChanges = true;
+ break;
+ }
+ }
+ if (hasChanges) {
+ onChange({ general: currentSettings });
+ }
+ }, [currentSettings, onChange, settings]);
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+GeneralTab.displayName = "";
+GeneralTab.icon = Settings;
diff --git a/web/src/app/_settings/tabs/index.tsx b/web/src/app/_settings/tabs/index.tsx
new file mode 100644
index 0000000..43ceadd
--- /dev/null
+++ b/web/src/app/_settings/tabs/index.tsx
@@ -0,0 +1,19 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { Settings, type LucideIcon } from "lucide-react";
+
+import { AboutTab } from "./about-tab";
+import { GeneralTab } from "./general-tab";
+import { MCPTab } from "./mcp-tab";
+
+export const SETTINGS_TABS = [GeneralTab, MCPTab, AboutTab].map((tab) => {
+ const name = tab.name ?? tab.displayName;
+ return {
+ ...tab,
+ id: name.replace(/Tab$/, "").toLocaleLowerCase(),
+ label: name.replace(/Tab$/, ""),
+ icon: (tab.icon ?? ) as LucideIcon,
+ component: tab,
+ };
+});
diff --git a/web/src/app/_settings/tabs/mcp-tab.tsx b/web/src/app/_settings/tabs/mcp-tab.tsx
new file mode 100644
index 0000000..1b177c8
--- /dev/null
+++ b/web/src/app/_settings/tabs/mcp-tab.tsx
@@ -0,0 +1,152 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { motion } from "framer-motion";
+import { Blocks, PencilRuler, Trash } from "lucide-react";
+import { useCallback, useState } from "react";
+
+import { Tooltip } from "~/app/_components/tooltip";
+import { Button } from "~/components/ui/button";
+import type { MCPServerMetadata } from "~/core/mcp";
+
+import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
+
+import type { Tab } from "./types";
+
+export const MCPTab: Tab = ({ settings, onChange }) => {
+ const [servers, setServers] = useState(
+ settings.mcp.servers,
+ );
+ const [newlyAdded, setNewlyAdded] = useState(false);
+ const handleAddServers = useCallback(
+ (servers: MCPServerMetadata[]) => {
+ const merged = mergeServers(settings.mcp.servers, servers);
+ setServers(merged);
+ onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
+ setNewlyAdded(true);
+ setTimeout(() => {
+ setNewlyAdded(false);
+ }, 1000);
+ setTimeout(() => {
+ document.getElementById("settings-content-scrollable")?.scrollTo({
+ top: 0,
+ behavior: "smooth",
+ });
+ }, 100);
+ },
+ [onChange, settings],
+ );
+ const handleDeleteServer = useCallback(
+ (name: string) => {
+ const merged = settings.mcp.servers.filter(
+ (server) => server.name !== name,
+ );
+ setServers(merged);
+ onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
+ },
+ [onChange, settings],
+ );
+ const animationProps = {
+ initial: { backgroundColor: "gray" },
+ animate: { backgroundColor: "transparent" },
+ transition: { duration: 1 },
+ style: {
+ transition: "background-color 1s ease-out",
+ },
+ };
+ return (
+
+ );
+};
+MCPTab.icon = Blocks;
+MCPTab.badge = "Beta";
+
+function mergeServers(
+ existing: MCPServerMetadata[],
+ added: MCPServerMetadata[],
+): MCPServerMetadata[] {
+ const serverMap = new Map(existing.map((server) => [server.name, server]));
+
+ for (const addedServer of added) {
+ addedServer.createdAt = Date.now();
+ addedServer.updatedAt = Date.now();
+ serverMap.set(addedServer.name, addedServer);
+ }
+
+ const result = Array.from(serverMap.values());
+ result.sort((a, b) => b.createdAt - a.createdAt);
+ return result;
+}
diff --git a/web/src/app/_settings/tabs/types.ts b/web/src/app/_settings/tabs/types.ts
new file mode 100644
index 0000000..83731aa
--- /dev/null
+++ b/web/src/app/_settings/tabs/types.ts
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import type { LucideIcon } from "lucide-react";
+import type { FunctionComponent } from "react";
+
+import type { SettingsState } from "~/core/store";
+
+export type Tab = FunctionComponent<{
+ settings: SettingsState;
+ onChange: (changes: Partial) => void;
+}> & {
+ displayName?: string;
+ icon?: LucideIcon;
+ badge?: string;
+};
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index 4c57e9f..fdee97a 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -16,7 +16,7 @@ import { MessagesBlock } from "./_components/messages-block";
import { ResearchBlock } from "./_components/research-block";
import { ThemeToggle } from "./_components/theme-toggle";
import { Tooltip } from "./_components/tooltip";
-import { SettingsDialog } from "./_dialogs/settings-dialog";
+import { SettingsDialog } from "./_settings/dialogs/settings-dialog";
export default function HomePage() {
const openResearchId = useStore((state) => state.openResearchId);
diff --git a/web/src/components/ui/accordion.tsx b/web/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..1a4eaca
--- /dev/null
+++ b/web/src/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDownIcon } from "lucide-react"
+
+import { cn } from "~/lib/utils"
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
new file mode 100644
index 0000000..fc40406
--- /dev/null
+++ b/web/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "~/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..31fbcb6
--- /dev/null
+++ b/web/src/components/ui/checkbox.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "~/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/web/src/components/ui/collapsible.tsx b/web/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..ae9fad0
--- /dev/null
+++ b/web/src/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx
new file mode 100644
index 0000000..1d0e3ff
--- /dev/null
+++ b/web/src/components/ui/select.tsx
@@ -0,0 +1,185 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "~/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/web/src/core/api/chat.ts b/web/src/core/api/chat.ts
index c39100b..787b634 100644
--- a/web/src/core/api/chat.ts
+++ b/web/src/core/api/chat.ts
@@ -1,11 +1,11 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
-import { env } from "~/env";
-
+import type { MCPServerMetadata } from "../mcp";
import { fetchStream } from "../sse";
import { sleep } from "../utils";
+import { resolveServiceURL } from "./resolve-service-url";
import type { ChatEvent } from "./types";
export function chatStream(
@@ -15,23 +15,29 @@ export function chatStream(
max_plan_iterations: number;
max_step_num: number;
interrupt_feedback?: string;
+ mcp_settings?: {
+ servers: Record<
+ string,
+ MCPServerMetadata & {
+ enabled_tools: string[];
+ add_to_agents: string[];
+ }
+ >;
+ };
},
options: { abortSignal?: AbortSignal } = {},
) {
if (location.search.includes("mock")) {
return chatStreamMock(userMessage, params, options);
}
- return fetchStream(
- (env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") + "/chat/stream",
- {
- body: JSON.stringify({
- messages: [{ role: "user", content: userMessage }],
- auto_accepted_plan: false,
- ...params,
- }),
- signal: options.abortSignal,
- },
- );
+ return fetchStream(resolveServiceURL("chat/stream"), {
+ body: JSON.stringify({
+ messages: [{ role: "user", content: userMessage }],
+ auto_accepted_plan: false,
+ ...params,
+ }),
+ signal: options.abortSignal,
+ });
}
async function* chatStreamMock(
diff --git a/web/src/core/api/index.ts b/web/src/core/api/index.ts
index d038fa1..79742fa 100644
--- a/web/src/core/api/index.ts
+++ b/web/src/core/api/index.ts
@@ -2,4 +2,6 @@
// SPDX-License-Identifier: MIT
export * from "./chat";
+export * from "./mcp";
+export * from "./podcast";
export * from "./types";
diff --git a/web/src/core/api/mcp.ts b/web/src/core/api/mcp.ts
new file mode 100644
index 0000000..a50cf13
--- /dev/null
+++ b/web/src/core/api/mcp.ts
@@ -0,0 +1,20 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import type { SimpleMCPServerMetadata } from "../mcp";
+
+import { resolveServiceURL } from "./resolve-service-url";
+
+export async function queryMCPServerMetadata(config: SimpleMCPServerMetadata) {
+ const response = await fetch(resolveServiceURL("mcp/server/metadata"), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(config),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+}
diff --git a/web/src/core/api/podcast.ts b/web/src/core/api/podcast.ts
index c323576..a8f2fc3 100644
--- a/web/src/core/api/podcast.ts
+++ b/web/src/core/api/podcast.ts
@@ -1,20 +1,16 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
-import { env } from "~/env";
+import { resolveServiceURL } from "./resolve-service-url";
export async function generatePodcast(content: string) {
- const response = await fetch(
- (env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api") +
- "/podcast/generate",
- {
- method: "post",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ content }),
+ const response = await fetch(resolveServiceURL("podcast/generate"), {
+ method: "post",
+ headers: {
+ "Content-Type": "application/json",
},
- );
+ body: JSON.stringify({ content }),
+ });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
diff --git a/web/src/core/api/resolve-service-url.ts b/web/src/core/api/resolve-service-url.ts
new file mode 100644
index 0000000..a87b777
--- /dev/null
+++ b/web/src/core/api/resolve-service-url.ts
@@ -0,0 +1,12 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { env } from "~/env";
+
+export function resolveServiceURL(path: string) {
+ let BASE_URL = env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/";
+ if (!BASE_URL.endsWith("/")) {
+ BASE_URL += "/";
+ }
+ return new URL(path, BASE_URL).toString();
+}
diff --git a/web/src/core/mcp/index.ts b/web/src/core/mcp/index.ts
new file mode 100644
index 0000000..2a6f01f
--- /dev/null
+++ b/web/src/core/mcp/index.ts
@@ -0,0 +1,6 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+export * from "./schema";
+export * from "./types";
+export * from "./utils";
diff --git a/web/src/core/mcp/schema.ts b/web/src/core/mcp/schema.ts
new file mode 100644
index 0000000..0e4f879
--- /dev/null
+++ b/web/src/core/mcp/schema.ts
@@ -0,0 +1,57 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { z } from "zod";
+
+export const MCPConfigSchema = z.object({
+ mcpServers: z.record(
+ z.union(
+ [
+ z.object({
+ command: z.string({
+ message: "`command` must be a string",
+ }),
+ args: z
+ .array(z.string(), {
+ message: "`args` must be an array of strings",
+ })
+ .optional(),
+ env: z
+ .record(z.string(), {
+ message: "`env` must be an object of key-value pairs",
+ })
+ .optional(),
+ }),
+ z.object({
+ url: z
+ .string({
+ message:
+ "`url` must be a valid URL starting with http:// or https://",
+ })
+ .refine(
+ (value) => {
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+ },
+ {
+ message:
+ "`url` must be a valid URL starting with http:// or https://",
+ },
+ ),
+ env: z
+ .record(z.string(), {
+ message: "`env` must be an object of key-value pairs",
+ })
+ .optional(),
+ }),
+ ],
+ {
+ message: "Invalid server type",
+ },
+ ),
+ ),
+});
diff --git a/web/src/core/mcp/types.ts b/web/src/core/mcp/types.ts
new file mode 100644
index 0000000..854e21b
--- /dev/null
+++ b/web/src/core/mcp/types.ts
@@ -0,0 +1,43 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+export interface MCPToolMetadata {
+ name: string;
+ description: string;
+ inputSchema?: Record;
+}
+
+export interface GenericMCPServerMetadata {
+ name: string;
+ transport: T;
+ enabled: boolean;
+ env?: Record;
+ tools: MCPToolMetadata[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface StdioMCPServerMetadata
+ extends GenericMCPServerMetadata<"stdio"> {
+ transport: "stdio";
+ command: string;
+ args?: string[];
+}
+export type SimpleStdioMCPServerMetadata = Omit<
+ StdioMCPServerMetadata,
+ "enabled" | "tools" | "createdAt" | "updatedAt"
+>;
+
+export interface SSEMCPServerMetadata extends GenericMCPServerMetadata<"sse"> {
+ transport: "sse";
+ url: string;
+}
+export type SimpleSSEMCPServerMetadata = Omit<
+ SSEMCPServerMetadata,
+ "enabled" | "tools" | "createdAt" | "updatedAt"
+>;
+
+export type MCPServerMetadata = StdioMCPServerMetadata | SSEMCPServerMetadata;
+export type SimpleMCPServerMetadata =
+ | SimpleStdioMCPServerMetadata
+ | SimpleSSEMCPServerMetadata;
diff --git a/web/src/core/mcp/utils.ts b/web/src/core/mcp/utils.ts
new file mode 100644
index 0000000..6111f26
--- /dev/null
+++ b/web/src/core/mcp/utils.ts
@@ -0,0 +1,16 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
+import { useSettingsStore } from "../store";
+
+export function findMCPTool(name: string) {
+ const mcpServers = useSettingsStore.getState().mcp.servers;
+ for (const server of mcpServers) {
+ for (const tool of server.tools) {
+ if (tool.name === name) {
+ return tool;
+ }
+ }
+ }
+ return null;
+}
diff --git a/web/src/core/store/settings-store.ts b/web/src/core/store/settings-store.ts
index 7040923..30e0329 100644
--- a/web/src/core/store/settings-store.ts
+++ b/web/src/core/store/settings-store.ts
@@ -1,5 +1,10 @@
+// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+// SPDX-License-Identifier: MIT
+
import { create } from "zustand";
+import type { MCPServerMetadata } from "../mcp";
+
const SETTINGS_KEY = "deerflow.settings";
const DEFAULT_SETTINGS: SettingsState = {
@@ -7,6 +12,34 @@ const DEFAULT_SETTINGS: SettingsState = {
maxPlanIterations: 1,
maxStepNum: 3,
},
+ mcp: {
+ servers: [
+ {
+ enabled: true,
+ name: "Zapier",
+ transport: "sse",
+ url: "https://actions.zapier.com/mcp/sk-ak-OnJ4kVKzxcLjpvpLChkT7RCYuh/sse",
+ env: { API_KEY: "123" },
+ createdAt: new Date("2025-04-20").valueOf(),
+ updatedAt: new Date("2025-04-20").valueOf(),
+ tools: [
+ {
+ name: "youtube_get_report",
+ description:
+ "Creates a report on specified data from your owned and managed channels.",
+ },
+ {
+ name: "edit_actions",
+ description: "Edit your existing MCP provider actions",
+ },
+ {
+ name: "add_actions",
+ description: "Add new actions to your MCP provider",
+ },
+ ],
+ },
+ ],
+ },
};
export type SettingsState = {
@@ -14,6 +47,9 @@ export type SettingsState = {
maxPlanIterations: number;
maxStepNum: number;
};
+ mcp: {
+ servers: MCPServerMetadata[];
+ };
};
export const useSettingsStore = create(() => ({
diff --git a/web/src/core/store/store.ts b/web/src/core/store/store.ts
index 82b8716..deb80ca 100644
--- a/web/src/core/store/store.ts
+++ b/web/src/core/store/store.ts
@@ -4,13 +4,13 @@
import { nanoid } from "nanoid";
import { create } from "zustand";
-import { chatStream } from "../api";
-import { generatePodcast } from "../api/podcast";
+import { chatStream, generatePodcast } from "../api";
import type { Message } from "../messages";
import { mergeMessage } from "../messages";
import { parseJSON } from "../utils";
import { useSettingsStore } from "./settings-store";
+import type { MCPServerMetadata, SimpleMCPServerMetadata } from "../mcp";
const THREAD_ID = nanoid();
@@ -57,7 +57,52 @@ export async function sendMessage(
setResponding(true);
try {
- const generalSettings = useSettingsStore.getState().general;
+ const settings = useSettingsStore.getState();
+ const generalSettings = settings.general;
+ const mcpServers = settings.mcp.servers.filter((server) => server.enabled);
+ let mcpSettings:
+ | {
+ servers: Record<
+ string,
+ MCPServerMetadata & {
+ enabled_tools: string[];
+ add_to_agents: string[];
+ }
+ >;
+ }
+ | undefined = undefined;
+ if (mcpServers.length > 0) {
+ mcpSettings = {
+ servers: mcpServers.reduce((acc, cur) => {
+ const { transport, env } = cur;
+ let server: SimpleMCPServerMetadata;
+ if (transport === "stdio") {
+ server = {
+ name: cur.name,
+ transport,
+ env,
+ command: cur.command,
+ args: cur.args,
+ };
+ } else {
+ server = {
+ name: cur.name,
+ transport,
+ env,
+ url: cur.url,
+ };
+ }
+ return {
+ ...acc,
+ [cur.name]: {
+ ...server,
+ enabled_tools: cur.tools.map((tool) => tool.name),
+ add_to_agents: ["researcher"],
+ },
+ };
+ }, {}),
+ };
+ }
const stream = chatStream(
content,
{
@@ -65,6 +110,7 @@ export async function sendMessage(
max_plan_iterations: generalSettings.maxPlanIterations,
max_step_num: generalSettings.maxStepNum,
interrupt_feedback: interruptFeedback,
+ mcp_settings: mcpSettings,
},
options,
);