feat: Configurable MCP Dropdown Placeholder (#7988)

* new env  variable for mcp label

* 🔄 refactor: Update MCPSelect placeholderText to draw from interface section of librechat.yaml rather than .env

* 🧹 chore: extract mcpServers schema for better maintainability

* 🔄 refactor: Update MCPSelect and useMCPSelect to utilize TPlugin type for better type consistency

* 🔄 refactor: Pass placeholder from startupConfig to MCPSubMenu for improved localization

* 🔄 refactor: Integrate startupConfig into BadgeRowContext and related components for enhanced configuration management

---------

Co-authored-by: mwbrandao <mariana.brandao@nos.pt>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2025-06-23 10:21:01 -07:00 committed by GitHub
parent a058963a9f
commit 2b2f7fe289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 46 additions and 21 deletions

View file

@ -41,6 +41,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,
bookmarks: interfaceConfig?.bookmarks ?? defaults.bookmarks,
memories: shouldDisableMemories ? false : (interfaceConfig?.memories ?? defaults.memories),
prompts: interfaceConfig?.prompts ?? defaults.prompts,

View file

@ -1,6 +1,7 @@
import React, { createContext, useContext } from 'react';
import { Tools, LocalStorageKeys } from 'librechat-data-provider';
import { useMCPSelect, useToolToggle, useCodeApiKeyForm, useSearchApiKeyForm } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
interface BadgeRowContextType {
conversationId?: string | null;
@ -10,6 +11,7 @@ interface BadgeRowContextType {
fileSearch: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
startupConfig: ReturnType<typeof useGetStartupConfig>['data'];
}
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
@ -28,6 +30,9 @@ interface BadgeRowProviderProps {
}
export default function BadgeRowProvider({ children, conversationId }: BadgeRowProviderProps) {
/** Startup config */
const { data: startupConfig } = useGetStartupConfig();
/** MCPSelect hook */
const mcpSelect = useMCPSelect({ conversationId });
@ -73,6 +78,7 @@ export default function BadgeRowProvider({ children, conversationId }: BadgeRowP
mcpSelect,
webSearch,
fileSearch,
startupConfig,
conversationId,
codeApiKeyForm,
codeInterpreter,

View file

@ -2,8 +2,7 @@ import React, { memo, useCallback, useState } from 'react';
import { SettingsIcon } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import type { McpServerInfo } from '~/hooks/Plugins/useMCPSelect';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
import { useToastContext, useBadgeRowContext } from '~/Providers';
import MultiSelect from '~/components/ui/MultiSelect';
@ -18,11 +17,11 @@ const getBaseMCPPluginKey = (fullPluginKey: string): string => {
function MCPSelect() {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcpSelect } = useBadgeRowContext();
const { mcpSelect, startupConfig } = useBadgeRowContext();
const { mcpValues, setMCPValues, mcpServerNames, mcpToolDetails, isPinned } = mcpSelect;
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: () => {
@ -129,6 +128,8 @@ function MCPSelect() {
return null;
}
const placeholderText =
startupConfig?.interface?.mcpServers?.placeholder || localize('com_ui_mcp_servers');
return (
<>
<MultiSelect
@ -138,7 +139,7 @@ function MCPSelect() {
defaultSelectedValues={mcpValues ?? []}
renderSelectedValues={renderSelectedValues}
renderItemContent={renderItemContent}
placeholder={localize('com_ui_mcp_servers')}
placeholder={placeholderText}
popoverClassName="min-w-fit"
className="badge-icon min-w-fit"
selectIcon={<MCPIcon className="icon-md text-text-primary" />}

View file

@ -11,6 +11,7 @@ interface MCPSubMenuProps {
mcpValues?: string[];
mcpServerNames: string[];
handleMCPToggle: (serverName: string) => void;
placeholder?: string;
}
const MCPSubMenu = ({
@ -19,6 +20,7 @@ const MCPSubMenu = ({
mcpServerNames,
setIsMCPPinned,
handleMCPToggle,
placeholder,
...props
}: MCPSubMenuProps) => {
const localize = useLocalize();
@ -38,7 +40,7 @@ const MCPSubMenu = ({
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{localize('com_ui_mcp_servers')}</span>
<span>{placeholder || localize('com_ui_mcp_servers')}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button

View file

@ -18,8 +18,15 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
const localize = useLocalize();
const isDisabled = disabled ?? false;
const [isPopoverActive, setIsPopoverActive] = useState(false);
const { webSearch, codeInterpreter, fileSearch, mcpSelect, searchApiKeyForm, codeApiKeyForm } =
useBadgeRowContext();
const {
webSearch,
mcpSelect,
fileSearch,
startupConfig,
codeApiKeyForm,
codeInterpreter,
searchApiKeyForm,
} = useBadgeRowContext();
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
codeApiKeyForm;
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
@ -89,6 +96,8 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
[mcpSelect],
);
const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder;
const dropdownItems = useMemo(() => {
const items: MenuItemProps[] = [
{
@ -246,8 +255,9 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
<MCPSubMenu
{...props}
mcpValues={mcpValues}
mcpServerNames={mcpServerNames}
isMCPPinned={isMCPPinned}
placeholder={mcpPlaceholder}
mcpServerNames={mcpServerNames}
setIsMCPPinned={setIsMCPPinned}
handleMCPToggle={handleMCPToggle}
/>
@ -262,6 +272,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
canRunCode,
isMCPPinned,
isCodePinned,
mcpPlaceholder,
mcpServerNames,
isSearchPinned,
setIsMCPPinned,

View file

@ -1,7 +1,7 @@
import { useRef, useEffect, useCallback, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys, EModelEndpoint } from 'librechat-data-provider';
import type { TPlugin, TPluginAuthConfig } from 'librechat-data-provider';
import type { TPlugin } from 'librechat-data-provider';
import { useAvailableToolsQuery } from '~/data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { ephemeralAgentByConvoId } from '~/store';
@ -24,20 +24,13 @@ interface UseMCPSelectOptions {
conversationId?: string | null;
}
export interface McpServerInfo {
name: string;
pluginKey: string;
authConfig?: TPluginAuthConfig[];
authenticated?: boolean;
}
export function useMCPSelect({ conversationId }: UseMCPSelectOptions) {
const key = conversationId ?? Constants.NEW_CONVO;
const hasSetFetched = useRef<string | null>(null);
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data: TPlugin[]) => {
const mcpToolsMap = new Map<string, McpServerInfo>();
const mcpToolsMap = new Map<string, TPlugin>();
data.forEach((tool) => {
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
if (isMCP && tool.chatMenu !== false) {
@ -109,13 +102,13 @@ export function useMCPSelect({ conversationId }: UseMCPSelectOptions) {
}, [mcpToolDetails]);
return {
isPinned,
mcpValues,
setIsPinned,
setMCPValues,
mcpServerNames,
ephemeralAgent,
mcpToolDetails,
setEphemeralAgent,
isPinned,
setIsPinned,
};
}

View file

@ -13,6 +13,9 @@ cache: true
# Custom interface configuration
interface:
customWelcome: "Welcome to LibreChat! Enjoy your experience."
# MCP Servers UI configuration
mcpServers:
placeholder: 'MCP Servers'
# Privacy policy settings
privacyPolicy:
externalUrl: 'https://librechat.ai/privacy-policy'

View file

@ -482,6 +482,12 @@ const termsOfServiceSchema = z.object({
export type TTermsOfService = z.infer<typeof termsOfServiceSchema>;
const mcpServersSchema = z.object({
placeholder: z.string().optional(),
});
export type TMcpServersConfig = z.infer<typeof mcpServersSchema>;
export const intefaceSchema = z
.object({
privacyPolicy: z
@ -492,6 +498,7 @@ export const intefaceSchema = z
.optional(),
termsOfService: termsOfServiceSchema.optional(),
customWelcome: z.string().optional(),
mcpServers: mcpServersSchema.optional(),
endpointsMenu: z.boolean().optional(),
modelSelect: z.boolean().optional(),
parameters: z.boolean().optional(),
@ -600,6 +607,7 @@ export type TStartupConfig = {
>;
}
>;
mcpPlaceholder?: string;
};
export enum OCRStrategy {

View file

@ -400,7 +400,7 @@ export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;
export const tPluginSchema = z.object({
name: z.string(),
pluginKey: z.string(),
description: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
authConfig: z.array(tPluginAuthConfigSchema).optional(),
authenticated: z.boolean().optional(),