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 (
-
+
- + Running Python code
-
- +
+ {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) => ( -
  • setActiveTabId(tab.id)} - > - - {tab.label} -
  • - ))} -
    -
-
-
- {SETTINGS_TABS.map((tab) => ( - - - - ))} -
-
-
-
- - - - -
-
- ); -} - -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 ( -
- - ( - - Max plan iterations - - - field.onChange(parseInt(event.target.value)) - } - /> - - - Set to 1 for single-step planning. Set to 2 to enable - re-planning. - - - - )} - /> - ( - - Max steps of a research plan - - - field.onChange(parseInt(event.target.value)) - } - /> - - - By default, each research plan has 3 steps. - - - - )} - /> - - - ); -}; -GeneralTab.displayName = "GeneralTab"; -GeneralTab.icon = Settings; - -const MCPTab: Tab = () => { - return ( -
-

Coming soon...

-
- ); -}; -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 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. +
+ +
+