diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 0d8d5d008f..927fc1a5bd 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -116,7 +116,10 @@ export default function AgentConfig() { type="button" onClick={() => setActivePanel(Panel.model)} title={model || undefined} - className="relative flex h-9 w-full min-w-0 items-center overflow-hidden rounded-lg border border-border-light bg-surface-secondary px-1 text-sm font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary" + className={cn( + 'relative flex h-9 w-full min-w-0 items-center overflow-hidden rounded-lg border border-border-light bg-surface-secondary text-sm font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary', + model != null && model ? 'px-1' : 'px-3', + )} >
{Icon && ( diff --git a/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx b/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx index efaaa90967..a1427b10f0 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolsMarketplaceDialog.tsx @@ -7,12 +7,13 @@ import { OGDialogTitle, OGDialogContent, OGDialogDescription, + useToastContext, } from '@librechat/client'; import type { AgentItem, AgentItemKind, ItemFilter } from './items/types'; import type { AgentForm } from '~/common'; +import { itemKey, mcpServerToken, matchesMcpServer } from './items/selectors'; import { useAgentItems, useUninstallToolCredentials } from './hooks'; import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog'; -import { itemKey, mcpServerToken } from './items/selectors'; import { computeToggleAction } from './items/mutations'; import { useLocalize, useToolFavorites } from '~/hooks'; import MarketplaceSidebar from './MarketplaceSidebar'; @@ -20,6 +21,7 @@ import MarketplaceCatalog from './MarketplaceCatalog'; import ItemDialog from './ItemDialog/ItemDialog'; import { applyFilter } from './items/filtering'; import { NEW_ACTION_ID } from './items/types'; +import { isEphemeralAgent } from '~/common'; interface ToolsMarketplaceDialogProps { open: boolean; @@ -36,6 +38,7 @@ export default function ToolsMarketplaceDialog({ agentId, }: ToolsMarketplaceDialogProps) { const localize = useLocalize(); + const { showToast } = useToastContext(); const { getValues, setValue } = useFormContext(); const uninstallToolCredentials = useUninstallToolCredentials(); @@ -56,6 +59,13 @@ export default function ToolsMarketplaceDialog({ setAddMcpOpen(true); return; } + /** Actions are persisted against the agent id, so an unsaved agent has + * nothing to attach them to — surface the save-first error instead of + * letting the editor fail on submit. */ + if (isEphemeralAgent(agentId)) { + showToast({ message: localize('com_agents_no_agent_id_error'), status: 'error' }); + return; + } setDetailItem({ kind: 'action', id: NEW_ACTION_ID, @@ -65,7 +75,7 @@ export default function ToolsMarketplaceDialog({ endpointCount: 0, }); }, - [localize], + [agentId, localize, showToast], ); const selectedIds = useMemo(() => new Set(selectedItems.map(itemKey)), [selectedItems]); @@ -123,11 +133,10 @@ export default function ToolsMarketplaceDialog({ case 'mcp-remove': { if (item.kind !== 'mcp') break; const toolIds = new Set((item.server.tools ?? []).map((t) => t.tool_id)); - const serverToken = mcpServerToken(item.id); const current = (getValues('tools') ?? []) as string[]; setValue( 'tools', - current.filter((t) => t !== serverToken && !toolIds.has(t)), + current.filter((t) => !matchesMcpServer(t, item.id) && !toolIds.has(t)), { shouldDirty: true }, ); break; diff --git a/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts b/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts index d52e23925c..44383f81a8 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/__tests__/selectors.spec.ts @@ -1,7 +1,7 @@ import { AgentCapabilities, Constants } from 'librechat-data-provider'; import type { AgentItem } from '../types'; import { makePlugin, makeSkill, makeMcpServer, makeAction } from 'test/itemFactories'; -import { deriveSelectedItems } from '../selectors'; +import { deriveSelectedItems, matchesMcpServer } from '../selectors'; const mcpServerToken = (serverName: string) => `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`; @@ -197,3 +197,23 @@ describe('deriveSelectedItems', () => { expect(kindOrder.indexOf('tool')).toBeLessThan(kindOrder.indexOf('skill')); }); }); + +describe('matchesMcpServer', () => { + test('matches every persisted token format for the server', () => { + expect(matchesMcpServer(`${Constants.mcp_server}${Constants.mcp_delimiter}srv`, 'srv')).toBe( + true, + ); + expect(matchesMcpServer('srv', 'srv')).toBe(true); + expect(matchesMcpServer('mcp_srv', 'srv')).toBe(true); + expect(matchesMcpServer('mcp_srv_tool', 'srv')).toBe(true); + expect(matchesMcpServer('search_mcp_srv', 'srv')).toBe(true); + expect(matchesMcpServer(`sys__all__sys${Constants.mcp_delimiter}srv`, 'srv')).toBe(true); + }); + + test('does not match tokens for other servers or plain tool ids', () => { + expect(matchesMcpServer('mcp_other', 'srv')).toBe(false); + expect(matchesMcpServer('search_mcp_other', 'srv')).toBe(false); + expect(matchesMcpServer('dalle', 'srv')).toBe(false); + expect(matchesMcpServer('srv2', 'srv')).toBe(false); + }); +}); diff --git a/client/src/components/SidePanel/Agents/Tools/items/selectors.ts b/client/src/components/SidePanel/Agents/Tools/items/selectors.ts index 40a56324dc..916153a814 100644 --- a/client/src/components/SidePanel/Agents/Tools/items/selectors.ts +++ b/client/src/components/SidePanel/Agents/Tools/items/selectors.ts @@ -62,17 +62,28 @@ export function mcpServerToken(serverName: string): string { return `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`; } +/** + * Whether a form `tools` token references the given MCP server, across every + * format ever persisted: the server placeholder token, the raw server name, + * the legacy `mcp_` token, and per-tool ids in both prefix and suffix + * shapes. Selection and removal must share this predicate — anything the + * selection logic counts as attached, an explicit remove must also strip, or + * a legacy token leaves the server permanently selected. + */ +export function matchesMcpServer(token: string, serverName: string): boolean { + const prefixed = `${MCP_PREFIX}${serverName}`; + return ( + token === mcpServerToken(serverName) || + token === serverName || + token === prefixed || + token.startsWith(`${prefixed}_`) || + token.endsWith(`_${prefixed}`) + ); +} + function isMcpSelected(item: AgentItem, form: FormSelection): boolean { if (item.kind !== 'mcp') return false; - const prefixed = `${MCP_PREFIX}${item.id}`; - return form.tools.some( - (t) => - t === mcpServerToken(item.id) || - t === item.id || - t === prefixed || - t.startsWith(`${prefixed}_`) || - t.endsWith(`_${prefixed}`), - ); + return form.tools.some((t) => matchesMcpServer(t, item.id)); } function isSkillSelected(item: AgentItem, form: FormSelection): boolean {