make MCPFormPanel agnostic to Agent / Chat context

This commit is contained in:
Dustin Healy 2025-06-26 14:58:38 -07:00
parent cf91dc3aad
commit 389ab1db77
6 changed files with 233 additions and 152 deletions

View file

@ -0,0 +1,112 @@
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
import type { MCP } from '~/common';
import MCPFormPanel from '../MCP/MCPFormPanel';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({
onSuccess,
onError,
}: {
onSuccess: () => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
try {
console.log('Mock delete MCP:', { mcp_id, agent_id });
onSuccess();
} catch (error) {
onError(error as Error);
}
},
};
}
function useUpdateAgentMCP({
onSuccess,
onError,
}: {
onSuccess: (mcp: MCP) => void;
onError: (error: Error) => void;
}) {
return {
mutate: async (mcp: MCP) => {
try {
// TODO: Implement MCP endpoint
onSuccess(mcp);
} catch (error) {
onError(error as Error);
}
},
isLoading: false,
};
}
export default function AgentMCPFormPanel() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
const updateAgentMCP = useUpdateAgentMCP({
onSuccess(mcp) {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setMcp(mcp);
},
onError(error) {
showToast({
message: (error as Error).message || localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const deleteAgentMCP = useDeleteAgentMCP({
onSuccess: () => {
showToast({
message: localize('com_ui_delete_mcp_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setMcp(undefined);
},
onError(error) {
showToast({
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
status: 'error',
});
},
});
const handleBack = () => {
setActivePanel(Panel.builder);
setMcp(undefined);
};
const handleSave = (mcp: MCP) => {
updateAgentMCP.mutate(mcp);
};
const handleDelete = (mcp_id: string, contextId: string) => {
deleteAgentMCP.mutate({ mcp_id, agent_id: contextId });
};
return (
<MCPFormPanel
mcp={mcp}
contextId={agent_id}
onBack={handleBack}
onDelete={handleDelete}
onSave={handleSave}
showDeleteButton={!!mcp}
isDeleteDisabled={!agent_id || !mcp?.mcp_id}
/>
);
}

View file

@ -7,7 +7,7 @@ import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel'; import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel'; import AgentPanel from './AgentPanel';
import MCPPanel from './MCPPanel'; import AgentMCPFormPanel from './AgentMCPFormPanel';
import { Panel } from '~/common'; import { Panel } from '~/common';
export default function AgentPanelSwitch() { export default function AgentPanelSwitch() {
@ -55,7 +55,7 @@ function AgentPanelSwitchWithContext() {
return <VersionPanel />; return <VersionPanel />;
} }
if (activePanel === Panel.mcp) { if (activePanel === Panel.mcp) {
return <MCPPanel />; return <AgentMCPFormPanel />;
} }
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />; return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
} }

View file

@ -1,66 +1,57 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { defaultMCPFormValues } from '~/common/mcp'; import { defaultMCPFormValues } from '~/common/mcp';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg'; import { TrashIcon } from '~/components/svg';
import type { MCPForm } from '~/common'; import type { MCPForm, MCP } from '~/common';
import MCPInput from './MCPInput'; import MCPInput from './MCPInput';
import { Panel } from '~/common';
import { import {
AuthTypeEnum, AuthTypeEnum,
AuthorizationTypeEnum, AuthorizationTypeEnum,
TokenExchangeMethodEnum, TokenExchangeMethodEnum,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({ interface MCPFormPanelProps {
onSuccess, // Data
onError, mcp?: MCP;
}: { agent_id?: string; // agent_id, conversation_id, etc.
onSuccess: () => void;
onError: (error: Error) => void; // Actions
}) { onBack: () => void;
return { onDelete?: (mcp_id: string, agent_id: string) => void;
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => { onSave: (mcp: MCP) => void;
try {
console.log('Mock delete MCP:', { mcp_id, agent_id }); // UI customization
onSuccess(); title?: string;
} catch (error) { subtitle?: string;
onError(error as Error); showDeleteButton?: boolean;
} isDeleteDisabled?: boolean;
}, deleteConfirmMessage?: string;
};
// Form customization
defaultValues?: Partial<MCPForm>;
} }
export default function MCPPanel() { export default function MCPFormPanel({
mcp,
agent_id,
onBack,
onDelete,
onSave,
title,
subtitle,
showDeleteButton = true,
isDeleteDisabled = false,
deleteConfirmMessage,
defaultValues = defaultMCPFormValues,
}: MCPFormPanelProps) {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext();
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
const deleteAgentMCP = useDeleteAgentMCP({
onSuccess: () => {
showToast({
message: localize('com_ui_delete_mcp_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setMcp(undefined);
},
onError(error) {
showToast({
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
status: 'error',
});
},
});
const methods = useForm<MCPForm>({ const methods = useForm<MCPForm>({
defaultValues: defaultMCPFormValues, defaultValues: defaultValues,
}); });
const { reset } = methods; const { reset } = methods;
@ -96,33 +87,32 @@ export default function MCPPanel() {
} }
}, [mcp, reset]); }, [mcp, reset]);
const handleDelete = () => {
if (onDelete && mcp?.mcp_id && agent_id) {
onDelete(mcp.mcp_id, agent_id);
}
};
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form className="h-full grow overflow-hidden"> <form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto px-2 pb-12 text-sm"> <div className="h-full overflow-auto px-2 pb-12 text-sm">
<div className="relative flex flex-col items-center px-16 py-6 text-center"> <div className="relative flex flex-col items-center px-16 py-6 text-center">
<div className="absolute left-0 top-6"> <div className="absolute left-0 top-6">
<button <button type="button" className="btn btn-neutral relative" onClick={onBack}>
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
setMcp(undefined);
}}
>
<div className="flex w-full items-center justify-center gap-2"> <div className="flex w-full items-center justify-center gap-2">
<ChevronLeft /> <ChevronLeft />
</div> </div>
</button> </button>
</div> </div>
{!!mcp && ( {!!mcp && showDeleteButton && onDelete && (
<OGDialog> <OGDialog>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<div className="absolute right-0 top-6"> <div className="absolute right-0 top-6">
<button <button
type="button" type="button"
disabled={!agent_id || !mcp.mcp_id} disabled={isDeleteDisabled || !mcp.mcp_id || !agent_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium" className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
> >
<TrashIcon className="text-red-500" /> <TrashIcon className="text-red-500" />
@ -135,22 +125,11 @@ export default function MCPPanel() {
className="max-w-[450px]" className="max-w-[450px]"
main={ main={
<Label className="text-left text-sm font-medium"> <Label className="text-left text-sm font-medium">
{localize('com_ui_delete_mcp_confirm')} {deleteConfirmMessage || localize('com_ui_delete_mcp_confirm')}
</Label> </Label>
} }
selection={{ selection={{
selectHandler: () => { selectHandler: handleDelete,
if (!agent_id) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
});
}
deleteAgentMCP.mutate({
mcp_id: mcp.mcp_id,
agent_id,
});
},
selectClasses: selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white', 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'), selectText: localize('com_ui_delete'),
@ -160,11 +139,14 @@ export default function MCPPanel() {
)} )}
<div className="text-xl font-medium"> <div className="text-xl font-medium">
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')} {title ||
(mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server'))}
</div>
<div className="text-xs text-text-secondary">
{subtitle || localize('com_agents_mcp_info')}
</div> </div>
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
</div> </div>
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} /> <MCPInput mcp={mcp} agent_id={agent_id} onSave={onSave} />
</div> </div>
</form> </form>
</FormProvider> </FormProvider>

View file

@ -5,54 +5,24 @@ import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon'; import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { Label, Checkbox } from '~/components/ui'; import { Label, Checkbox } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg'; import { Spinner } from '~/components/svg';
import { MCPForm } from '~/common/types'; import { MCPForm } from '~/common/types';
function useUpdateAgentMCP({
onSuccess,
onError,
}: {
onSuccess: (data: [string, MCP]) => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({
mcp_id,
metadata,
agent_id,
}: {
mcp_id?: string;
metadata: MCP['metadata'];
agent_id: string;
}) => {
try {
// TODO: Implement MCP endpoint
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
} catch (error) {
onError(error as Error);
}
},
isLoading: false,
};
}
interface MCPInputProps { interface MCPInputProps {
mcp?: MCP; mcp?: MCP;
agent_id?: string; agent_id?: string;
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>; onSave: (mcp: MCP) => void;
isLoading?: boolean;
} }
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) { export default function MCPInput({ mcp, a, onSave, isLoading = false }: MCPInputProps) {
const localize = useLocalize(); const localize = useLocalize();
const { showToast } = useToastContext();
const { const {
handleSubmit, handleSubmit,
register, register,
formState: { errors }, formState: { errors },
control, control,
} = useFormContext<MCPForm>(); } = useFormContext<MCPForm>();
const [isLoading, setIsLoading] = useState(false);
const [showTools, setShowTools] = useState(false); const [showTools, setShowTools] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]); const [selectedTools, setSelectedTools] = useState<string[]>([]);
@ -64,50 +34,16 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
} }
}, [mcp]); }, [mcp]);
const updateAgentMCP = useUpdateAgentMCP({
onSuccess(data) {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setMCP(data[1]);
setShowTools(true);
setSelectedTools(data[1].metadata.tools ?? []);
setIsLoading(false);
},
onError(error) {
showToast({
message: (error as Error).message || localize('com_ui_update_mcp_error'),
status: 'error',
});
setIsLoading(false);
},
});
const saveMCP = handleSubmit(async (data: MCPForm) => { const saveMCP = handleSubmit(async (data: MCPForm) => {
setIsLoading(true); const updatedMCP: MCP = {
try { mcp_id: mcp?.mcp_id ?? '',
const response = await updateAgentMCP.mutate({ agent_id: a ?? '', // This will be agent_id, conversation_id, etc.
agent_id: agent_id ?? '', metadata: {
mcp_id: mcp?.mcp_id, ...data,
metadata: { tools: selectedTools,
...data, },
tools: selectedTools, };
}, onSave(updatedMCP);
});
setMCP(response[1]);
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
} catch {
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
} finally {
setIsLoading(false);
}
}); });
const handleSelectAll = () => { const handleSelectAll = () => {
@ -140,14 +76,15 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
const base64String = reader.result as string; const base64String = reader.result as string;
setMCP({ const updatedMCP: MCP = {
mcp_id: mcp?.mcp_id ?? '', mcp_id: mcp?.mcp_id ?? '',
agent_id: agent_id ?? '', agent_id: a ?? '',
metadata: { metadata: {
...mcp?.metadata, ...mcp?.metadata,
icon: base64String, icon: base64String,
}, },
}); };
onSave(updatedMCP);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }

View file

@ -9,6 +9,8 @@ import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton'; import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useToastContext } from '~/Providers'; import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import MCPFormPanel from './MCPFormPanel';
import type { MCP } from '~/common';
interface ServerConfigWithVars { interface ServerConfigWithVars {
serverName: string; serverName: string;
@ -24,6 +26,7 @@ export default function MCPPanel() {
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>( const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null, null,
); );
const [showMCPForm, setShowMCPForm] = useState(false);
const mcpServerDefinitions = useMemo(() => { const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) { if (!startupConfig?.mcpServers) {
@ -89,14 +92,54 @@ export default function MCPPanel() {
setSelectedServerNameForEditing(null); setSelectedServerNameForEditing(null);
}; };
const handleAddMCP = () => {
setShowMCPForm(true);
};
const handleBackFromForm = () => {
setShowMCPForm(false);
};
const handleSaveMCP = (mcp: MCP) => {
// TODO: Implement MCP save logic for conversation context
console.log('Saving MCP:', mcp);
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setShowMCPForm(false);
};
if (showMCPForm) {
return (
<MCPFormPanel
onBack={handleBackFromForm}
onSave={handleSaveMCP}
showDeleteButton={false}
title={localize('com_ui_add_mcp_server')}
subtitle={localize('com_agents_mcp_info_chat')}
/>
);
}
if (startupConfigLoading) { if (startupConfigLoading) {
return <MCPPanelSkeleton />; return <MCPPanelSkeleton />;
} }
if (mcpServerDefinitions.length === 0) { if (mcpServerDefinitions.length === 0) {
return ( return (
<div className="p-4 text-center text-sm text-gray-500"> <div className="h-auto max-w-full overflow-x-hidden p-3">
{localize('com_sidepanel_mcp_no_servers_with_vars')} <div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
</div>
<div className="mt-4">
<Button
onClick={handleAddMCP}
className="w-full bg-green-500 text-white hover:bg-green-600"
>
{localize('com_ui_add_mcp')}
</Button>
</div>
</div> </div>
); );
} }
@ -153,6 +196,12 @@ export default function MCPPanel() {
{server.serverName} {server.serverName}
</Button> </Button>
))} ))}
<Button
onClick={handleAddMCP}
className="w-full bg-green-500 text-white hover:bg-green-600"
>
{localize('com_ui_add_mcp')}
</Button>
</div> </div>
</div> </div>
); );

View file

@ -20,6 +20,7 @@
"com_agents_mcp_description_placeholder": "Explain what it does in a few words", "com_agents_mcp_description_placeholder": "Explain what it does in a few words",
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px", "com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services", "com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
"com_agents_mcp_info_chat": "Add MCP servers to enable chat to perform tasks and interact with external services",
"com_agents_mcp_name_placeholder": "Custom Tool", "com_agents_mcp_name_placeholder": "Custom Tool",
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat", "com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.", "com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",