### What problem does this PR solve? Feat: Synchronize MCP data to agent #3221 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.20.0
| @@ -5,6 +5,7 @@ import { | |||
| IMcpServer, | |||
| IMcpServerListResponse, | |||
| IMCPTool, | |||
| IMCPToolRecord, | |||
| } from '@/interfaces/database/mcp'; | |||
| import { | |||
| IImportMcpServersRequestBody, | |||
| @@ -16,6 +17,7 @@ import mcpServerService, { | |||
| } from '@/services/mcp-server-service'; | |||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | |||
| import { useDebounce } from 'ahooks'; | |||
| import { useState } from 'react'; | |||
| import { | |||
| useGetPaginationWithRouter, | |||
| useHandleSearchChange, | |||
| @@ -201,17 +203,19 @@ export const useExportMcpServer = () => { | |||
| }; | |||
| export const useListMcpServerTools = () => { | |||
| const { data, isFetching: loading } = useQuery({ | |||
| const [ids, setIds] = useState<string[]>([]); | |||
| const { data, isFetching: loading } = useQuery<IMCPToolRecord>({ | |||
| queryKey: [McpApiAction.ListMcpServerTools], | |||
| initialData: [], | |||
| initialData: {} as IMCPToolRecord, | |||
| gcTime: 0, | |||
| enabled: ids.length > 0, | |||
| queryFn: async () => { | |||
| const { data } = await mcpServerService.listTools(); | |||
| return data?.data ?? []; | |||
| const { data } = await mcpServerService.listTools({ mcp_ids: ids }); | |||
| return data?.data ?? {}; | |||
| }, | |||
| }); | |||
| return { data, loading }; | |||
| return { data, loading, setIds }; | |||
| }; | |||
| export const useTestMcpServer = () => { | |||
| @@ -11,6 +11,8 @@ export interface IMcpServer { | |||
| export type IMCPToolObject = Record<string, Omit<IMCPTool, 'name'>>; | |||
| export type IMCPToolRecord = Record<string, IMCPTool>; | |||
| export interface IMcpServerListResponse { | |||
| mcp_servers: IMcpServer[]; | |||
| total: number; | |||
| @@ -11,11 +11,13 @@ import { RAGFlowNodeType } from '@/interfaces/database/flow'; | |||
| import { cn } from '@/lib/utils'; | |||
| import { lowerFirst } from 'lodash'; | |||
| import { Play, X } from 'lucide-react'; | |||
| import { useMemo } from 'react'; | |||
| import { BeginId, Operator } from '../constant'; | |||
| import { AgentFormContext } from '../context'; | |||
| import { RunTooltip } from '../flow-tooltip'; | |||
| import { useHandleNodeNameChange } from '../hooks/use-change-node-name'; | |||
| import OperatorIcon from '../operator-icon'; | |||
| import useGraphStore from '../store'; | |||
| import { needsSingleStepDebugging } from '../utils'; | |||
| import SingleDebugDrawer from './single-debug-drawer'; | |||
| import { useFormConfigMap } from './use-form-config-map'; | |||
| @@ -40,6 +42,7 @@ const FormSheet = ({ | |||
| showSingleDebugDrawer, | |||
| }: IModalProps<any> & IProps) => { | |||
| const operatorName: Operator = node?.data.label as Operator; | |||
| const clickedToolId = useGraphStore((state) => state.clickedToolId); | |||
| const FormConfigMap = useFormConfigMap(); | |||
| @@ -52,6 +55,13 @@ const FormSheet = ({ | |||
| data: node?.data, | |||
| }); | |||
| const isMcp = useMemo(() => { | |||
| return ( | |||
| operatorName === Operator.Tool && | |||
| Object.values(Operator).every((x) => x !== clickedToolId) | |||
| ); | |||
| }, [clickedToolId, operatorName]); | |||
| const { t } = useTranslate('flow'); | |||
| return ( | |||
| @@ -67,18 +77,23 @@ const FormSheet = ({ | |||
| <section className="flex-col border-b py-2 px-5"> | |||
| <div className="flex items-center gap-2 pb-3"> | |||
| <OperatorIcon name={operatorName}></OperatorIcon> | |||
| <div className="flex items-center gap-1 flex-1"> | |||
| <label htmlFor="">{t('title')}</label> | |||
| {node?.id === BeginId ? ( | |||
| <span>{t(BeginId)}</span> | |||
| ) : ( | |||
| <Input | |||
| value={name} | |||
| onBlur={handleNameBlur} | |||
| onChange={handleNameChange} | |||
| ></Input> | |||
| )} | |||
| </div> | |||
| {isMcp ? ( | |||
| <div className="flex-1">MCP Config</div> | |||
| ) : ( | |||
| <div className="flex items-center gap-1 flex-1"> | |||
| <label htmlFor="">{t('title')}</label> | |||
| {node?.id === BeginId ? ( | |||
| <span>{t(BeginId)}</span> | |||
| ) : ( | |||
| <Input | |||
| value={name} | |||
| onBlur={handleNameBlur} | |||
| onChange={handleNameChange} | |||
| ></Input> | |||
| )} | |||
| </div> | |||
| )} | |||
| {needsSingleStepDebugging(operatorName) && ( | |||
| <RunTooltip> | |||
| @@ -1,5 +1,6 @@ | |||
| import useGraphStore from '../../store'; | |||
| import { ToolFormConfigMap } from './constant'; | |||
| import MCPForm from './mcp-form'; | |||
| const EmptyContent = () => <div></div>; | |||
| @@ -8,6 +9,7 @@ const ToolForm = () => { | |||
| const ToolForm = | |||
| ToolFormConfigMap[clickedToolId as keyof typeof ToolFormConfigMap] ?? | |||
| MCPForm ?? | |||
| EmptyContent; | |||
| return ( | |||
| @@ -0,0 +1,103 @@ | |||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |||
| import { Checkbox } from '@/components/ui/checkbox'; | |||
| import { | |||
| Form, | |||
| FormControl, | |||
| FormField, | |||
| FormItem, | |||
| FormMessage, | |||
| } from '@/components/ui/form'; | |||
| import { useGetMcpServer } from '@/hooks/use-mcp-request'; | |||
| import useGraphStore from '@/pages/agent/store'; | |||
| import { zodResolver } from '@hookform/resolvers/zod'; | |||
| import { memo } from 'react'; | |||
| import { useForm } from 'react-hook-form'; | |||
| import { z } from 'zod'; | |||
| import { MCPCard } from './mcp-card'; | |||
| import { useValues } from './use-values'; | |||
| import { useWatchFormChange } from './use-watch-change'; | |||
| const FormSchema = z.object({ | |||
| items: z.array(z.string()), | |||
| }); | |||
| function MCPForm() { | |||
| const clickedToolId = useGraphStore((state) => state.clickedToolId); | |||
| const values = useValues(); | |||
| const form = useForm({ | |||
| defaultValues: values, | |||
| resolver: zodResolver(FormSchema), | |||
| }); | |||
| const { data } = useGetMcpServer(clickedToolId); | |||
| useWatchFormChange(form); | |||
| return ( | |||
| <Form {...form}> | |||
| <form | |||
| className="space-y-6 p-4" | |||
| onSubmit={(e) => { | |||
| e.preventDefault(); | |||
| }} | |||
| > | |||
| <Card className="bg-background-highlight p-5"> | |||
| <CardHeader className="p-0 pb-3"> | |||
| <CardTitle>{data.name}</CardTitle> | |||
| </CardHeader> | |||
| <CardContent className="p-0"> | |||
| <span className="pr-2"> URL:</span> | |||
| <a href={data.url} className="text-background-checked"> | |||
| {data.url} | |||
| </a> | |||
| </CardContent> | |||
| </Card> | |||
| <FormField | |||
| control={form.control} | |||
| name="items" | |||
| render={() => ( | |||
| <FormItem className="space-y-2"> | |||
| {Object.entries(data.variables?.tools || {}).map( | |||
| ([name, mcp]) => ( | |||
| <FormField | |||
| key={name} | |||
| control={form.control} | |||
| name="items" | |||
| render={({ field }) => { | |||
| return ( | |||
| <FormItem | |||
| key={name} | |||
| className="flex flex-row items-center gap-2" | |||
| > | |||
| <FormControl> | |||
| <MCPCard key={name} data={{ ...mcp, name }}> | |||
| <Checkbox | |||
| className="translate-y-1" | |||
| checked={field.value?.includes(name)} | |||
| onCheckedChange={(checked) => { | |||
| return checked | |||
| ? field.onChange([...field.value, name]) | |||
| : field.onChange( | |||
| field.value?.filter( | |||
| (value) => value !== name, | |||
| ), | |||
| ); | |||
| }} | |||
| /> | |||
| </MCPCard> | |||
| </FormControl> | |||
| </FormItem> | |||
| ); | |||
| }} | |||
| /> | |||
| ), | |||
| )} | |||
| <FormMessage /> | |||
| </FormItem> | |||
| )} | |||
| /> | |||
| </form> | |||
| </Form> | |||
| ); | |||
| } | |||
| export default memo(MCPForm); | |||
| @@ -0,0 +1,20 @@ | |||
| import { Card, CardContent, CardTitle } from '@/components/ui/card'; | |||
| import { IMCPTool } from '@/interfaces/database/mcp'; | |||
| import { PropsWithChildren } from 'react'; | |||
| export function MCPCard({ | |||
| data, | |||
| children, | |||
| }: { data: IMCPTool } & PropsWithChildren) { | |||
| return ( | |||
| <Card className="p-3"> | |||
| <CardContent className="p-0 flex gap-3"> | |||
| {children} | |||
| <section> | |||
| <CardTitle className="pb-3">{data.name}</CardTitle> | |||
| <p>{data.description}</p> | |||
| </section> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import useGraphStore from '@/pages/agent/store'; | |||
| import { getAgentNodeMCP } from '@/pages/agent/utils'; | |||
| import { isEmpty } from 'lodash'; | |||
| import { useMemo } from 'react'; | |||
| export function useValues() { | |||
| const { clickedToolId, clickedNodeId, findUpstreamNodeById } = useGraphStore( | |||
| (state) => state, | |||
| ); | |||
| const values = useMemo(() => { | |||
| const agentNode = findUpstreamNodeById(clickedNodeId); | |||
| const mcpList = getAgentNodeMCP(agentNode); | |||
| const formData = | |||
| mcpList.find((x) => x.mcp_id === clickedToolId)?.tools || {}; | |||
| if (isEmpty(formData)) { | |||
| return { items: [] }; | |||
| } | |||
| return { items: Object.keys(formData) }; | |||
| }, [clickedNodeId, clickedToolId, findUpstreamNodeById]); | |||
| return values; | |||
| } | |||
| @@ -0,0 +1,47 @@ | |||
| import { useGetMcpServer } from '@/hooks/use-mcp-request'; | |||
| import useGraphStore from '@/pages/agent/store'; | |||
| import { getAgentNodeMCP } from '@/pages/agent/utils'; | |||
| import { pick } from 'lodash'; | |||
| import { useEffect, useMemo } from 'react'; | |||
| import { UseFormReturn, useWatch } from 'react-hook-form'; | |||
| export function useWatchFormChange(form?: UseFormReturn<any>) { | |||
| let values = useWatch({ control: form?.control }); | |||
| const { clickedToolId, clickedNodeId, findUpstreamNodeById, updateNodeForm } = | |||
| useGraphStore((state) => state); | |||
| const { data } = useGetMcpServer(clickedToolId); | |||
| const nextMCPTools = useMemo(() => { | |||
| const mcpTools = data.variables?.tools || []; | |||
| values = form?.getValues(); | |||
| return pick(mcpTools, values.items); | |||
| }, [values, data?.variables]); | |||
| useEffect(() => { | |||
| const agentNode = findUpstreamNodeById(clickedNodeId); | |||
| // Manually triggered form updates are synchronized to the canvas | |||
| if (agentNode) { | |||
| const agentNodeId = agentNode?.id; | |||
| const mcpList = getAgentNodeMCP(agentNode); | |||
| const nextMCP = mcpList.map((x) => { | |||
| if (x.mcp_id === clickedToolId) { | |||
| return { | |||
| ...x, | |||
| tools: nextMCPTools, | |||
| }; | |||
| } | |||
| return x; | |||
| }); | |||
| updateNodeForm(agentNodeId, nextMCP, ['mcp']); | |||
| } | |||
| }, [ | |||
| clickedNodeId, | |||
| clickedToolId, | |||
| findUpstreamNodeById, | |||
| nextMCPTools, | |||
| updateNodeForm, | |||
| ]); | |||
| } | |||
| @@ -569,6 +569,11 @@ export function getAgentNodeTools(agentNode?: RAGFlowNodeType) { | |||
| return tools; | |||
| } | |||
| export function getAgentNodeMCP(agentNode?: RAGFlowNodeType) { | |||
| const tools: IAgentForm['mcp'] = get(agentNode, 'data.form.mcp', []); | |||
| return tools; | |||
| } | |||
| export function mapEdgeMouseEvent( | |||
| edges: Edge[], | |||
| edgeId: string, | |||
| @@ -48,7 +48,7 @@ const methods = { | |||
| }, | |||
| listTools: { | |||
| url: listMcpServerTools, | |||
| method: 'get', | |||
| method: 'post', | |||
| }, | |||
| testTool: { | |||
| url: testMcpServerTool, | |||