feat: add optional sensitive flag to MCP customUserVars

Dynamic MCP credential fields all rendered as masked SecretInputs, which
also hid non-secret setup values like usernames, project keys, and URLs.

Add an optional `sensitive` flag to customUserVars and the plugin auth
config. It defaults to masked when omitted, so existing configs keep the
safe-by-default behavior; set `sensitive: false` to render a field as
plain text. The flag is display-only — values remain encrypted at rest.
This commit is contained in:
Marco Beretta 2026-05-30 22:41:00 +02:00
parent 3158e34ccd
commit 6474c8456d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
10 changed files with 143 additions and 66 deletions

View file

@ -146,6 +146,7 @@ const getMCPTools = async (req, res) => {
authField: key,
label: value.title || key,
description: value.description || '',
sensitive: value.sensitive,
}));
server.authenticated = false;
}

View file

@ -16,6 +16,8 @@ export function isEphemeralAgent(agentId: string | null | undefined): boolean {
export interface ConfigFieldDetail {
title: string;
description: string;
/** Whether the field holds a secret and should be masked (defaults to masked when omitted). */
sensitive?: boolean;
}
export type CodeBarProps = {

View file

@ -1,7 +1,7 @@
import DOMPurify from 'dompurify';
import React, { useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Button, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client';
import { Button, Input, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client';
import type { ConfigFieldDetail } from '~/common';
import { useLocalize } from '~/hooks';
@ -90,18 +90,34 @@ export default function MCPConfigDialog({
name={key}
control={control}
defaultValue={initialValues[key] || ''}
render={({ field }) => (
<SecretInput
id={key}
{...field}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
/>
)}
render={({ field }) => {
const placeholder = localize('com_ui_mcp_enter_var', { 0: details.title });
const className =
'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm';
if (details.sensitive === false) {
return (
<Input
id={key}
{...field}
type="text"
placeholder={placeholder}
className={className}
/>
);
}
return (
<SecretInput
id={key}
{...field}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
placeholder={placeholder}
className={className}
/>
);
}}
/>
{details.description && (
<p

View file

@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import DOMPurify from 'dompurify';
import { useForm, Controller } from 'react-hook-form';
import { Label, Button, SecretInput } from '@librechat/client';
import { Label, Input, Button, SecretInput } from '@librechat/client';
import type { Control, FieldErrors } from 'react-hook-form';
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
import { useLocalize } from '~/hooks';
@ -9,6 +9,8 @@ import { useLocalize } from '~/hooks';
export interface CustomUserVarConfig {
title: string;
description?: string;
/** Whether the field holds a secret and should be masked (defaults to masked when omitted). */
sensitive?: boolean;
}
interface CustomUserVarsSectionProps {
@ -83,29 +85,30 @@ function AuthField({ name, config, hasValue, control, errors, autoFocus }: AuthF
name={name}
control={control}
defaultValue=""
render={({ field }) => (
<SecretInput
id={name}
// Prevent autofill: browser DOM mutations bypass React's synthetic
// onChange, silently emptying react-hook-form state on submit.
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
/* autoFocus is generally disabled due to the fact that it can disorient users,
* but in this case, the required field would logically be immediately navigated to anyways, and the component's
* functionality emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...field}
placeholder={
hasValue
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title })
}
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"
/>
)}
render={({ field }) => {
const placeholder = hasValue
? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title });
const 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';
// Prevent autofill: browser DOM mutations bypass React's synthetic
// onChange, silently emptying react-hook-form state on submit.
const sharedProps = {
id: name,
'data-lpignore': 'true',
'data-1p-ignore': 'true',
/* autoFocus is generally disorienting, but here the required field is navigated to
* anyway, and the section emulates a modal opening where users expect focus to shift. */
autoFocus,
...field,
placeholder,
className,
};
if (config.sensitive === false) {
return <Input {...sharedProps} type="text" autoComplete="off" />;
}
return <SecretInput {...sharedProps} autoComplete="new-password" controlsOnHover />;
}}
/>
{sanitizedDescription && (
<p

View file

@ -31,4 +31,21 @@ describe('CustomUserVarsSection', () => {
expect(input).toHaveAttribute('data-lpignore', 'true');
expect(input).toHaveAttribute('data-1p-ignore', 'true');
});
it('renders non-sensitive fields as unmasked text while keeping secrets masked', () => {
render(
<CustomUserVarsSection
serverName="test-server"
fields={{
api_key: { title: 'My API Key', description: 'Your API key' },
project_key: { title: 'Project Key', description: 'Your project key', sensitive: false },
}}
onSave={jest.fn()}
onRevoke={jest.fn()}
/>,
);
expect(screen.getByLabelText(/My API Key/)).toHaveAttribute('type', 'password');
expect(screen.getByLabelText(/Project Key/)).toHaveAttribute('type', 'text');
});
});

View file

@ -40,6 +40,31 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
{authConfig.map((config: TPluginAuthConfig, i: number) => {
const authField = config.authField.split('||')[0];
const isOptional = config.optional === true;
const inputClassName =
'flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0';
const sharedProps = {
id: authField,
'aria-invalid': !!errors[authField],
'aria-describedby': `${authField}-error`,
'aria-label': config.label,
'aria-required': !isOptional,
/* autoFocus is generally disorienting, but here the required field must be navigated to
* anyway, and the form emulates a modal opening where users expect focus to shift. */
autoFocus: i === 0,
className: inputClassName,
...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
),
};
return (
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
<label
@ -50,35 +75,17 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
</label>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<SecretInput
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
id={authField}
aria-invalid={!!errors[authField]}
aria-describedby={`${authField}-error`}
aria-label={config.label}
aria-required={!isOptional}
/* autoFocus is generally disabled due to the fact that it can disorient users,
* but in this case, the required field must be navigated to anyways, and the component's functionality
* emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={i === 0}
{...register(
authField,
isOptional
? {}
: {
required: `${config.label} is required.`,
minLength: {
value: 1,
message: `${config.label} must be at least 1 character long`,
},
},
)}
className="flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
/>
{config.sensitive === false ? (
<input type="text" autoComplete="off" {...sharedProps} />
) : (
<SecretInput
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
controlsOnHover
{...sharedProps}
/>
)}
</HoverCardTrigger>
<PluginTooltip content={config.description} position="right" />
</HoverCard>

View file

@ -27,6 +27,27 @@ describe('PluginAuthForm', () => {
expect(screen.getByLabelText('Secret')).toHaveAttribute('type', 'password');
});
it('masks fields by default and renders non-sensitive fields as plain text', () => {
const mixedPlugin = {
pluginKey: 'mixed-plugin',
authConfig: [
{ authField: 'token', label: 'Token' },
{ authField: 'secret', label: 'Secret', sensitive: true },
{ authField: 'url', label: 'URL', sensitive: false },
],
};
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={mixedPlugin} onSubmit={onSubmit} />);
expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password');
expect(screen.getByLabelText('Secret')).toHaveAttribute('type', 'password');
const urlField = screen.getByLabelText('URL');
expect(urlField).toHaveAttribute('type', 'text');
expect(urlField.parentElement?.querySelector('button')).toBeNull();
});
it('calls the onSubmit function with the form data when submitted', async () => {
//@ts-ignore - dont need all props of plugin
render(<PluginAuthForm plugin={plugin} onSubmit={onSubmit} />);

View file

@ -562,6 +562,7 @@ export function useMCPServerManager({
authField: key,
label: config.title,
description: config.description,
sensitive: config.sensitive,
}))
: []),
authenticated: serverData?.authenticated ?? false,
@ -609,6 +610,7 @@ export function useMCPServerManager({
fieldsSchema[field.authField] = {
title: field.label || field.authField,
description: field.description,
sensitive: field.sensitive,
};
});
}

View file

@ -192,6 +192,12 @@ const BaseOptionsSchema = z.object({
z.object({
title: z.string(),
description: z.string(),
/**
* Whether the field holds a secret and should be masked in the UI.
* Defaults to masked when omitted; set to `false` for non-secret setup
* values (e.g. username, project key, base URL) to render as plain text.
*/
sensitive: z.boolean().optional(),
}),
)
.optional(),

View file

@ -629,6 +629,8 @@ export const tPluginAuthConfigSchema = z.object({
label: z.string(),
description: z.string(),
optional: z.boolean().optional(),
/** Whether the field holds a secret and should be masked in the UI (defaults to masked when omitted). */
sensitive: z.boolean().optional(),
});
export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;