✂️ refactor: MCP UI Separation for Agents (#9237)

* refactor: MCP UI Separation for Agents (Dustin WIP)

feat: separate MCPs into their own lists away from tools + actions and add the status indicator functionality from chat to their dropdown ui

fix: spotify mcp was not persisting on agent creation

feat: show disconnected saved servers and their tools in agent mcp list in created agents

fix: select-all regression fixed (caused by deleting tools we were drawing from for rendering list)

fix: dont show all mcps, only those installed in agent in list

feat: separate ToolSelectDialog for MCPServerTools

fix: uninitialized mcp servers not showing as added in toolselectdialog

refactor: reduce looping in AgentPanelContext for categorizing groups and mcps

refactor: split ToolSelectDialog and MCPToolSelectDialog functionality (still needs customization for custom user vars)

chore: address ESLint comments

chore: address ESLint comments

feat: one-click initialization on MCP servers in agent builder

fix: stop propagation triggering reinit on caret click

refactor: split uninitialized MCPs component from initialized MCPs

feat: new mcp tool select dialog ui with custom user vars

feat: show initialization state for CUV configurable MCPs too

chore: remove unused localization string

fix: deselecting all tools caused a re-render

fix: remove subtools so removal from MCPToolSelectDialog works more consistently

feat: added servers have all tools enabled by default

feat: mcp server list now alphabetical to prevent annoying ui behavior of servers jumping around depending on tool selection

fix: filter out placeholder group mcp tools from any actual tool calls / definitions

feat: indicator now takes you to config dialog for uninitialized servers

feat: show previously configured mcp servers that are now missing from the yaml

feat: select all enabled by default on first add to mcp server list

chore: address ESLint comments

* refactor: MCP UI Separation for Agents (Danny WIP)

chore: remove use of `{serverName}_mcp_{serverName}`

chore: import order

WIP: separate component concerns

refactor: streamline agent mcp tools

refactor: unify MCP server handling and improve tool visibility logic, remove unnecessary normalization or sorting, remove nesting button, make variable names clear

refactor: rename mcpServerIds to mcpServerNames for clarity and consistency across components

refactor: remove groupedMCPTools and toolToServerMap, streamline MCP server handling in context and components to effectively utilize mcpServersMap

refactor: optimize tool selection logic by replacing array includes with Set for improved performance

chore: add error logging for failed auth URL parsing in ToolCall component

refactor: enhance MCP tool handling by improving server name management and updating UI elements for better clarity

* refactor: decouple connection status from useMCPServerManager with useMCPConnectionStatus

* fix: improve MCP tool validation logic to handle unconfigured servers

* chore: enhance log message clarity for MCP server disconnection in updateUserPluginsController

* refactor: simplify connection status extraction in useMCPConnectionStatus hook

* refactor: improve initializing UX

* chore: replace string literal with ResourceType constant in useResourcePermissions

* refactor: cleanup code, remove redundancies, rename variables for clarity

* chore: add back filtering and sorting for mcp tools dialog

* refactor: initializeServer to return response and early return

* refactor: enhance server initialization logic and improve UI for OAuth interaction

* chore: clarify warning message for unconfigured MCP server in handleTools

* refactor: prevent CustomUserVarsSection from submitting tools dialog form

* fix: nested button of button issue in UninitializedMCPTool

* feat: add functionality to revoke custom user variables in MCPToolSelectDialog

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2025-08-29 19:57:01 -07:00 committed by GitHub
parent d16f93b5f7
commit 49e8443ec5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1589 additions and 180 deletions

View file

@ -312,6 +312,16 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
continue;
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
if (toolName === Constants.mcp_server) {
/** Placeholder used for UI purposes */
continue;
}
if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) {
logger.warn(
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
);
continue;
}
if (toolName === Constants.mcp_all) {
const currentMCPGenerator = async (index) =>
createMCPTools({

View file

@ -187,7 +187,7 @@ const updateUserPluginsController = async (req, res) => {
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
logger.info(
`[updateUserPluginsController] Disconnecting MCP server ${serverName} for user ${user.id} after plugin auth update for ${pluginKey}.`,
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
);
await mcpManager.disconnectUserConnection(user.id, serverName);
}

View file

@ -5,6 +5,7 @@ const { logger } = require('@librechat/data-schemas');
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
const {
Tools,
Constants,
SystemRoles,
FileSources,
ResourceType,
@ -69,9 +70,9 @@ const createAgentHandler = async (req, res) => {
for (const tool of tools) {
if (availableTools[tool]) {
agentData.tools.push(tool);
}
if (systemTools[tool]) {
} else if (systemTools[tool]) {
agentData.tools.push(tool);
} else if (tool.includes(Constants.mcp_delimiter)) {
agentData.tools.push(tool);
}
}

View file

@ -271,6 +271,7 @@ async function createMCPTool({
availableTools: tools,
}) {
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const availableTools =
tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true }));
/** @type {LCTool | undefined} */

View file

@ -1,11 +1,14 @@
import React, { createContext, useContext, useState } from 'react';
import React, { createContext, useContext, useState, useMemo } from 'react';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider';
import type { AgentPanelContextType } from '~/common';
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
import { useLocalize, useGetAgentsConfig } from '~/hooks';
import type { AgentPanelContextType, MCPServerInfo } from '~/common';
import { useAvailableToolsQuery, useGetActionsQuery, useGetStartupConfig } from '~/data-provider';
import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks';
import { Panel } from '~/common';
type GroupedToolType = AgentToolType & { tools?: AgentToolType[] };
type GroupedToolsRecord = Record<string, GroupedToolType>;
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
export function useAgentPanelContext() {
@ -33,67 +36,116 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !!agent_id,
});
const tools =
pluginTools?.map((tool) => ({
tool_id: tool.pluginKey,
metadata: tool as TPlugin,
agent_id: agent_id || '',
})) || [];
const { data: startupConfig } = useGetStartupConfig();
const mcpServerNames = useMemo(
() => Object.keys(startupConfig?.mcpServers ?? {}),
[startupConfig],
);
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!agent_id && mcpServerNames.length > 0,
});
const processedData = useMemo(() => {
if (!pluginTools) {
return {
tools: [],
groupedTools: {},
mcpServersMap: new Map<string, MCPServerInfo>(),
};
}
const tools: AgentToolType[] = [];
const groupedTools: GroupedToolsRecord = {};
const configuredServers = new Set(mcpServerNames);
const mcpServersMap = new Map<string, MCPServerInfo>();
for (const pluginTool of pluginTools) {
const tool: AgentToolType = {
tool_id: pluginTool.pluginKey,
metadata: pluginTool as TPlugin,
};
tools.push(tool);
const groupedTools = tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) {
acc[groupKey] = {
tool_id: groupKey,
metadata: {
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '',
} as TPlugin,
agent_id: agent_id || '',
if (!mcpServersMap.has(serverName)) {
const metadata = {
name: serverName,
pluginKey: serverName,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: pluginTool.icon || '',
} as TPlugin;
mcpServersMap.set(serverName, {
serverName,
tools: [],
};
isConfigured: configuredServers.has(serverName),
isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
metadata,
});
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
mcpServersMap.get(serverName)!.tools.push(tool);
} else {
acc[tool.tool_id] = {
// Non-MCP tool
groupedTools[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
);
}
for (const mcpServerName of mcpServerNames) {
if (mcpServersMap.has(mcpServerName)) {
continue;
}
const metadata = {
icon: '',
name: mcpServerName,
pluginKey: mcpServerName,
description: `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`,
} as TPlugin;
mcpServersMap.set(mcpServerName, {
tools: [],
metadata,
isConfigured: true,
serverName: mcpServerName,
isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected',
});
}
return {
tools,
groupedTools,
mcpServersMap,
};
}, [pluginTools, localize, mcpServerNames, connectionStatus]);
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
const value: AgentPanelContextType = {
mcp,
mcps,
/** Query data for actions and tools */
tools,
action,
setMcp,
actions,
setMcps,
agent_id,
setAction,
pluginTools,
activePanel,
groupedTools,
agentsConfig,
setActivePanel,
endpointsConfig,
setCurrentAgentId,
tools: processedData.tools,
groupedTools: processedData.groupedTools,
mcpServersMap: processedData.mcpServersMap,
};
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;

View file

@ -216,6 +216,14 @@ export type AgentPanelProps = {
agentsConfig?: t.TAgentsEndpoint | null;
};
export interface MCPServerInfo {
serverName: string;
tools: t.AgentToolType[];
isConfigured: boolean;
isConnected: boolean;
metadata: t.TPlugin;
}
export type AgentPanelContextType = {
action?: t.Action;
actions?: t.Action[];
@ -225,13 +233,16 @@ export type AgentPanelContextType = {
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
tools: t.AgentToolType[];
activePanel?: string;
tools: t.AgentToolType[];
pluginTools?: t.TPlugin[];
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agent_id?: string;
agentsConfig?: t.TAgentsEndpoint | null;
endpointsConfig?: t.TEndpointsConfig | null;
/** Pre-computed MCP server information indexed by server key */
mcpServersMap: Map<string, MCPServerInfo>;
};
export type AgentModelPanelProps = {

View file

@ -1,9 +1,9 @@
import React, { memo, useCallback } from 'react';
import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useBadgeRowContext } from '~/Providers';
import { useMCPServerManager } from '~/hooks';
type MCPSelectProps = { conversationId?: string | null };

View file

@ -3,8 +3,8 @@ import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
interface MCPSubMenuProps {

View file

@ -88,6 +88,10 @@ export default function ToolCall({
const url = new URL(authURL);
return url.hostname;
} catch (e) {
logger.error(
'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to parse auth URL',
e,
);
return '';
}
}, [auth]);

View file

@ -16,7 +16,6 @@ interface CustomUserVarsSectionProps {
onRevoke: () => void;
isSubmitting?: boolean;
}
interface AuthFieldProps {
name: string;
config: CustomUserVarConfig;
@ -69,7 +68,7 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title })
}
className="w-full shadow-sm sm:text-sm"
className="w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm"
/>
)}
/>
@ -79,23 +78,22 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
}
export default function CustomUserVarsSection({
serverName,
fields,
onSave,
onRevoke,
serverName,
isSubmitting = false,
}: CustomUserVarsSectionProps) {
const localize = useLocalize();
// Fetch auth value flags for the server
const { data: authValuesData } = useMCPAuthValuesQuery(serverName, {
enabled: !!serverName,
});
const {
reset,
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<Record<string, string>>({
defaultValues: useMemo(() => {
@ -140,10 +138,20 @@ export default function CustomUserVarsSection({
</form>
<div className="flex justify-end gap-2">
<Button onClick={handleRevokeClick} variant="destructive" disabled={isSubmitting}>
<Button
type="button"
variant="destructive"
disabled={isSubmitting}
onClick={handleRevokeClick}
>
{localize('com_ui_revoke')}
</Button>
<Button onClick={handleSubmit(onFormSubmit)} variant="submit" disabled={isSubmitting}>
<Button
type="button"
variant="submit"
disabled={isSubmitting}
onClick={handleSubmit(onFormSubmit)}
>
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
</Button>
</div>

View file

@ -1,8 +1,8 @@
import React from 'react';
import { RefreshCw } from 'lucide-react';
import { Button, Spinner } from '@librechat/client';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import { useLocalize } from '~/hooks';
import { useLocalize, useMCPServerManager, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
interface ServerInitializationSectionProps {
sidePanel?: boolean;
@ -21,16 +21,15 @@ export default function ServerInitializationSection({
}: ServerInitializationSectionProps) {
const localize = useLocalize();
const {
initializeServer,
connectionStatus,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
} = useMCPServerManager({ conversationId });
const { initializeServer, cancelOAuthFlow, isInitializing, isCancellable, getOAuthUrl } =
useMCPServerManager({ conversationId });
const serverStatus = connectionStatus[serverName];
const { data: startupConfig } = useGetStartupConfig();
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
const serverStatus = connectionStatus?.[serverName];
const isConnected = serverStatus?.connectionState === 'connected';
const canCancel = isCancellable(serverName);
const isServerInitializing = isInitializing(serverName);

View file

@ -12,22 +12,23 @@ import {
getIconKey,
cn,
} from '~/utils';
import { useFileMapContext, useAgentPanelContext } from '~/Providers';
import { ToolSelectDialog, MCPToolSelectDialog } from '~/components/Tools';
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
import { useFileMapContext, useAgentPanelContext } from '~/Providers';
import AgentCategorySelector from './AgentCategorySelector';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { useLocalize, useVisibleTools } from '~/hooks';
import { useGetAgentFiles } from '~/data-provider';
import { icons } from '~/hooks/Endpoint/Icons';
import Instructions from './Instructions';
import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
import SearchForm from './Search/Form';
import { useLocalize } from '~/hooks';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import MCPTools from './MCPTools';
import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
@ -43,10 +44,12 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
const { showToast } = useToastContext();
const methods = useFormContext<AgentForm>();
const [showToolDialog, setShowToolDialog] = useState(false);
const [showMCPToolDialog, setShowMCPToolDialog] = useState(false);
const {
actions,
setAction,
agentsConfig,
mcpServersMap,
setActivePanel,
endpointsConfig,
groupedTools: allTools,
@ -173,19 +176,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
Icon = icons[iconKey];
}
// Determine what to show
const selectedToolIds = tools ?? [];
const visibleToolIds = new Set(selectedToolIds);
// Check what group parent tools should be shown if any subtool is present
Object.entries(allTools ?? {}).forEach(([toolId, toolObj]) => {
if (toolObj.tools?.length) {
// if any subtool of this group is selected, ensure group parent tool rendered
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
visibleToolIds.add(toolId);
}
}
});
const { toolIds, mcpServerNames } = useVisibleTools(tools, allTools, mcpServersMap);
return (
<>
@ -326,8 +317,8 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
</label>
<div>
<div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => {
{/* Render all visible IDs (including groups with subtools selected) */}
{toolIds.map((toolId, i) => {
if (!allTools) return null;
const tool = allTools[toolId];
if (!tool) return null;
@ -385,8 +376,11 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
</div>
</div>
{/* MCP Section */}
{/* <MCPSection /> */}
<MCPTools
agentId={agent_id}
mcpServerNames={mcpServerNames}
setShowMCPToolDialog={setShowMCPToolDialog}
/>
{/* Support Contact (Optional) */}
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-2">
@ -477,6 +471,13 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
setIsOpen={setShowToolDialog}
endpoint={EModelEndpoint.agents}
/>
<MCPToolSelectDialog
agentId={agent_id}
isOpen={showMCPToolDialog}
mcpServerNames={mcpServerNames}
setIsOpen={setShowMCPToolDialog}
endpoint={EModelEndpoint.agents}
/>
</>
);
}

View file

@ -7,6 +7,7 @@ import {
Tools,
Constants,
SystemRoles,
ResourceType,
EModelEndpoint,
PermissionBits,
isAssistantsEndpoint,
@ -53,7 +54,7 @@ export default function AgentPanel() {
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'agent',
ResourceType.AGENT,
basicAgentQuery.data?._id || '',
);

View file

@ -0,0 +1,368 @@
import React, { useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
Checkbox,
OGDialog,
Accordion,
TrashIcon,
AccordionItem,
CircleHelpIcon,
OGDialogTrigger,
useToastContext,
AccordionContent,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [accordionValue, setAccordionValue] = useState<string>('');
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
if (!serverInfo) {
return null;
}
const currentServerName = serverInfo.serverName;
const getSelectedTools = () => {
if (!serverInfo?.tools) return [];
const formTools = getValues('tools') || [];
return serverInfo.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
};
const updateFormTools = (newSelectedTools: string[]) => {
const currentTools = getValues('tools') || [];
const otherTools = currentTools.filter(
(t: string) => !serverInfo?.tools?.some((st) => st.tool_id === t),
);
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
);
};
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentServerName;
const statusIconProps = getServerStatusIconProps(currentServerName);
const configDialogProps = getConfigDialogProps();
const statusIcon = statusIconProps && (
<div
onClick={(e) => {
e.stopPropagation();
}}
className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary"
>
<MCPServerStatusIcon {...statusIconProps} />
</div>
);
return (
<OGDialog>
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
<AccordionItem value={currentServerName} className="group relative w-full border-none">
<div
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-surface-primary-alt"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<AccordionPrimitive.Header asChild>
<div
className="flex grow cursor-pointer select-none items-center gap-1 rounded bg-transparent p-0 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
onClick={() =>
setAccordionValue((prev) => {
if (prev) {
return '';
}
return currentServerName;
})
}
>
{statusIcon && <div className="flex items-center">{statusIcon}</div>}
{serverInfo.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${serverInfo.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentServerName}
</div>
<div className="flex items-center">
<div className="relative flex items-center">
<div
className={cn(
'absolute right-0 transition-all duration-300',
isHovering || isFocused
? 'translate-x-0 opacity-100'
: 'translate-x-8 opacity-0',
)}
>
<div className="flex items-center gap-2">
<div
data-checkbox-container
onClick={(e) => e.stopPropagation()}
className="mt-1"
>
<Checkbox
id={`select-all-${currentServerName}`}
checked={
selectedTools.length === serverInfo.tools?.length &&
selectedTools.length > 0
}
onCheckedChange={(checked) => {
if (serverInfo.tools) {
const newSelectedTools = checked
? serverInfo.tools.map((t) => t.tool_id)
: [
`${Constants.mcp_server}${Constants.mcp_delimiter}${currentServerName}`,
];
updateFormTools(newSelectedTools);
}
}}
className={cn(
'h-4 w-4 rounded border border-border-medium transition-all duration-200 hover:border-border-heavy',
isExpanded ? 'visible' : 'pointer-events-none invisible',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
tabIndex={isExpanded ? 0 : -1}
/>
</div>
<div className="flex items-center gap-1">
{/* Caret button for accordion */}
<AccordionPrimitive.Trigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200 hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isExpanded && 'bg-surface-active-alt',
)}
aria-hidden="true"
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<ChevronDown
className={cn(
'h-4 w-4 transition-transform duration-200',
isExpanded && 'rotate-180',
)}
/>
</button>
</AccordionPrimitive.Trigger>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Delete ${currentServerName}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
</div>
</div>
</div>
</div>
</div>
</AccordionPrimitive.Header>
</div>
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
<div className="space-y-1">
{serverInfo.tools?.map((subTool) => (
<label
key={subTool.tool_id}
htmlFor={subTool.tool_id}
className={cn(
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
}}
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
onMouseLeave={() => setHoveredToolId(null)}
>
<Checkbox
id={subTool.tool_id}
checked={selectedTools.includes(subTool.tool_id)}
onCheckedChange={(_checked) => {
const newSelectedTools = selectedTools.includes(subTool.tool_id)
? selectedTools.filter((t) => t !== subTool.tool_id)
: [...selectedTools, subTool.tool_id];
updateFormTools(newSelectedTools);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
onClick={(e) => e.stopPropagation()}
className={cn(
'relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
)}
/>
<span className="text-token-text-primary select-none">
{subTool.metadata.name}
</span>
{subTool.metadata.description && (
<Ariakit.HovercardProvider placement="left-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
className={cn(
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
)}
aria-label={localize('com_ui_tool_info')}
>
<CircleHelpIcon className="h-4 w-4" />
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_info')}
</Ariakit.VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_tool_more_info')}
aria-expanded={hoveredToolId === subTool.tool_id}
aria-controls={`tool-description-${subTool.tool_id}`}
>
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_more_info')}
</Ariakit.VisuallyHidden>
<ChevronDown className="h-4 w-4" />
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
id={`tool-description-${subTool.tool_id}`}
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
portal={true}
unmountOnHide={true}
role="tooltip"
aria-label={subTool.metadata.description}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{subTool.metadata.description}
</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(currentServerName),
selectClasses:
'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'),
}}
/>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</OGDialog>
);
}

View file

@ -0,0 +1,71 @@
import React from 'react';
import UninitializedMCPTool from './UninitializedMCPTool';
import UnconfiguredMCPTool from './UnconfiguredMCPTool';
import { useAgentPanelContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import MCPTool from './MCPTool';
export default function MCPTools({
agentId,
mcpServerNames,
setShowMCPToolDialog,
}: {
agentId: string;
mcpServerNames?: string[];
setShowMCPToolDialog: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const localize = useLocalize();
const { mcpServersMap } = useAgentPanelContext();
return (
<div className="mb-4">
<label className="text-token-text-primary mb-2 block font-medium">
{localize('com_ui_mcp_servers')}
</label>
<div>
<div className="mb-1">
{/* Render servers with selected tools */}
{mcpServerNames?.map((mcpServerName) => {
const serverInfo = mcpServersMap.get(mcpServerName);
if (!serverInfo?.isConfigured) {
return (
<UnconfiguredMCPTool
key={`${mcpServerName}-${agentId}`}
serverName={mcpServerName}
/>
);
}
if (!serverInfo) {
return null;
}
if (serverInfo.isConnected) {
return (
<MCPTool key={`${serverInfo.serverName}-${agentId}`} serverInfo={serverInfo} />
);
}
return (
<UninitializedMCPTool
key={`${serverInfo.serverName}-${agentId}`}
serverInfo={serverInfo}
/>
);
})}
</div>
<div className="mt-2">
<button
type="button"
onClick={() => setShowMCPToolDialog(true)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_mcp_server_tools')}
</div>
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { CircleX } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
useToastContext,
OGDialogTrigger,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
if (!serverName) {
return null;
}
const removeTool = () => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-surface-primary-alt"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<div className="flex items-center">
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<CircleX className="h-4 w-4 text-red-500" />
</div>
</div>
<div className="flex grow cursor-not-allowed items-center gap-1 rounded bg-transparent p-0 text-left transition-colors">
<div
className="grow select-none px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{serverName}
<span className="ml-2 text-xs text-text-secondary">
{' - '}
{localize('com_ui_unavailable')}
</span>
</div>
</div>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200 hover:bg-surface-active-alt focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${serverName}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(),
selectClasses:
'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'),
}}
/>
</OGDialog>
);
}

View file

@ -0,0 +1,183 @@
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { initializeServer, isInitializing, getServerStatusIconProps, getConfigDialogProps } =
useMCPServerManager();
if (!serverInfo) {
return null;
}
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
const serverName = serverInfo.serverName;
const isServerInitializing = isInitializing(serverName);
const statusIconProps = getServerStatusIconProps(serverName);
const configDialogProps = getConfigDialogProps();
const statusIcon = statusIconProps && (
<div
onClick={(e) => {
e.stopPropagation();
}}
className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary"
>
<MCPServerStatusIcon {...statusIconProps} />
</div>
);
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-surface-primary-alt"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<div
className="flex grow cursor-pointer items-center gap-1 rounded bg-transparent p-0 text-left transition-colors"
onClick={(e) => {
if ((e.target as HTMLElement).closest('[data-status-icon]')) {
return;
}
if (!isServerInitializing) {
initializeServer(serverName);
}
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isServerInitializing) {
initializeServer(serverName);
}
}
}}
aria-disabled={isServerInitializing}
>
{statusIcon && (
<div className="flex items-center" data-status-icon>
{statusIcon}
</div>
)}
{serverInfo.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${serverInfo.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{serverName}
{isServerInitializing && (
<span className="ml-2 text-xs text-text-secondary">
{localize('com_ui_initializing')}
</span>
)}
</div>
</div>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200 hover:bg-surface-active-alt focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${serverName}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(serverName),
selectClasses:
'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'),
}}
/>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</OGDialog>
);
}

View file

@ -6,12 +6,11 @@ import { Constants, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useLocalize, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
function MCPPanelContent() {
const localize = useLocalize();
@ -19,7 +18,10 @@ function MCPPanelContent() {
const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
@ -57,11 +59,6 @@ function MCPPanelContent() {
}));
}, [startupConfig?.mcpServers]);
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
const handleServerClickToEdit = (serverName: string) => {
setSelectedServerNameForEditing(serverName);
};
@ -125,7 +122,7 @@ function MCPPanelContent() {
);
}
const serverStatus = connectionStatus[selectedServerNameForEditing];
const serverStatus = connectionStatus?.[selectedServerNameForEditing];
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
@ -170,7 +167,7 @@ function MCPPanelContent() {
<div className="h-auto max-w-full overflow-x-hidden py-2">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => {
const serverStatus = connectionStatus[server.serverName];
const serverStatus = connectionStatus?.[server.serverName];
const isConnected = serverStatus?.connectionState === 'connected';
return (

View file

@ -0,0 +1,116 @@
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
import type { AgentToolType } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
type MCPToolItemProps = {
tool: AgentToolType;
onAddTool: () => void;
onRemoveTool: () => void;
isInstalled?: boolean;
isConfiguring?: boolean;
isInitializing?: boolean;
};
function MCPToolItem({
tool,
onAddTool,
onRemoveTool,
isInstalled = false,
isConfiguring = false,
isInitializing = false,
}: MCPToolItemProps) {
const localize = useLocalize();
const handleClick = () => {
if (isInstalled) {
onRemoveTool();
} else {
onAddTool();
}
};
const name = tool.metadata?.name || tool.tool_id;
const description = tool.metadata?.description || '';
const icon = tool.metadata?.icon;
// Determine button state and text
const getButtonState = () => {
if (isInstalled) {
return {
text: localize('com_nav_tool_remove'),
icon: <XCircle className="flex h-4 w-4 items-center stroke-2" />,
className:
'btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200',
disabled: false,
};
}
if (isConfiguring) {
return {
text: localize('com_ui_confirm'),
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" />,
className: 'btn btn-primary relative',
disabled: false,
};
}
if (isInitializing) {
return {
text: localize('com_ui_initializing'),
icon: <Wrench className="flex h-4 w-4 items-center stroke-2" />,
className: 'btn btn-primary relative opacity-75 cursor-not-allowed',
disabled: true,
};
}
return {
text: localize('com_ui_add'),
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" />,
className: 'btn btn-primary relative',
disabled: false,
};
};
const buttonState = getButtonState();
return (
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
<div className="flex gap-4">
<div className="h-[70px] w-[70px] shrink-0">
<div className="relative h-full w-full">
{icon ? (
<img
src={icon}
alt={localize('com_ui_logo', { 0: name })}
className="h-full w-full rounded-[5px] bg-white"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-[5px] border border-border-medium bg-transparent">
<Wrench className="h-8 w-8 text-text-secondary" />
</div>
)}
<div className="absolute inset-0 rounded-[5px] ring-1 ring-inset ring-black/10"></div>
</div>
</div>
<div className="flex min-w-0 flex-col items-start justify-between">
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
{name}
</div>
<button
className={buttonState.className}
aria-label={`${buttonState.text} ${name}`}
onClick={handleClick}
disabled={buttonState.disabled}
>
<div className="flex w-full items-center justify-center gap-2">
{buttonState.text}
{buttonState.icon}
</div>
</button>
</div>
</div>
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
</div>
);
}
export default MCPToolItem;

View file

@ -0,0 +1,370 @@
import { useEffect, useState, useMemo } from 'react';
import { Search, X } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TError, AgentToolType } from 'librechat-data-provider';
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
import { useLocalize, usePluginDialogHelpers, useMCPServerManager } from '~/hooks';
import { useGetStartupConfig, useAvailableToolsQuery } from '~/data-provider';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { PluginPagination } from '~/components/Plugins/Store';
import { useAgentPanelContext } from '~/Providers';
import MCPToolItem from './MCPToolItem';
function MCPToolSelectDialog({
isOpen,
agentId,
setIsOpen,
mcpServerNames,
}: TPluginStoreDialogProps & {
agentId: string;
mcpServerNames?: string[];
endpoint: EModelEndpoint.agents;
}) {
const localize = useLocalize();
const { mcpServersMap } = useAgentPanelContext();
const { initializeServer } = useMCPServerManager();
const { data: startupConfig } = useGetStartupConfig();
const { getValues, setValue } = useFormContext<AgentForm>();
const { refetch: refetchAvailableTools } = useAvailableToolsQuery(EModelEndpoint.agents);
const [isInitializing, setIsInitializing] = useState<string | null>(null);
const [configuringServer, setConfiguringServer] = useState<string | null>(null);
const {
maxPage,
setMaxPage,
currentPage,
setCurrentPage,
itemsPerPage,
searchChanged,
setSearchChanged,
searchValue,
setSearchValue,
gridRef,
handleSearch,
handleChangePage,
error,
setError,
errorMessage,
setErrorMessage,
} = usePluginDialogHelpers();
const updateUserPlugins = useUpdateUserPluginsMutation();
const handleInstallError = (error: TError) => {
setError(true);
const errorMessage = error.response?.data?.message ?? '';
if (errorMessage) {
setErrorMessage(errorMessage);
}
setTimeout(() => {
setError(false);
setErrorMessage('');
}, 5000);
};
const handleDirectAdd = async (serverName: string) => {
try {
setIsInitializing(serverName);
const serverInfo = mcpServersMap.get(serverName);
if (!serverInfo?.isConnected) {
const result = await initializeServer(serverName);
if (result?.success && result.oauthRequired && result.oauthUrl) {
setIsInitializing(null);
return;
}
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'install',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
handleInstallError(error as TError);
setIsInitializing(null);
},
onSuccess: async () => {
const { data: updatedAvailableTools } = await refetchAvailableTools();
const currentTools = getValues('tools') || [];
const toolsToAdd: string[] = [
`${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`,
];
if (updatedAvailableTools) {
updatedAvailableTools.forEach((tool) => {
if (tool.pluginKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) {
toolsToAdd.push(tool.pluginKey);
}
});
}
const newTools = toolsToAdd.filter((tool) => !currentTools.includes(tool));
if (newTools.length > 0) {
setValue('tools', [...currentTools, ...newTools]);
}
setIsInitializing(null);
},
},
);
} catch (error) {
console.error('Error adding MCP server:', error);
}
};
const handleSaveCustomVars = async (serverName: string, authData: Record<string, string>) => {
try {
await updateUserPlugins.mutateAsync({
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'install',
auth: authData,
isEntityTool: true,
});
await handleDirectAdd(serverName);
setConfiguringServer(null);
} catch (error) {
console.error('Error saving custom vars:', error);
}
};
const handleRevokeCustomVars = (serverName: string) => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => {
setConfiguringServer(null);
},
},
);
};
const onAddTool = async (serverName: string) => {
if (configuringServer === serverName) {
setConfiguringServer(null);
await handleDirectAdd(serverName);
return;
}
const serverConfig = startupConfig?.mcpServers?.[serverName];
const hasCustomUserVars =
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
if (hasCustomUserVars) {
setConfiguringServer(serverName);
} else {
await handleDirectAdd(serverName);
}
};
const onRemoveTool = (serverName: string) => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => {
const currentTools = getValues('tools') || [];
const remainingTools = currentTools.filter(
(tool) =>
tool !== serverName && !tool.endsWith(`${Constants.mcp_delimiter}${serverName}`),
);
setValue('tools', remainingTools);
},
},
);
};
const installedToolsSet = useMemo(() => {
return new Set(mcpServerNames);
}, [mcpServerNames]);
const mcpServers = useMemo(() => {
const servers = Array.from(mcpServersMap.values());
return servers.sort((a, b) => a.serverName.localeCompare(b.serverName));
}, [mcpServersMap]);
const filteredServers = useMemo(() => {
if (!searchValue) {
return mcpServers;
}
return mcpServers.filter((serverInfo) =>
serverInfo.serverName.toLowerCase().includes(searchValue.toLowerCase()),
);
}, [mcpServers, searchValue]);
useEffect(() => {
setMaxPage(Math.ceil(filteredServers.length / itemsPerPage));
if (searchChanged) {
setCurrentPage(1);
setSearchChanged(false);
}
}, [
setMaxPage,
itemsPerPage,
searchChanged,
setCurrentPage,
setSearchChanged,
filteredServers.length,
]);
return (
<Dialog
open={isOpen}
onClose={() => {
setIsOpen(false);
setCurrentPage(1);
setSearchValue('');
setConfiguringServer(null);
setIsInitializing(null);
}}
className="relative z-[102]"
>
<div className="fixed inset-0 bg-surface-primary opacity-60 transition-opacity dark:opacity-80" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
className="relative max-h-[90vh] w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }}
>
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
<div className="flex items-center">
<div className="text-center sm:text-left">
<DialogTitle className="text-lg font-medium leading-6 text-text-primary">
{localize('com_nav_tool_dialog_mcp_server_tools')}
</DialogTitle>
<Description className="text-sm text-text-secondary">
{localize('com_nav_tool_dialog_description')}
</Description>
</div>
</div>
<div>
<button
onClick={() => {
setIsOpen(false);
setCurrentPage(1);
setConfiguringServer(null);
setIsInitializing(null);
}}
className="inline-block rounded-full text-text-secondary transition-colors hover:text-text-primary"
aria-label="Close dialog"
type="button"
>
<X aria-hidden="true" />
</button>
</div>
</div>
{error && (
<div
className="relative m-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700"
role="alert"
>
{localize('com_nav_plugin_auth_error')} {errorMessage}
</div>
)}
{configuringServer && (
<div className="p-4 sm:p-6 sm:pt-4">
<div className="mb-4">
<p className="text-sm text-text-secondary">
{localize('com_ui_mcp_configure_server_description', { 0: configuringServer })}
</p>
</div>
<CustomUserVarsSection
serverName={configuringServer}
fields={startupConfig?.mcpServers?.[configuringServer]?.customUserVars || {}}
onSave={(authData) => handleSaveCustomVars(configuringServer, authData)}
onRevoke={() => handleRevokeCustomVars(configuringServer)}
isSubmitting={updateUserPlugins.isLoading}
/>
</div>
)}
<div className="p-4 sm:p-6 sm:pt-4">
<div className="mt-4 flex flex-col gap-4">
<div
className="flex items-center justify-center space-x-4"
onClick={() => setConfiguringServer(null)}
>
<Search className="h-6 w-6 text-text-tertiary" />
<input
type="text"
value={searchValue}
onChange={handleSearch}
placeholder={localize('com_nav_tool_search')}
className="w-64 rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary focus:outline-none"
/>
</div>
<div
ref={gridRef}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
style={{ minHeight: '410px' }}
>
{filteredServers
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((serverInfo) => {
const isInstalled = installedToolsSet.has(serverInfo.serverName);
const isConfiguring = configuringServer === serverInfo.serverName;
const isServerInitializing = isInitializing === serverInfo.serverName;
const tool: AgentToolType = {
agent_id: agentId,
tool_id: serverInfo.serverName,
metadata: {
...serverInfo.metadata,
description: `${localize('com_ui_tool_collection_prefix')} ${serverInfo.serverName}`,
},
};
return (
<MCPToolItem
tool={tool}
isInstalled={isInstalled}
key={serverInfo.serverName}
isConfiguring={isConfiguring}
isInitializing={isServerInitializing}
onAddTool={() => onAddTool(serverInfo.serverName)}
onRemoveTool={() => onRemoveTool(serverInfo.serverName)}
/>
);
})}
</div>
</div>
<div className="mt-2 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
{maxPage > 0 ? (
<PluginPagination
currentPage={currentPage}
maxPage={maxPage}
onChangePage={handleChangePage}
/>
) : (
<div style={{ height: '21px' }}></div>
)}
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
export default MCPToolSelectDialog;

View file

@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants, isAgentsEndpoint } from 'librechat-data-provider';
import { isAgentsEndpoint } from 'librechat-data-provider';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type {
@ -15,7 +15,6 @@ import type { AgentForm, TPluginStoreDialogProps } from '~/common';
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
import { useAvailableToolsQuery } from '~/data-provider';
import ToolItem from './ToolItem';
function ToolSelectDialog({
@ -26,10 +25,9 @@ function ToolSelectDialog({
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
}) {
const localize = useLocalize();
const { getValues, setValue } = useFormContext<AgentForm>();
const { data: tools } = useAvailableToolsQuery(endpoint);
const { groupedTools } = useAgentPanelContext();
const isAgentTools = isAgentsEndpoint(endpoint);
const { getValues, setValue } = useFormContext<AgentForm>();
const { groupedTools, pluginTools } = useAgentPanelContext();
const {
maxPage,
@ -121,38 +119,28 @@ function ToolSelectDialog({
const onAddTool = (pluginKey: string) => {
setShowPluginAuthForm(false);
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(getAvailablePluginFromKey);
const availablePluginFromKey = pluginTools?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(availablePluginFromKey);
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter);
if (isMCPTool) {
// MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect),
// so we directly proceed to install without showing the auth form.
handleInstall({ pluginKey, action: 'install', auth: {} });
const { authConfig, authenticated = false } = availablePluginFromKey ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
} else {
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
} else {
handleInstall({
pluginKey,
action: 'install',
auth: {},
});
}
handleInstall({
pluginKey,
action: 'install',
auth: {},
});
}
};
const filteredTools = Object.values(groupedTools || {}).filter(
(tool: AgentToolType & { tools?: AgentToolType[] }) => {
// Check if the parent tool matches
if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
(currentTool: AgentToolType & { tools?: AgentToolType[] }) => {
if (currentTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
// Check if any child tools match
if (tool.tools) {
return tool.tools.some((childTool) =>
if (currentTool.tools) {
return currentTool.tools.some((childTool) =>
childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()),
);
}
@ -169,9 +157,9 @@ function ToolSelectDialog({
}
}
}, [
tools,
itemsPerPage,
pluginTools,
searchValue,
itemsPerPage,
filteredTools,
searchChanged,
setMaxPage,

View file

@ -1,2 +1,3 @@
export { default as MCPToolSelectDialog } from './MCPToolSelectDialog';
export { default as ToolSelectDialog } from './ToolSelectDialog';
export { default as ToolItem } from './ToolItem';

View file

@ -1,3 +1,5 @@
export * from './useMCPSelect';
export * from './useGetMCPTools';
export * from './useMCPConnectionStatus';
export * from './useMCPSelect';
export * from './useVisibleTools';
export { useMCPServerManager } from './useMCPServerManager';

View file

@ -0,0 +1,11 @@
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
export function useMCPConnectionStatus({ enabled }: { enabled?: boolean } = {}) {
const { data } = useMCPConnectionStatusQuery({
enabled,
});
return {
connectionStatus: data?.connectionStatus,
};
}

View file

@ -9,8 +9,7 @@ import {
} from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/common';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import { useLocalize, useMCPSelect, useGetMCPTools } from '~/hooks';
import { useLocalize, useMCPSelect, useGetMCPTools, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
interface ServerState {
@ -21,7 +20,7 @@ interface ServerState {
pollInterval: NodeJS.Timeout | null;
}
export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) {
export function useMCPServerManager({ conversationId }: { conversationId?: string | null } = {}) {
const localize = useLocalize();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
@ -83,13 +82,9 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return initialStates;
});
const { data: connectionStatusData } = useMCPConnectionStatusQuery({
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
/** Filter disconnected servers when values change, but only after initial load
This prevents clearing selections on page refresh when servers haven't connected yet
@ -97,7 +92,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const hasInitialLoadCompleted = useRef(false);
useEffect(() => {
if (!connectionStatusData || Object.keys(connectionStatus).length === 0) {
if (!connectionStatus || Object.keys(connectionStatus).length === 0) {
return;
}
@ -115,7 +110,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
if (connectedSelected.length !== mcpValues.length) {
setMCPValues(connectedSelected);
}
}, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]);
}, [connectionStatus, mcpValues, setMCPValues]);
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
setServerStates((prev) => {
@ -229,46 +224,46 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const initializeServer = useCallback(
async (serverName: string, autoOpenOAuth: boolean = true) => {
updateServerState(serverName, { isInitializing: true });
try {
const response = await reinitializeMutation.mutateAsync(serverName);
if (response.success) {
if (response.oauthRequired && response.oauthUrl) {
updateServerState(serverName, {
oauthUrl: response.oauthUrl,
oauthStartTime: Date.now(),
isCancellable: true,
isInitializing: true,
});
if (autoOpenOAuth) {
window.open(response.oauthUrl, '_blank', 'noopener,noreferrer');
}
startServerPolling(serverName);
} else {
await queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]);
showToast({
message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
status: 'success',
});
const currentValues = mcpValues ?? [];
if (!currentValues.includes(serverName)) {
setMCPValues([...currentValues, serverName]);
}
cleanupServerState(serverName);
}
} else {
if (!response.success) {
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
cleanupServerState(serverName);
return response;
}
if (response.oauthRequired && response.oauthUrl) {
updateServerState(serverName, {
oauthUrl: response.oauthUrl,
oauthStartTime: Date.now(),
isCancellable: true,
isInitializing: true,
});
if (autoOpenOAuth) {
window.open(response.oauthUrl, '_blank', 'noopener,noreferrer');
}
startServerPolling(serverName);
} else {
await queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
showToast({
message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
status: 'success',
});
const currentValues = mcpValues ?? [];
if (!currentValues.includes(serverName)) {
setMCPValues([...currentValues, serverName]);
}
cleanupServerState(serverName);
}
return response;
} catch (error) {
console.error(`[MCP Manager] Failed to initialize ${serverName}:`, error);
showToast({
@ -351,7 +346,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return;
}
const serverStatus = connectionStatus[serverName];
const serverStatus = connectionStatus?.[serverName];
if (serverStatus?.connectionState === 'connected') {
connectedServers.push(serverName);
} else {
@ -381,7 +376,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const filteredValues = currentValues.filter((name) => name !== serverName);
setMCPValues(filteredValues);
} else {
const serverStatus = connectionStatus[serverName];
const serverStatus = connectionStatus?.[serverName];
if (serverStatus?.connectionState === 'connected') {
setMCPValues([...currentValues, serverName]);
} else {
@ -455,7 +450,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const getServerStatusIconProps = useCallback(
(serverName: string) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName);
const serverStatus = connectionStatus[serverName];
const serverStatus = connectionStatus?.[serverName];
const serverConfig = startupConfig?.mcpServers?.[serverName];
const handleConfigClick = (e: React.MouseEvent) => {
@ -532,7 +527,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return {
serverName: selectedToolForConfig.name,
serverStatus: connectionStatus[selectedToolForConfig.name],
serverStatus: connectionStatus?.[selectedToolForConfig.name],
isOpen: isConfigModalOpen,
onOpenChange: handleDialogOpenChange,
fieldsSchema,
@ -553,7 +548,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return {
configuredServers,
connectionStatus,
initializeServer,
cancelOAuthFlow,
isInitializing,

View file

@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { Constants } from 'librechat-data-provider';
import type { AgentToolType } from 'librechat-data-provider';
import type { MCPServerInfo } from '~/common';
type GroupedToolType = AgentToolType & { tools?: AgentToolType[] };
type GroupedToolsRecord = Record<string, GroupedToolType>;
interface VisibleToolsResult {
toolIds: string[];
mcpServerNames: string[];
}
/**
* Custom hook to calculate visible tool IDs based on selected tools and their parent groups.
* If any subtool of a group is selected, the parent group tool is also made visible.
*
* @param selectedToolIds - Array of selected tool IDs
* @param allTools - Record of all available tools
* @param mcpServersMap - Map of all MCP servers
* @returns Object containing separate arrays of visible tool IDs for regular and MCP tools
*/
export function useVisibleTools(
selectedToolIds: string[] | undefined,
allTools: GroupedToolsRecord | undefined,
mcpServersMap: Map<string, MCPServerInfo>,
): VisibleToolsResult {
return useMemo(() => {
const mcpServers = new Set<string>();
const selectedSet = new Set<string>();
const regularToolIds = new Set<string>();
for (const toolId of selectedToolIds ?? []) {
if (!toolId.includes(Constants.mcp_delimiter)) {
selectedSet.add(toolId);
continue;
}
const serverName = toolId.split(Constants.mcp_delimiter)[1];
if (!serverName) {
continue;
}
mcpServers.add(serverName);
}
if (allTools) {
for (const [toolId, toolObj] of Object.entries(allTools)) {
if (selectedSet.has(toolId)) {
regularToolIds.add(toolId);
}
if (toolObj.tools?.length) {
for (const subtool of toolObj.tools) {
if (selectedSet.has(subtool.tool_id)) {
regularToolIds.add(toolId);
break;
}
}
}
}
}
if (mcpServersMap) {
for (const [mcpServerName] of mcpServersMap) {
if (mcpServers.has(mcpServerName)) {
continue;
}
/** Legacy check */
if (selectedSet.has(mcpServerName)) {
mcpServers.add(mcpServerName);
}
}
}
return {
toolIds: Array.from(regularToolIds).sort((a, b) => a.localeCompare(b)),
mcpServerNames: Array.from(mcpServers).sort((a, b) => a.localeCompare(b)),
};
}, [allTools, mcpServersMap, selectedToolIds]);
}

View file

@ -104,6 +104,7 @@
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
"com_assistants_add_actions": "Add Actions",
"com_assistants_add_tools": "Add Tools",
"com_assistants_add_mcp_server_tools": "Add MCP Server Tools",
"com_assistants_allow_sites_you_trust": "Only allow sites you trust.",
"com_assistants_append_date": "Append Current Date & Time",
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
@ -579,7 +580,8 @@
"com_nav_theme_system": "System",
"com_nav_tool_dialog": "Assistant Tools",
"com_nav_tool_dialog_agents": "Agent Tools",
"com_nav_tool_dialog_description": "Assistant must be saved to persist tool selections.",
"com_nav_tool_dialog_mcp_server_tools": "MCP Server Tools",
"com_nav_tool_dialog_description": "Agent must be saved to persist tool selections.",
"com_nav_tool_remove": "Remove",
"com_nav_tool_search": "Search tools",
"com_nav_user": "USER",
@ -769,6 +771,7 @@
"com_ui_confirm_action": "Confirm Action",
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
"com_ui_confirm_change": "Confirm Change",
"com_ui_confirm": "Confirm",
"com_ui_connecting": "Connecting",
"com_ui_context": "Context",
"com_ui_continue": "Continue",
@ -830,6 +833,8 @@
"com_ui_delete_success": "Successfully deleted",
"com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
"com_ui_delete_tool_error": "Error while deleting the tool: {{error}}",
"com_ui_delete_tool_success": "Tool deleted successfully",
"com_ui_deleted": "Deleted",
"com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc",
@ -947,6 +952,7 @@
"com_ui_image_gen": "Image Gen",
"com_ui_import": "Import",
"com_ui_import_conversation_error": "There was an error importing your conversations",
"com_ui_initializing": "Initializing...",
"com_ui_import_conversation_file_type_error": "Unsupported import type",
"com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully",
@ -1202,6 +1208,7 @@
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_error": "Failed to unarchive conversation",
"com_ui_unknown": "Unknown",
"com_ui_unavailable": "Unavailable",
"com_ui_unset": "Unset",
"com_ui_untitled": "Untitled",
"com_ui_update": "Update",
@ -1265,5 +1272,7 @@
"com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_ui_mcp_configure_server": "Configure {{0}}",
"com_ui_mcp_configure_server_description": "Configure custom variables for {{0}}",
"com_user_message": "You"
}

View file

@ -6,11 +6,11 @@ import { cn } from '~/utils';
import './Tooltip.css';
interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string;
side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
role?: string;
className?: string;
description: string;
enableHTML?: boolean;
side?: 'top' | 'bottom' | 'left' | 'right';
}
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(

View file

@ -1559,8 +1559,13 @@ export enum Constants {
mcp_delimiter = '_mcp_',
/** Prefix for MCP plugins */
mcp_prefix = 'mcp_',
/** Unique value to indicate all MCP servers */
/** Unique value to indicate all MCP servers. For backend use only. */
mcp_all = 'sys__all__sys',
/**
* Unique value to indicate the MCP tool was added to an agent.
* This helps inform the UI if the mcp server was previously added.
* */
mcp_server = 'sys__server__sys',
/** Placeholder Agent ID for Ephemeral Agents */
EPHEMERAL_AGENT_ID = 'ephemeral',
}

View file

@ -335,7 +335,7 @@ export type ActionMetadataRuntime = ActionMetadata & {
export type MCP = {
mcp_id: string;
metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string });
export type MCPMetadata = Omit<ActionMetadata, 'auth'> & {
name?: string;
@ -352,6 +352,6 @@ export type MCPAuth = ActionAuth;
export type AgentToolType = {
tool_id: string;
metadata: ToolMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string });
export type ToolMetadata = TPlugin;