diff --git a/client/src/components/MCP/McpOAuthDialog.tsx b/client/src/components/MCP/McpOAuthDialog.tsx new file mode 100644 index 0000000000..3b0ec6e192 --- /dev/null +++ b/client/src/components/MCP/McpOAuthDialog.tsx @@ -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 ( + + + + {localize('com_nav_mcp_connect_server', { 0: serverName })} + + + {localize('com_ui_mcp_oauth_qr_code_description')} + + +
+ + +
+
+ {oauthUrl} +
+ + + {isCopying ? localize('com_ui_link_copied') : ''} + +
+ +
+
+ +
+ + {localize('com_ui_mcp_oauth_scan_qr')} + +
+ + {canCancel && ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx b/client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx new file mode 100644 index 0000000000..bff910ba04 --- /dev/null +++ b/client/src/components/MCP/__tests__/McpOAuthDialog.spec.tsx @@ -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(); + 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(); + 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(); + expect(screen.queryByText('com_ui_cancel')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText('com_ui_cancel')).toBeInTheDocument(); + }); + + test('renders nothing without an OAuth URL', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx index 8d514cfc4c..187421184e 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx @@ -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 ? ( +
+ {oauthUrl} +
+ ) : 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(); + expect(screen.getByText('com_nav_mcp_connect_server')).toBeInTheDocument(); + }); + + test('clicking Connect initializes the server without auto-opening OAuth', () => { + mockInitializeServer.mockResolvedValue({ success: true }); + render(); + 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(); + 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(); + 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, diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx index 0ac6e15322..22b49e33fc 100644 --- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx @@ -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(); - const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); + const { + getServerStatusIconProps, + getConfigDialogProps, + initializeServer, + getOAuthUrl, + isCancellable, + cancelOAuthFlow, + } = useMCPServerManager(); + const [oauthOpen, setOauthOpen] = useState(false); + const [oauthUrl, setOauthUrl] = useState(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 (
@@ -141,9 +183,22 @@ export default function McpSection({ item }: Props) { {localize(statusDisplay.labelKey)}
- {statusIconProps && } + {isConnected && statusIconProps && } + {!isConnected && ( + + )} +
@@ -272,6 +327,18 @@ export default function McpSection({ item }: Props) {
{configDialogProps && } + { + cancelOAuthFlow(serverName); + setOauthOpen(false); + setOauthUrl(null); + }} + />
); } diff --git a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx index fb02de2d1c..cbd0ce5d49 100644 --- a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx @@ -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('marketplace'); const [search, setSearch] = useState(''); + const [category, setCategory] = useState('all'); + const [createOpen, setCreateOpen] = useState(false); const [detailItem, setDetailItem] = useState(null); const catalog = useMemo( @@ -83,9 +95,39 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial [skillsData, user?.id], ); + const categoryOptions = useMemo(() => { + const seen = new Set(); + 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: , + }); + } + 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 ( @@ -120,9 +164,24 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial
- - {localize('com_ui_skills')} - +
+ + {localize('com_ui_skills')} + + {hasCreateAccess && ( + + )} +
@@ -139,13 +198,17 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial className="h-[42px] bg-transparent pl-9" />
+ 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
setDetailItem(null)} /> + ); diff --git a/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx b/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx index c0026d30c1..4bf619df65 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolCard.tsx @@ -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 (
)} - {item.kind === 'action' && item.endpointCount > 0 && ( -
- - {localize( - item.endpointCount === 1 - ? 'com_ui_tools_endpoint_count_one' - : 'com_ui_tools_endpoint_count', - { count: item.endpointCount }, - )} - + {(item.kind === 'action' && item.endpointCount > 0) || isPublicSkill || isSharedSkill ? ( +
+ {item.kind === 'action' && item.endpointCount > 0 && ( + + {localize( + item.endpointCount === 1 + ? 'com_ui_tools_endpoint_count_one' + : 'com_ui_tools_endpoint_count', + { count: item.endpointCount }, + )} + + )} + {isSharedSkill && skill && ( + + + )} + {isPublicSkill && ( + + + )}
- )} + ) : null} - {canConfigure && ( + {(canConfigure || showInfoOnly) && ( )}
diff --git a/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx b/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx index 803fb3ab68..2327b4bc30 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx @@ -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, ], ); diff --git a/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx b/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx index 15d72ac3d3..1f6d5bcc72 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx @@ -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, ], ); diff --git a/client/src/components/SidePanel/Agents/Tools/__tests__/MarketplaceCatalog.spec.tsx b/client/src/components/SidePanel/Agents/Tools/__tests__/MarketplaceCatalog.spec.tsx index c53531c000..f2ba35325b 100644 --- a/client/src/components/SidePanel/Agents/Tools/__tests__/MarketplaceCatalog.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/__tests__/MarketplaceCatalog.spec.tsx @@ -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[] = [ diff --git a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolCard.spec.tsx b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolCard.spec.tsx index 1707cce631..74eb0be9ad 100644 --- a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolCard.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolCard.spec.tsx @@ -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(); 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(); + 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(); + expect(screen.queryByText('Me')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsMarketplaceDialog.spec.tsx b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsMarketplaceDialog.spec.tsx index 71a80b7155..24a89de7a8 100644 --- a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsMarketplaceDialog.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsMarketplaceDialog.spec.tsx @@ -60,6 +60,7 @@ jest.mock('react-router-dom', () => ({ jest.mock('../hooks', () => ({ useBuiltinAuthMap: () => new Map(), useShowMemory: () => false, + useWebSearchUserProvided: () => false, useUninstallToolCredentials: () => jest.fn(), })); diff --git a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx index 2177893b77..a5f3add5c0 100644 --- a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx @@ -39,6 +39,7 @@ jest.mock('~/hooks/MCP', () => ({ jest.mock('../hooks', () => ({ useBuiltinAuthMap: () => new Map(), useShowMemory: () => false, + useWebSearchUserProvided: () => false, useUninstallToolCredentials: () => jest.fn(), })); diff --git a/client/src/components/SidePanel/Agents/Tools/hooks.ts b/client/src/components/SidePanel/Agents/Tools/hooks.ts index f62a6fa050..c0713e713f 100644 --- a/client/src/components/SidePanel/Agents/Tools/hooks.ts +++ b/client/src/components/SidePanel/Agents/Tools/hooks.ts @@ -27,6 +27,21 @@ export function useBuiltinAuthMap(): Map { }, [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, diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts index 6a2d984f62..05e4272a97 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/catalog.spec.ts @@ -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: [] }); diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts new file mode 100644 index 0000000000..aa12b7c3d9 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/configurable.spec.ts @@ -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 = {}): 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); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts index 6a66f8e2dc..f0f8b6f4b2 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/catalog.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/catalog.ts @@ -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, }); } diff --git a/client/src/components/SidePanel/Agents/Tools/items/configurable.ts b/client/src/components/SidePanel/Agents/Tools/items/configurable.ts index 7eb938def5..07bbdfecb6 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/configurable.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/configurable.ts @@ -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': diff --git a/client/src/components/SidePanel/Agents/Tools/items/types.ts b/client/src/components/SidePanel/Agents/Tools/items/types.ts index 09975ffac4..9252be0305 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/types.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/types.ts @@ -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 { diff --git a/client/src/components/Skills/dialogs/CreateSkillDialog.tsx b/client/src/components/Skills/dialogs/CreateSkillDialog.tsx index ed8041423d..3b03c29ad8 100644 --- a/client/src/components/Skills/dialogs/CreateSkillDialog.tsx +++ b/client/src/components/Skills/dialogs/CreateSkillDialog.tsx @@ -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) => { diff --git a/client/src/components/Tools/MCPToolItem.tsx b/client/src/components/Tools/MCPToolItem.tsx deleted file mode 100644 index 7e9012e8ce..0000000000 --- a/client/src/components/Tools/MCPToolItem.tsx +++ /dev/null @@ -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: