feat: refine agent tools picker (skills, MCP connect/OAuth, web search)

- Skills picker: per-card visibility (public) and shared-author badges,
  category filtering, and an in-place Create skill flow that auto-attaches
  the new skill without leaving the builder
- MCP: inline Connect button in the first dialog plus a dedicated OAuth
  dialog (continue, copyable URL, QR code) shown only when OAuth is required
- Web search: auth-aware affordance, settings cog when user-provided and an
  info icon when system-defined
- Remove orphaned com_ui_unavailable/com_ui_initializing keys and the dead
  Tools/MCPToolItem component
This commit is contained in:
Marco Beretta 2026-06-30 17:59:29 +02:00
parent 71eb21d80c
commit 6c53a83a9e
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
21 changed files with 656 additions and 153 deletions

View file

@ -0,0 +1,119 @@
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogDescription,
} from '@librechat/client';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { cn } from '~/utils';
interface McpOAuthDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName: string;
oauthUrl: string;
canCancel: boolean;
onCancel: () => void;
}
/**
* Dedicated second dialog, opened ONLY when connecting an MCP server requires
* OAuth. Offers three ways to complete the flow: continue in this browser, copy
* the authorization URL to open elsewhere, or scan a QR code to open it on a
* phone. Auto-closes once the server connects (the caller derives `open` from
* connection state).
*/
export default function McpOAuthDialog({
open,
onOpenChange,
serverName,
oauthUrl,
canCancel,
onCancel,
}: McpOAuthDialogProps) {
const localize = useLocalize();
const [isCopying, setIsCopying] = useState(false);
const copyUrl = useCopyToClipboard({ text: oauthUrl });
if (!oauthUrl) {
return null;
}
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-md overflow-hidden rounded-2xl">
<OGDialogTitle className="text-base font-semibold text-text-primary">
{localize('com_nav_mcp_connect_server', { 0: serverName })}
</OGDialogTitle>
<OGDialogDescription className="text-sm text-text-secondary">
{localize('com_ui_mcp_oauth_qr_code_description')}
</OGDialogDescription>
<div className="flex flex-col gap-4 p-1">
<Button
type="button"
variant="submit"
className="w-full"
onClick={() => window.open(oauthUrl, '_blank', 'noopener,noreferrer')}
>
{localize('com_ui_continue_oauth')}
</Button>
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
<div
className="min-w-0 flex-1 break-all text-xs text-text-secondary"
data-testid="mcp-oauth-url"
>
{oauthUrl}
</div>
<Button
type="button"
size="sm"
variant="outline"
aria-label={localize('com_ui_copy_link')}
onClick={() => {
if (!isCopying) {
copyUrl(setIsCopying);
}
}}
className={cn('shrink-0', isCopying && 'cursor-default')}
>
{isCopying ? (
<CopyCheck className="size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</Button>
<span className="sr-only" role="status" aria-live="polite">
{isCopying ? localize('com_ui_link_copied') : ''}
</span>
</div>
<div className="flex flex-col items-center gap-2">
<div className="rounded-2xl bg-white p-4 shadow-lg">
<QRCodeSVG
value={oauthUrl}
size={180}
marginSize={2}
title={localize('com_ui_mcp_oauth_qr_code_description')}
/>
</div>
<span className="text-xs text-text-secondary">
{localize('com_ui_mcp_oauth_scan_qr')}
</span>
</div>
{canCancel && (
<Button type="button" variant="outline" className="w-full" onClick={onCancel}>
{localize('com_ui_cancel')}
</Button>
)}
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -0,0 +1,71 @@
import type { ReactNode } from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render, screen, fireEvent } from '@testing-library/react';
import McpOAuthDialog from '../McpOAuthDialog';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useCopyToClipboard: () => jest.fn(),
}));
jest.mock('@librechat/client', () => {
const React = jest.requireActual('react');
const Pass = ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children);
return {
OGDialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? React.createElement('div', null, children) : null,
OGDialogContent: Pass,
OGDialogTitle: Pass,
OGDialogDescription: Pass,
Button: ({
children,
onClick,
'aria-label': ariaLabel,
}: {
children?: ReactNode;
onClick?: () => void;
'aria-label'?: string;
}) =>
React.createElement('button', { type: 'button', onClick, 'aria-label': ariaLabel }, children),
};
});
const baseProps = {
open: true,
onOpenChange: jest.fn(),
serverName: 'srv',
oauthUrl: 'https://oauth.example/authorize?x=1',
canCancel: false,
onCancel: jest.fn(),
};
describe('McpOAuthDialog', () => {
test('renders the continue button, the copyable URL, and a QR code', () => {
render(<McpOAuthDialog {...baseProps} />);
expect(screen.getByText('com_ui_continue_oauth')).toBeInTheDocument();
expect(screen.getByTestId('mcp-oauth-url')).toHaveTextContent(baseProps.oauthUrl);
expect(screen.getByText('com_ui_mcp_oauth_scan_qr')).toBeInTheDocument();
expect(document.querySelector('svg')).toBeInTheDocument();
});
test('Continue opens the OAuth URL in a new tab', () => {
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
render(<McpOAuthDialog {...baseProps} />);
fireEvent.click(screen.getByText('com_ui_continue_oauth'));
expect(openSpy).toHaveBeenCalledWith(baseProps.oauthUrl, '_blank', 'noopener,noreferrer');
openSpy.mockRestore();
});
test('shows a Cancel action only when cancellable', () => {
const { rerender } = render(<McpOAuthDialog {...baseProps} canCancel={false} />);
expect(screen.queryByText('com_ui_cancel')).not.toBeInTheDocument();
rerender(<McpOAuthDialog {...baseProps} canCancel={true} />);
expect(screen.getByText('com_ui_cancel')).toBeInTheDocument();
});
test('renders nothing without an OAuth URL', () => {
const { container } = render(<McpOAuthDialog {...baseProps} oauthUrl="" />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -5,6 +5,7 @@ import McpSection from '../sections/McpSection';
const mockSetValue = jest.fn();
const mockGetValues = jest.fn((): string[] => []);
const mockInitializeServer = jest.fn();
jest.mock('react-hook-form', () => ({
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
@ -17,6 +18,7 @@ jest.mock('~/Providers', () => ({
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useCopyToClipboard: () => jest.fn(),
useAgentCapabilities: () => ({
deferredToolsEnabled: false,
programmaticToolsEnabled: false,
@ -25,6 +27,10 @@ jest.mock('~/hooks', () => ({
useMCPServerManager: () => ({
getServerStatusIconProps: () => null,
getConfigDialogProps: () => null,
initializeServer: mockInitializeServer,
getOAuthUrl: () => undefined,
isCancellable: () => false,
cancelOAuthFlow: jest.fn(),
}),
useMCPToolOptions: () => ({
isToolDeferred: () => false,
@ -68,6 +74,24 @@ jest.mock('~/components/MCP/MCPServerStatusIcon', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('~/components/MCP/McpOAuthDialog', () => ({
__esModule: true,
default: ({
open,
oauthUrl,
onCancel,
}: {
open: boolean;
oauthUrl: string;
onCancel: () => void;
}) =>
open ? (
<div data-testid="oauth-dialog">
{oauthUrl}
<button type="button" data-testid="oauth-cancel" onClick={onCancel} />
</div>
) : null,
}));
jest.mock('@librechat/client', () => {
const React = jest.requireActual('react');
@ -114,6 +138,7 @@ describe('McpSection', () => {
beforeEach(() => {
mockSetValue.mockClear();
mockGetValues.mockReturnValue([]);
mockInitializeServer.mockReset();
});
test('renders one row per tool', () => {
@ -160,6 +185,44 @@ describe('McpSection', () => {
expect(screen.getByTestId('tool-mcp:srv:b')).toHaveAttribute('aria-pressed', 'false');
});
test('renders an inline Connect button when the server is not connected', () => {
render(<McpSection item={item} />);
expect(screen.getByText('com_nav_mcp_connect_server')).toBeInTheDocument();
});
test('clicking Connect initializes the server without auto-opening OAuth', () => {
mockInitializeServer.mockResolvedValue({ success: true });
render(<McpSection item={item} />);
fireEvent.click(screen.getByText('com_nav_mcp_connect_server'));
expect(mockInitializeServer).toHaveBeenCalledWith('srv', false);
});
test('opens the OAuth dialog only when initialize reports oauthRequired', async () => {
mockInitializeServer.mockResolvedValue({
success: true,
oauthRequired: true,
oauthUrl: 'https://oauth.example/authorize?x=1',
});
render(<McpSection item={item} />);
fireEvent.click(screen.getByText('com_nav_mcp_connect_server'));
expect(await screen.findByTestId('oauth-dialog')).toHaveTextContent(
'https://oauth.example/authorize?x=1',
);
});
test('cancelling the OAuth flow closes the dialog', async () => {
mockInitializeServer.mockResolvedValue({
success: true,
oauthRequired: true,
oauthUrl: 'https://oauth.example/authorize?x=1',
});
render(<McpSection item={item} />);
fireEvent.click(screen.getByText('com_nav_mcp_connect_server'));
await screen.findByTestId('oauth-dialog');
fireEvent.click(screen.getByTestId('oauth-cancel'));
expect(screen.queryByTestId('oauth-dialog')).not.toBeInTheDocument();
});
test('shows empty hint when the server exposes no tools', () => {
const empty: McpItem = {
...item,

View file

@ -1,7 +1,9 @@
import { useState } from 'react';
import { Clock, Code2 } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useFormContext, useWatch } from 'react-hook-form';
import { Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
import { Button, Spinner, Checkbox, Skeleton, TooltipAnchor } from '@librechat/client';
import type { MouseEvent } from 'react';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { McpItem } from '../../items/types';
import type { AgentForm } from '~/common';
@ -13,6 +15,7 @@ import {
} from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import McpOAuthDialog from '~/components/MCP/McpOAuthDialog';
import { useAgentPanelContext } from '~/Providers';
import MCPToolItem from '../../../MCPToolItem';
import { useLocalize } from '~/hooks';
@ -56,7 +59,17 @@ interface Props {
export default function McpSection({ item }: Props) {
const localize = useLocalize();
const { control, getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
const {
getServerStatusIconProps,
getConfigDialogProps,
initializeServer,
getOAuthUrl,
isCancellable,
cancelOAuthFlow,
} = useMCPServerManager();
const [oauthOpen, setOauthOpen] = useState(false);
const [oauthUrl, setOauthUrl] = useState<string | null>(null);
const [prevConnected, setPrevConnected] = useState(false);
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
const { agentsConfig } = useGetAgentsConfig();
const { deferredToolsEnabled, programmaticToolsEnabled } = useAgentCapabilities(
@ -124,6 +137,35 @@ export default function McpSection({ item }: Props) {
* both cases instead of a misleading "no tools" message. */
const toolsLoading =
!hasTools && (mcpToolsLoading || isInitializing || connectionState === 'connecting');
const isConnected = connectionState === 'connected';
const isBusy = isInitializing || connectionState === 'connecting';
/** Close + clear the OAuth dialog once the server connects, and don't let it
* reopen on its own if the connection later drops. No useEffect adjust state
* during render by comparing against the previous connection result. */
if (prevConnected !== isConnected) {
setPrevConnected(isConnected);
if (isConnected) {
setOauthOpen(false);
setOauthUrl(null);
}
}
/** Connect inline from this first dialog. Servers with custom user variables are
* routed to the config dialog (which sets the vars and initializes); others
* connect directly. `autoOpenOAuth=false` surfaces the URL in our OAuth dialog
* (continue / copy / QR) instead of the browser silently opening a tab. */
const handleConnect = async (e: MouseEvent) => {
if (statusIconProps != null && statusIconProps.hasCustomUserVars) {
statusIconProps.onConfigClick(e);
return;
}
const res = await initializeServer(serverName, false);
if (res?.oauthRequired && res.oauthUrl) {
setOauthUrl(res.oauthUrl);
setOauthOpen(true);
}
};
return (
<div className="flex flex-col gap-5">
@ -141,9 +183,22 @@ export default function McpSection({ item }: Props) {
{localize(statusDisplay.labelKey)}
</span>
</div>
{statusIconProps && <MCPServerStatusIcon {...statusIconProps} />}
{isConnected && statusIconProps && <MCPServerStatusIcon {...statusIconProps} />}
</div>
{!isConnected && (
<Button
type="button"
variant="submit"
className="w-full gap-2"
disabled={isBusy}
onClick={handleConnect}
>
{isBusy && <Spinner className="size-4" />}
{localize('com_nav_mcp_connect_server', { 0: serverName })}
</Button>
)}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">
@ -272,6 +327,18 @@ export default function McpSection({ item }: Props) {
</div>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
<McpOAuthDialog
open={oauthOpen && !isConnected}
onOpenChange={setOauthOpen}
serverName={serverName}
oauthUrl={oauthUrl ?? getOAuthUrl(serverName) ?? ''}
canCancel={isCancellable(serverName)}
onCancel={() => {
cancelOAuthFlow(serverName);
setOauthOpen(false);
setOauthUrl(null);
}}
/>
</div>
);
}

View file

@ -1,17 +1,20 @@
import { useMemo, useState, useCallback } from 'react';
import { Search } from 'lucide-react';
import { Plus, Search } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Radio,
Input,
Button,
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogDescription,
} from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { AgentItem } from './items/types';
import type { TSkill } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { CategoryOption } from './CategoryFilter';
import type { AgentItem } from './items/types';
import type { AgentForm } from '~/common';
import {
useListSkillsQuery,
@ -19,10 +22,13 @@ import {
useGetSkillFavoritesQuery,
} from '~/data-provider';
import { useLocalize, useHasAccess, useAuthContext } from '~/hooks';
import { CreateSkillDialog } from '~/components/Skills/dialogs';
import MarketplaceCatalog from './MarketplaceCatalog';
import ItemDialog from './ItemDialog/ItemDialog';
import { CategoryIcon } from '~/components/Prompts';
import { buildSkillItems } from './items/catalog';
import ItemDialog from './ItemDialog/ItemDialog';
import { applyFilter } from './items/filtering';
import CategoryFilter from './CategoryFilter';
import { itemKey } from './items/selectors';
interface SkillsDialogProps {
@ -48,6 +54,10 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
permissionType: PermissionTypes.SKILLS,
permission: Permissions.USE,
});
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.SKILLS,
permission: Permissions.CREATE,
});
const { data: skillsData, isLoading: isLoadingSkills } = useListSkillsQuery(
{ limit: 100 },
{ enabled: hasSkillsAccess },
@ -76,6 +86,8 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
const [view, setView] = useState<SkillView>('marketplace');
const [search, setSearch] = useState('');
const [category, setCategory] = useState<string | 'all'>('all');
const [createOpen, setCreateOpen] = useState(false);
const [detailItem, setDetailItem] = useState<AgentItem | null>(null);
const catalog = useMemo(
@ -83,9 +95,39 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
[skillsData, user?.id],
);
const categoryOptions = useMemo<CategoryOption[]>(() => {
const seen = new Set<string>();
const options: CategoryOption[] = [];
for (const item of catalog) {
if (item.kind !== 'skill') {
continue;
}
const value = item.skill.category;
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
options.push({
value,
label: value,
icon: <CategoryIcon category={value} className="size-4" />,
});
}
return options;
}, [catalog]);
const filtered = useMemo(
() => applyFilter(catalog, { search, kind: 'skill', category: 'all', view }, { favoritedIds }),
[catalog, search, view, favoritedIds],
() => applyFilter(catalog, { search, kind: 'skill', category, view }, { favoritedIds }),
[catalog, search, category, view, favoritedIds],
);
const handleSkillCreated = useCallback(
(skill: TSkill) => {
const current = (getValues('skills') ?? []) as string[];
setValue('skills', Array.from(new Set([...current, skill._id])), { shouldDirty: true });
setView('mine');
},
[getValues, setValue],
);
const handleToggle = useCallback(
@ -110,7 +152,9 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
);
const emptyKey: TranslationKeys | undefined =
!search.trim() && view === 'marketplace' ? 'com_ui_no_skills_found' : undefined;
!search.trim() && category === 'all' && view === 'marketplace'
? 'com_ui_no_skills_found'
: undefined;
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
@ -120,9 +164,24 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
</OGDialogDescription>
<div className="flex h-[80vh] max-h-[760px] flex-col">
<div className="flex flex-col gap-3 border-b border-border-light px-6 pb-4 pt-5">
<OGDialogTitle className="pr-10 text-base font-semibold text-text-primary">
{localize('com_ui_skills')}
</OGDialogTitle>
<div className="flex items-center justify-between gap-2 pr-10">
<OGDialogTitle className="text-base font-semibold text-text-primary">
{localize('com_ui_skills')}
</OGDialogTitle>
{hasCreateAccess && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCreateOpen(true)}
aria-label={localize('com_ui_create_skill')}
className="shrink-0 gap-1.5"
>
<Plus className="size-4" aria-hidden="true" />
<span className="truncate">{localize('com_ui_create_skill')}</span>
</Button>
)}
</div>
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
@ -139,13 +198,17 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
className="h-[42px] bg-transparent pl-9"
/>
</div>
<CategoryFilter options={categoryOptions} value={category} onChange={setCategory} />
<label id="skills-view-label" className="sr-only">
{localize('com_ui_skills_filter')}
</label>
<Radio
options={viewOptions}
value={view}
onChange={(value) => setView(value as SkillView)}
onChange={(value) => {
setView(value as SkillView);
setCategory('all');
}}
className="flex-shrink-0 p-1"
aria-labelledby="skills-view-label"
/>
@ -167,6 +230,11 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
</div>
</div>
<ItemDialog item={detailItem} agentId={agentId} onClose={() => setDetailItem(null)} />
<CreateSkillDialog
isOpen={createOpen}
setIsOpen={setCreateOpen}
onCreated={handleSkillCreated}
/>
</OGDialogContent>
</OGDialog>
);

View file

@ -1,10 +1,10 @@
import { memo, useState } from 'react';
import { BadgeCheck, Check, Settings } from 'lucide-react';
import { BadgeCheck, Check, Globe, Info, Settings, User } from 'lucide-react';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { AgentItem } from './items/types';
import { hasConfigurableSettings } from './items/configurable';
import { useLocalize, useAuthContext } from '~/hooks';
import { getIconForItem } from './items/icons';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface ToolCardProps {
@ -79,14 +79,24 @@ function ItemIconView({ item, size }: ItemIconProps) {
function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps) {
const localize = useLocalize();
const { name, description } = useDisplayStrings(item);
const { user } = useAuthContext();
const isNative = item.kind === 'builtin';
const kindLabel = localize(KIND_LABEL_KEYS[item.kind]);
const canConfigure = hasConfigurableSettings(item) && onConfigure !== undefined;
const skill = item.kind === 'skill' ? item.skill : undefined;
const isPublicSkill = skill?.isPublic === true;
const isSharedSkill = skill != null && skill.author !== user?.id && Boolean(skill.authorName);
const showInfoOnly =
item.kind === 'builtin' &&
item.id === 'web_search' &&
!canConfigure &&
onConfigure !== undefined;
const DetailIcon = canConfigure ? Settings : Info;
return (
<div
className={cn(
'group relative flex h-32 w-full flex-col rounded-2xl border',
'group relative flex h-32 w-full flex-col overflow-hidden rounded-2xl border',
selected
? 'border-emerald-500/60 bg-emerald-500/[0.06] shadow-sm'
: 'border-border-light bg-transparent hover:border-border-medium hover:bg-surface-tertiary hover:shadow-sm',
@ -137,27 +147,50 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
{isNative ? localize('com_ui_tools_native_short') : kindLabel}
</p>
)}
{item.kind === 'action' && item.endpointCount > 0 && (
<div className="mt-auto flex w-full items-center gap-1.5">
<span className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2 py-0.5 text-[10px] text-text-tertiary">
{localize(
item.endpointCount === 1
? 'com_ui_tools_endpoint_count_one'
: 'com_ui_tools_endpoint_count',
{ count: item.endpointCount },
)}
</span>
{(item.kind === 'action' && item.endpointCount > 0) || isPublicSkill || isSharedSkill ? (
<div className="mt-auto flex w-full flex-wrap items-center gap-1.5">
{item.kind === 'action' && item.endpointCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2 py-0.5 text-[10px] text-text-tertiary">
{localize(
item.endpointCount === 1
? 'com_ui_tools_endpoint_count_one'
: 'com_ui_tools_endpoint_count',
{ count: item.endpointCount },
)}
</span>
)}
{isSharedSkill && skill && (
<span
className="inline-flex max-w-[60%] items-center gap-1 rounded-full bg-surface-tertiary px-2 py-0.5 text-[10px] text-text-tertiary"
title={localize('com_ui_tools_shared_by', { name: skill.authorName })}
aria-label={localize('com_ui_tools_shared_by', { name: skill.authorName })}
>
<User className="size-2.5 shrink-0" aria-hidden="true" />
<span className="truncate">{skill.authorName}</span>
</span>
)}
{isPublicSkill && (
<span
className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-1.5 py-0.5 text-[10px] text-text-tertiary"
title={localize('com_ui_sr_public_skill')}
aria-label={localize('com_ui_sr_public_skill')}
>
<Globe className="size-2.5" aria-hidden="true" />
</span>
)}
</div>
)}
) : null}
</button>
{canConfigure && (
{(canConfigure || showInfoOnly) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onConfigure?.(item);
}}
aria-label={localize('com_ui_tools_configure')}
aria-label={
canConfigure ? localize('com_ui_tools_configure') : localize('com_ui_tools_info')
}
className={cn(
'absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-lg text-text-secondary',
'opacity-0 transition duration-150 hover:bg-surface-hover hover:text-text-primary',
@ -165,7 +198,7 @@ function ToolCardImpl({ item, selected, onToggle, onConfigure }: ToolCardProps)
'focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
>
<Settings className="size-4" aria-hidden="true" />
<DetailIcon className="size-4" aria-hidden="true" />
</button>
)}
</div>

View file

@ -12,7 +12,12 @@ import {
import type { AgentItem, AgentItemKind, ItemFilter } from './items/types';
import type { TranslationKeys } from '~/hooks/useLocalize';
import type { AgentForm } from '~/common';
import { useBuiltinAuthMap, useShowMemory, useUninstallToolCredentials } from './hooks';
import {
useBuiltinAuthMap,
useShowMemory,
useWebSearchUserProvided,
useUninstallToolCredentials,
} from './hooks';
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
import { deriveSelectedItems, itemKey } from './items/selectors';
import { computeToggleAction } from './items/mutations';
@ -52,6 +57,7 @@ export default function ToolsMarketplaceDialog({
});
const builtinAuthMap = useBuiltinAuthMap();
const showMemory = useShowMemory();
const webSearchUserProvided = useWebSearchUserProvided();
const uninstallToolCredentials = useUninstallToolCredentials();
const { data: favorites } = useGetFavoritesQuery();
@ -115,6 +121,7 @@ export default function ToolsMarketplaceDialog({
actions: agentActions,
permissions: { mcp: hasMcpAccess, skills: false },
showMemory,
webSearchUserProvided,
builtinAuthMap,
}),
[
@ -124,6 +131,7 @@ export default function ToolsMarketplaceDialog({
agentActions,
hasMcpAccess,
showMemory,
webSearchUserProvided,
builtinAuthMap,
],
);

View file

@ -6,7 +6,12 @@ import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-
import type { TPlugin } from 'librechat-data-provider';
import type { AgentItem } from './items/types';
import type { AgentForm } from '~/common';
import { useBuiltinAuthMap, useShowMemory, useUninstallToolCredentials } from './hooks';
import {
useBuiltinAuthMap,
useShowMemory,
useWebSearchUserProvided,
useUninstallToolCredentials,
} from './hooks';
import { useListSkillsQuery, useDeleteAgentAction } from '~/data-provider';
import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP';
import ToolsMarketplaceDialog from './ToolsMarketplaceDialog';
@ -66,6 +71,7 @@ export default function ToolsSection({ agentId }: Props) {
const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills });
const showMemory = useShowMemory();
const webSearchUserProvided = useWebSearchUserProvided();
const builtinAuthMap = useBuiltinAuthMap();
const uninstallToolCredentials = useUninstallToolCredentials();
@ -97,6 +103,7 @@ export default function ToolsSection({ agentId }: Props) {
actions: agentActions,
permissions: { mcp: hasMcpAccess, skills: showSkills },
showMemory,
webSearchUserProvided,
builtinAuthMap,
}),
[
@ -108,6 +115,7 @@ export default function ToolsSection({ agentId }: Props) {
hasMcpAccess,
showSkills,
showMemory,
webSearchUserProvided,
builtinAuthMap,
],
);

View file

@ -1,11 +1,12 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import MarketplaceCatalog from '../MarketplaceCatalog';
import type { AgentItem } from '../items/types';
import MarketplaceCatalog from '../MarketplaceCatalog';
import { itemKey } from '../items/selectors';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useAuthContext: () => ({ user: { id: 'u1' } }),
}));
const items: AgentItem[] = [

View file

@ -1,7 +1,7 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import ToolCard from '../ToolCard';
import type { AgentItem } from '../items/types';
import ToolCard from '../ToolCard';
jest.mock('~/hooks', () => ({
useLocalize:
@ -11,6 +11,7 @@ jest.mock('~/hooks', () => ({
const parts = Object.entries(options).map(([name, value]) => `${name}=${String(value)}`);
return `${key}[${parts.join(',')}]`;
},
useAuthContext: () => ({ user: { id: 'u1' } }),
}));
const skill: AgentItem = {
@ -69,4 +70,37 @@ describe('ToolCard', () => {
render(<ToolCard item={mcp} selected={false} onToggle={jest.fn()} />);
expect(screen.queryByText('com_ui_tools_count[count=14]')).not.toBeInTheDocument();
});
test('renders public and shared-author badges for another user public skill', () => {
const shared: AgentItem = {
kind: 'skill',
id: 's2',
name: 'Shared',
description: '',
iconKey: 'skill',
skill: {
_id: 's2',
name: 'Shared',
author: 'u2',
authorName: 'Alice',
isPublic: true,
} as never,
};
render(<ToolCard item={shared} selected={false} onToggle={jest.fn()} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByLabelText('com_ui_sr_public_skill')).toBeInTheDocument();
});
test('omits the author badge for the current user own skill', () => {
const own: AgentItem = {
kind: 'skill',
id: 's3',
name: 'Mine',
description: '',
iconKey: 'skill',
skill: { _id: 's3', name: 'Mine', author: 'u1', authorName: 'Me', isPublic: false } as never,
};
render(<ToolCard item={own} selected={false} onToggle={jest.fn()} />);
expect(screen.queryByText('Me')).not.toBeInTheDocument();
});
});

View file

@ -60,6 +60,7 @@ jest.mock('react-router-dom', () => ({
jest.mock('../hooks', () => ({
useBuiltinAuthMap: () => new Map(),
useShowMemory: () => false,
useWebSearchUserProvided: () => false,
useUninstallToolCredentials: () => jest.fn(),
}));

View file

@ -39,6 +39,7 @@ jest.mock('~/hooks/MCP', () => ({
jest.mock('../hooks', () => ({
useBuiltinAuthMap: () => new Map(),
useShowMemory: () => false,
useWebSearchUserProvided: () => false,
useUninstallToolCredentials: () => jest.fn(),
}));

View file

@ -27,6 +27,21 @@ export function useBuiltinAuthMap(): Map<string, boolean> {
}, [data]);
}
/**
* Whether `web_search` uses USER_PROVIDED auth (a user-managed key). When false
* the deployment uses SYSTEM_DEFINED keys, so there is nothing for the user to
* configure. Shares the `useBuiltinAuthMap` React Query key, so it adds no
* request. Threaded into `buildCatalog` so the card/row affordance is decided
* synchronously (cog vs info) without a per-row hook.
*/
export function useWebSearchUserProvided(): boolean {
const { data } = useVerifyAgentToolAuth({ toolId: Tools.web_search }, { retry: 1 });
return useMemo(
() => data?.authTypes?.some(([, authType]) => authType === AuthType.USER_PROVIDED) ?? false,
[data],
);
}
/**
* Returns a callback that revokes a tool's stored user credentials when it is
* removed from an agent, mirroring the legacy `AgentTool` removal. Without it,

View file

@ -37,6 +37,21 @@ describe('buildCatalog', () => {
expect(buildCatalog({ ...emptyInputs, showMemory: true }).find(memoryId)).toBeDefined();
});
test('flags web_search userProvidedAuth from the webSearchUserProvided input', () => {
const findWebSearch = (inputs: BuildCatalogInputs) =>
buildCatalog(inputs).find(
(i) => i.kind === 'builtin' && i.id === AgentCapabilities.web_search,
);
const base = {
...emptyInputs,
agentsConfig: { capabilities: [AgentCapabilities.web_search] },
};
const userProvided = findWebSearch({ ...base, webSearchUserProvided: true });
const systemDefined = findWebSearch({ ...base, webSearchUserProvided: false });
expect(userProvided?.kind === 'builtin' && userProvided.userProvidedAuth).toBe(true);
expect(systemDefined?.kind === 'builtin' && systemDefined.userProvidedAuth).toBe(false);
});
test('hides MCP items when the user lacks MCP permission', () => {
const map = new Map();
map.set('srv', { serverName: 'srv', isConfigured: true, tools: [] });

View file

@ -0,0 +1,82 @@
import type { AgentItem } from '../types';
import { makePlugin, makeMcpServer, makeSkill, makeAction } from 'test/itemFactories';
import { hasConfigurableSettings } from '../configurable';
const builtin = (id: string, extra: Record<string, unknown> = {}): AgentItem =>
({ kind: 'builtin', id, name: '', description: '', iconKey: id, ...extra }) as AgentItem;
describe('hasConfigurableSettings', () => {
test('artifacts, file_search, and context builtins are configurable', () => {
expect(hasConfigurableSettings(builtin('artifacts'))).toBe(true);
expect(hasConfigurableSettings(builtin('file_search'))).toBe(true);
expect(hasConfigurableSettings(builtin('context'))).toBe(true);
});
test('execute_code and memory builtins are not configurable', () => {
expect(hasConfigurableSettings(builtin('execute_code'))).toBe(false);
expect(hasConfigurableSettings(builtin('memory'))).toBe(false);
});
test('web_search is configurable only when auth is user-provided', () => {
expect(hasConfigurableSettings(builtin('web_search'))).toBe(false);
expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: false }))).toBe(false);
expect(hasConfigurableSettings(builtin('web_search', { userProvidedAuth: true }))).toBe(true);
});
test('mcp and action are always configurable; skills never are', () => {
const mcp: AgentItem = {
kind: 'mcp',
id: 'srv',
name: 'srv',
description: '',
iconKey: 'mcp',
server: makeMcpServer({ serverName: 'srv' }),
toolCount: 0,
};
const action: AgentItem = {
kind: 'action',
id: 'a1',
name: 'a1',
description: '',
iconKey: 'action',
action: makeAction({ action_id: 'a1' }),
endpointCount: 0,
};
const skill: AgentItem = {
kind: 'skill',
id: 's1',
name: 's1',
description: '',
iconKey: 'skill',
skill: makeSkill({ _id: 's1' }),
};
expect(hasConfigurableSettings(mcp)).toBe(true);
expect(hasConfigurableSettings(action)).toBe(true);
expect(hasConfigurableSettings(skill)).toBe(false);
});
test('a regular tool is configurable only when it needs auth', () => {
const noAuth: AgentItem = {
kind: 'tool',
id: 'dalle',
name: 'DALL-E',
description: '',
iconKey: 'tool',
plugin: makePlugin({ pluginKey: 'dalle' }),
};
const needsAuth: AgentItem = {
kind: 'tool',
id: 'serpapi',
name: 'SerpApi',
description: '',
iconKey: 'tool',
plugin: makePlugin({
pluginKey: 'serpapi',
authConfig: [{ authField: 'SERPAPI_API_KEY', label: 'Key', description: '' }],
authenticated: false,
}),
};
expect(hasConfigurableSettings(noAuth)).toBe(false);
expect(hasConfigurableSettings(needsAuth)).toBe(true);
});
});

View file

@ -45,6 +45,12 @@ export interface BuildCatalogInputs {
* `agentsConfig.capabilities` here.
*/
showMemory?: boolean;
/**
* Whether `web_search` uses USER_PROVIDED auth (a user-managed key). Drives the
* cog-vs-info affordance on the web_search card/row: configurable only when a
* user key exists; SYSTEM_DEFINED deployments have nothing to configure.
*/
webSearchUserProvided?: boolean;
}
interface BuiltinDef {
@ -107,6 +113,8 @@ export function buildCatalog(inputs: BuildCatalogInputs): AgentItem[] {
name: def.nameKey,
description: def.descriptionKey,
status: inputs.builtinAuthMap?.get(def.id) === true ? 'needs_setup' : undefined,
userProvidedAuth:
def.id === AgentCapabilities.web_search ? inputs.webSearchUserProvided === true : undefined,
});
}

View file

@ -10,7 +10,12 @@ import { pluginNeedsAuth } from './auth';
export function hasConfigurableSettings(item: AgentItem): boolean {
switch (item.kind) {
case 'builtin':
return item.id === 'artifacts' || item.id === 'file_search' || item.id === 'context';
return (
item.id === 'artifacts' ||
item.id === 'file_search' ||
item.id === 'context' ||
(item.id === 'web_search' && item.userProvidedAuth === true)
);
case 'tool':
return pluginNeedsAuth(item.plugin);
case 'mcp':

View file

@ -36,6 +36,12 @@ interface ItemBase {
export interface BuiltinItem extends ItemBase {
kind: 'builtin';
id: BuiltinId;
/**
* True when `web_search` auth is USER_PROVIDED (a user-managed key exists to
* configure). Undefined/false means SYSTEM_DEFINED nothing to configure, so
* the card/row shows an info icon instead of a settings cog.
*/
userProvidedAuth?: boolean;
}
export interface ToolItem extends ItemBase {

View file

@ -12,6 +12,7 @@ import {
SKILL_NAME_MAX_LENGTH,
SKILL_DESCRIPTION_MAX_LENGTH,
} from 'librechat-data-provider';
import type { TSkill } from 'librechat-data-provider';
import { useCreateSkillMutation } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -22,6 +23,12 @@ interface CreateSkillDialogProps {
defaultName?: string;
defaultDescription?: string;
defaultBody?: string;
/**
* Called with the created skill instead of navigating to its page. Lets the
* dialog be reused in-context (e.g. the agent-builder skill picker) so creating
* a skill keeps the user in place rather than routing to `/skills/:id`.
*/
onCreated?: (skill: TSkill) => void;
}
interface FormValues {
@ -40,6 +47,7 @@ export default function CreateSkillDialog({
defaultName = '',
defaultDescription = '',
defaultBody = '',
onCreated,
}: CreateSkillDialogProps) {
const localize = useLocalize();
const navigate = useNavigate();
@ -60,6 +68,10 @@ export default function CreateSkillDialog({
showToast({ status: 'success', message: localize('com_ui_skill_created') });
setIsOpen(false);
reset();
if (onCreated) {
onCreated(skill);
return;
}
navigate(`/skills/${skill._id}`);
},
onError: (error: unknown) => {

View file

@ -1,115 +0,0 @@
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" aria-hidden="true" />,
className: 'btn btn-neutral border-token-border-light relative',
disabled: false,
};
}
if (isConfiguring) {
return {
text: localize('com_ui_confirm'),
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" aria-hidden="true" />,
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" aria-hidden="true" />,
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" aria-hidden="true" />,
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

@ -1269,7 +1269,6 @@
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
"com_ui_importing": "Importing",
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
"com_ui_initializing": "Initializing...",
"com_ui_input": "Input",
"com_ui_instructions": "Instructions",
"com_ui_invalid_json": "Invalid JSON",
@ -1327,6 +1326,8 @@
"com_ui_mcp_invalid_url": "Please enter a valid URL",
"com_ui_mcp_no_description": "No description available",
"com_ui_mcp_oauth_cancelled": "OAuth login cancelled for {{0}}",
"com_ui_mcp_oauth_qr_code_description": "QR code to open the OAuth login on another device",
"com_ui_mcp_oauth_scan_qr": "Scan to open on your phone",
"com_ui_mcp_oauth_timeout": "OAuth login timed out for {{0}}",
"com_ui_mcp_programmatic": "Programmatic",
"com_ui_mcp_programmatic_all": "Mark all as programmatic",
@ -1930,6 +1931,7 @@
"com_ui_tools_search_no_results": "Nothing matched your search",
"com_ui_tools_section_count": "{{count}} enabled",
"com_ui_tools_section_title": "Tools",
"com_ui_tools_shared_by": "Shared by {{name}}",
"com_ui_tools_skills_enabled_kill_switch": "Agent skills",
"com_ui_tools_skills_enabled_kill_switch_hint": "Master switch for this agent's skills. When off, the agent ignores every skill you've selected, letting you disable them all at once without clearing your selection.",
"com_ui_tools_source_action": "Custom action",
@ -1953,7 +1955,6 @@
"com_ui_unarchive_conversation": "Unarchive conversation",
"com_ui_unarchive_error": "Failed to unarchive conversation",
"com_ui_unassigned": "Unassigned",
"com_ui_unavailable": "Unavailable",
"com_ui_unfavorite": "Remove from favorites",
"com_ui_unknown": "Unknown",
"com_ui_unknown_file_type": "Unknown file type",