mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🛰️ fix: Attach Request-Scoped MCP Servers (#14780)
* fix: attach request-scoped MCP servers * fix: satisfy MCP static checks * fix: format MCP runtime hint
This commit is contained in:
parent
c44d11ebf4
commit
1a3e2aebcb
26 changed files with 861 additions and 175 deletions
|
|
@ -99,6 +99,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
tools,
|
||||
isConfigured: configuredServers.has(serverName),
|
||||
isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
|
||||
requestScoped: serverConfig?.requestScoped,
|
||||
metadata,
|
||||
consumeOnly: serverConfig?.consumeOnly,
|
||||
});
|
||||
|
|
@ -130,6 +131,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
isConfigured: true,
|
||||
serverName: mcpServerName,
|
||||
isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected',
|
||||
requestScoped: serverConfig?.requestScoped,
|
||||
consumeOnly: serverConfig?.consumeOnly,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ export interface MCPServerInfo {
|
|||
tools: t.AgentToolType[];
|
||||
isConfigured: boolean;
|
||||
isConnected: boolean;
|
||||
/** True when tools can only be discovered with live chat request fields. */
|
||||
requestScoped?: boolean;
|
||||
consumeOnly?: boolean;
|
||||
metadata: t.TPlugin;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ jest.mock('@librechat/client', () => {
|
|||
'aria-label': ariaLabel,
|
||||
onChange: (e: { target: { checked: boolean } }) => onCheckedChange(e.target.checked),
|
||||
}),
|
||||
Skeleton: ({ className }: { className?: string }) => React.createElement('div', { className }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -186,6 +187,19 @@ describe('McpSection', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('selecting a current tool replaces stale catalog ids for the same server', () => {
|
||||
mockGetValues.mockReturnValue(['removed_mcp_srv', 'dalle']);
|
||||
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByTestId('tool-mcp:srv:a'));
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tools',
|
||||
['dalle', 'sys__server__sys_mcp_srv', 'mcp:srv:a'],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('select-all writes every tool id', () => {
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByLabelText('com_ui_tools_mcp_select_all'));
|
||||
|
|
@ -249,6 +263,76 @@ describe('McpSection', () => {
|
|||
expect(screen.getByText('com_ui_tools_mcp_no_tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('lets an already-connected request-scoped server attach its runtime tools', () => {
|
||||
const runtimeItem: McpItem = {
|
||||
...item,
|
||||
server: {
|
||||
...item.server,
|
||||
tools: [],
|
||||
isConnected: true,
|
||||
requestScoped: true,
|
||||
} as never,
|
||||
toolCount: 0,
|
||||
};
|
||||
|
||||
render(<McpSection item={runtimeItem} />);
|
||||
|
||||
expect(screen.getByText('com_ui_tools_mcp_runtime_tools_available')).toBeInTheDocument();
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('com_ui_tools_mcp_select_all'));
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tools',
|
||||
['sys__server__sys_mcp_srv', 'sys__all__sys_mcp_srv'],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('detaches every token for a request-scoped server while preserving unrelated tools', () => {
|
||||
mockGetValues.mockReturnValue([
|
||||
'sys__server__sys_mcp_srv',
|
||||
'sys__all__sys_mcp_srv',
|
||||
'search_mcp_srv',
|
||||
'dalle',
|
||||
]);
|
||||
const runtimeItem: McpItem = {
|
||||
...item,
|
||||
server: {
|
||||
...item.server,
|
||||
tools: [],
|
||||
isConnected: true,
|
||||
requestScoped: true,
|
||||
} as never,
|
||||
toolCount: 0,
|
||||
};
|
||||
|
||||
render(<McpSection item={runtimeItem} />);
|
||||
|
||||
expect(screen.getByText('com_ui_tools_mcp_runtime_tools')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText('com_ui_tools_mcp_deselect_all'));
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tools', ['dalle'], { shouldDirty: true });
|
||||
});
|
||||
|
||||
test('does not offer runtime attachment before a request-scoped server is connected', () => {
|
||||
const disconnectedRuntimeItem: McpItem = {
|
||||
...item,
|
||||
server: {
|
||||
...item.server,
|
||||
tools: [],
|
||||
isConnected: false,
|
||||
requestScoped: true,
|
||||
} as never,
|
||||
toolCount: 0,
|
||||
};
|
||||
|
||||
render(<McpSection item={disconnectedRuntimeItem} />);
|
||||
|
||||
expect(screen.queryByLabelText('com_ui_tools_mcp_select_all')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_tools_mcp_runtime_tools_available')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_tools_mcp_no_tools')).toBeInTheDocument();
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('deferred connect attaches the whole server via the mcp_all wildcard', async () => {
|
||||
// Request-scoped servers (runtime {{LIBRECHAT_BODY_*}} placeholders) defer
|
||||
// their connection to the next chat turn, so no tool list arrives here —
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import {
|
|||
useMCPServerManager,
|
||||
useMCPToolOptions,
|
||||
} from '~/hooks';
|
||||
import { matchesMcpServer, mcpAllToken, mcpServerToken } from '../../items/selectors';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { mcpAllToken, mcpServerToken } from '../../items/selectors';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import McpOAuthDialog from '~/components/MCP/McpOAuthDialog';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
|
|
@ -167,6 +167,17 @@ export default function McpSection({ item }: Props) {
|
|||
[serverName, serverToken, serverAllToken, mcpServersMap],
|
||||
);
|
||||
|
||||
const isServerSelection = useCallback(
|
||||
(token: string): boolean => {
|
||||
const allServerNames = Array.from(new Set([...mcpServersMap.keys(), serverName]));
|
||||
return (
|
||||
matchesMcpServer(token, serverName, allServerNames) ||
|
||||
tools.some((tool) => tool.tool_id === toCurrentToolId(token))
|
||||
);
|
||||
},
|
||||
[mcpServersMap, serverName, tools, toCurrentToolId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Migrates legacy raw-keyed `tool_options` for THIS server to the current
|
||||
* normalized ids the option toggles (defer / programmatic / background /
|
||||
|
|
@ -221,21 +232,30 @@ export default function McpSection({ item }: Props) {
|
|||
* wildcard is also stripped unless explicitly re-passed in `next`, so a
|
||||
* per-tool selection always supersedes a stale wildcard (e.g. after a server
|
||||
* stops being request-scoped and its tools become enumerable). Legacy
|
||||
* raw-keyed entries count as this server's (via `toCurrentToolId`), so a
|
||||
* selection update REPLACES them instead of letting a deselected legacy
|
||||
* tool survive every rewrite. */
|
||||
* raw-keyed and removed-tool entries count as this server's via boundary-safe
|
||||
* server matching, so a selection update REPLACES them instead of letting an
|
||||
* invisible stale tool survive every rewrite. */
|
||||
const updateFormTools = useCallback(
|
||||
(next: string[]) => {
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
const otherTools = current.filter(
|
||||
(t) =>
|
||||
t !== serverToken &&
|
||||
t !== serverAllToken &&
|
||||
!tools.some((st) => st.tool_id === toCurrentToolId(t)),
|
||||
);
|
||||
const otherTools = current.filter((tool) => !isServerSelection(tool));
|
||||
setValue('tools', [...otherTools, serverToken, ...next], { shouldDirty: true });
|
||||
},
|
||||
[getValues, setValue, serverToken, serverAllToken, tools, toCurrentToolId],
|
||||
[getValues, isServerSelection, serverToken, setValue],
|
||||
);
|
||||
|
||||
/** Request-scoped servers have no per-tool catalog outside a chat turn. Their
|
||||
* sole meaningful selection is the runtime wildcard, so clearing it detaches
|
||||
* the whole server instead of leaving behind an unusable server-only pin. */
|
||||
const toggleRuntimeTools = useCallback(
|
||||
(checked: boolean) => {
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
const otherTools = current.filter((tool) => !isServerSelection(tool));
|
||||
setValue('tools', checked ? [...otherTools, serverToken, serverAllToken] : otherTools, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
},
|
||||
[getValues, isServerSelection, serverAllToken, serverToken, setValue],
|
||||
);
|
||||
|
||||
const toggleToolSelect = (toolId: string) => {
|
||||
|
|
@ -270,7 +290,7 @@ 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 isConnected = connectionState === 'connected' || liveServer.isConnected === true;
|
||||
const isBusy = isInitializing || connectionState === 'connecting';
|
||||
|
||||
/** Close + clear the OAuth dialog once the server connects, and don't let it
|
||||
|
|
@ -296,15 +316,21 @@ export default function McpSection({ item }: Props) {
|
|||
* manager's init state (not the awaited response) also covers connects that
|
||||
* happen behind the customUserVars config dialog, which this component does
|
||||
* not await. */
|
||||
const serverDeferred = isConnectionDeferred(serverName);
|
||||
const initConnectionDeferred = isConnectionDeferred(serverName);
|
||||
const requestScoped = liveServer.requestScoped === true;
|
||||
const runtimeToolsAvailable =
|
||||
!hasTools && !toolsLoading && (isWildcardAttached || (requestScoped && isConnected));
|
||||
const runtimeToolsMessage = isWildcardAttached
|
||||
? 'com_ui_tools_mcp_runtime_tools'
|
||||
: 'com_ui_tools_mcp_runtime_tools_available';
|
||||
useEffect(() => {
|
||||
if (!autoSelectPending) {
|
||||
return;
|
||||
}
|
||||
if (serverDeferred && !hasTools) {
|
||||
if (initConnectionDeferred && !hasTools) {
|
||||
setAutoSelectPending(false);
|
||||
if (!isWildcardAttached) {
|
||||
updateFormTools([serverAllToken]);
|
||||
toggleRuntimeTools(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -315,13 +341,13 @@ export default function McpSection({ item }: Props) {
|
|||
updateFormTools(tools.map((t) => t.tool_id));
|
||||
}, [
|
||||
autoSelectPending,
|
||||
serverDeferred,
|
||||
initConnectionDeferred,
|
||||
isConnected,
|
||||
hasTools,
|
||||
tools,
|
||||
updateFormTools,
|
||||
toggleRuntimeTools,
|
||||
isWildcardAttached,
|
||||
serverAllToken,
|
||||
]);
|
||||
|
||||
/** Connect inline from this first dialog. Servers with custom user variables are
|
||||
|
|
@ -407,9 +433,9 @@ export default function McpSection({ item }: Props) {
|
|||
<span className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_ui_tools_mcp_tools_section')}
|
||||
</span>
|
||||
{hasTools && (
|
||||
{(hasTools || runtimeToolsAvailable) && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{deferredToolsEnabled && (
|
||||
{hasTools && deferredToolsEnabled && (
|
||||
<OptionToggle
|
||||
icon={Clock}
|
||||
size="md"
|
||||
|
|
@ -419,7 +445,7 @@ export default function McpSection({ item }: Props) {
|
|||
onToggle={() => toggleDeferAll(tools)}
|
||||
/>
|
||||
)}
|
||||
{programmaticToolsEnabled && (
|
||||
{hasTools && programmaticToolsEnabled && (
|
||||
<OptionToggle
|
||||
icon={Code2}
|
||||
size="md"
|
||||
|
|
@ -433,7 +459,7 @@ export default function McpSection({ item }: Props) {
|
|||
onToggle={() => toggleProgrammaticAll(tools)}
|
||||
/>
|
||||
)}
|
||||
{backgroundToolsEnabled && (
|
||||
{hasTools && backgroundToolsEnabled && (
|
||||
<OptionToggle
|
||||
icon={Zap}
|
||||
size="md"
|
||||
|
|
@ -445,7 +471,7 @@ export default function McpSection({ item }: Props) {
|
|||
onToggle={() => toggleBackgroundAll(tools)}
|
||||
/>
|
||||
)}
|
||||
{toolIntentsEnabled && (
|
||||
{hasTools && toolIntentsEnabled && (
|
||||
<OptionToggle
|
||||
icon={Captions}
|
||||
size="md"
|
||||
|
|
@ -456,25 +482,28 @@ export default function McpSection({ item }: Props) {
|
|||
onToggle={() => toggleIntentAll(intentEligibleTools)}
|
||||
/>
|
||||
)}
|
||||
{(deferredToolsEnabled ||
|
||||
programmaticToolsEnabled ||
|
||||
backgroundToolsEnabled ||
|
||||
toolIntentsEnabled) && (
|
||||
<span className="mx-1 h-4 w-px bg-border-light" aria-hidden="true" />
|
||||
)}
|
||||
{hasTools &&
|
||||
(deferredToolsEnabled ||
|
||||
programmaticToolsEnabled ||
|
||||
backgroundToolsEnabled ||
|
||||
toolIntentsEnabled) && (
|
||||
<span className="mx-1 h-4 w-px bg-border-light" aria-hidden="true" />
|
||||
)}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs text-text-secondary">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => toggleAll(checked === true)}
|
||||
checked={hasTools ? allSelected : isWildcardAttached}
|
||||
onCheckedChange={(checked) =>
|
||||
hasTools ? toggleAll(checked === true) : toggleRuntimeTools(checked === true)
|
||||
}
|
||||
aria-label={
|
||||
allSelected
|
||||
(hasTools ? allSelected : isWildcardAttached)
|
||||
? localize('com_ui_tools_mcp_deselect_all')
|
||||
: localize('com_ui_tools_mcp_select_all')
|
||||
}
|
||||
className="size-4 rounded border border-border-medium"
|
||||
/>
|
||||
<span>
|
||||
{allSelected
|
||||
{(hasTools ? allSelected : isWildcardAttached)
|
||||
? localize('com_ui_tools_mcp_deselect_all')
|
||||
: localize('com_ui_tools_mcp_select_all')}
|
||||
</span>
|
||||
|
|
@ -527,9 +556,7 @@ export default function McpSection({ item }: Props) {
|
|||
</Collapse>
|
||||
<Collapse open={!hasTools && !toolsLoading}>
|
||||
<p className="rounded-xl border border-dashed border-border-light p-3 text-center text-xs text-text-tertiary">
|
||||
{localize(
|
||||
isWildcardAttached ? 'com_ui_tools_mcp_runtime_tools' : 'com_ui_tools_mcp_no_tools',
|
||||
)}
|
||||
{localize(runtimeToolsAvailable ? runtimeToolsMessage : 'com_ui_tools_mcp_no_tools')}
|
||||
</p>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ import {
|
|||
} from '@librechat/client';
|
||||
import type { AgentItem, AgentItemKind, ItemFilter } from './items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { itemKey, mcpServerToken, matchesMcpServer, mcpServerIds } from './items/selectors';
|
||||
import {
|
||||
itemKey,
|
||||
mcpAllToken,
|
||||
mcpServerToken,
|
||||
matchesMcpServer,
|
||||
mcpServerIds,
|
||||
} from './items/selectors';
|
||||
import { useAgentItems, useUninstallToolCredentials } from './hooks';
|
||||
import AddMcpServerDialog from './ItemDialog/AddMcpServerDialog';
|
||||
import { computeToggleAction } from './items/mutations';
|
||||
|
|
@ -121,7 +127,9 @@ export default function ToolsMarketplaceDialog({
|
|||
}
|
||||
case 'mcp-add': {
|
||||
if (item.kind !== 'mcp') break;
|
||||
const toolIds = (item.server.tools ?? []).map((t) => t.tool_id);
|
||||
const toolIds = item.server.requestScoped
|
||||
? [mcpAllToken(item.id)]
|
||||
: (item.server.tools ?? []).map((t) => t.tool_id);
|
||||
const current = (getValues('tools') ?? []) as string[];
|
||||
setValue(
|
||||
'tools',
|
||||
|
|
@ -160,13 +168,19 @@ export default function ToolsMarketplaceDialog({
|
|||
setDetailItem(item);
|
||||
return;
|
||||
}
|
||||
/** An MCP server with no exposed tools yet can't be enabled in place — open
|
||||
* its dialog so it can be connected/configured first. */
|
||||
if (item.kind === 'mcp' && item.toolCount === 0) {
|
||||
const wasSelected = selectedIds.has(itemKey(item));
|
||||
/** An unselected, toolless MCP server normally needs its setup dialog.
|
||||
* A connected request-scoped server is already ready and attaches via
|
||||
* its runtime wildcard; a selected toolless server must remain removable. */
|
||||
if (
|
||||
item.kind === 'mcp' &&
|
||||
item.toolCount === 0 &&
|
||||
!wasSelected &&
|
||||
!(item.server.requestScoped === true && item.server.isConnected === true)
|
||||
) {
|
||||
setDetailItem(item);
|
||||
return;
|
||||
}
|
||||
const wasSelected = selectedIds.has(itemKey(item));
|
||||
if (!wasSelected && item.status === 'needs_setup') {
|
||||
setDetailItem(item);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { useLocalize, useHasAccess } from '~/hooks';
|
|||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { isEphemeralAgent, ESide } from '~/common';
|
||||
import ItemDialog from './ItemDialog/ItemDialog';
|
||||
import { mcpAllToken } from './items/selectors';
|
||||
import { InfoTrigger } from '../Advanced/ui';
|
||||
import { Collapse } from '~/components/ui';
|
||||
import SkillsDialog from './SkillsDialog';
|
||||
|
|
@ -51,7 +52,8 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
|
||||
const { control, getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { agentsConfig, regularTools, mcpServersMap } = useAgentPanelContext();
|
||||
const { removeTool: removeMCPTool } = useRemoveMCPTool();
|
||||
const mcpServerNames = useMemo(() => Array.from(mcpServersMap?.keys() ?? []), [mcpServersMap]);
|
||||
const { removeTool: removeMCPTool } = useRemoveMCPTool({ serverNames: mcpServerNames });
|
||||
const deleteAgentAction = useDeleteAgentAction({
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
|
|
@ -253,7 +255,9 @@ export default function ToolsSection({ agentId }: Props) {
|
|||
item.kind === 'mcp'
|
||||
? {
|
||||
...item,
|
||||
toolCount: (item.server.tools ?? []).filter((t) => enabled.has(t.tool_id)).length,
|
||||
toolCount: enabled.has(mcpAllToken(item.id))
|
||||
? (item.server.tools ?? []).length
|
||||
: (item.server.tools ?? []).filter((t) => enabled.has(t.tool_id)).length,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
|||
import ToolsMarketplaceDialog from '../ToolsMarketplaceDialog';
|
||||
|
||||
const mockSetValue = jest.fn();
|
||||
const mockGetValues = jest.fn(() => []);
|
||||
const mockGetValues = jest.fn((): string[] => []);
|
||||
let mockWatchedTools: string[] = [];
|
||||
let mockMcpServersMap = new Map<string, object>();
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({
|
||||
|
|
@ -13,7 +15,7 @@ jest.mock('react-hook-form', () => ({
|
|||
}),
|
||||
useWatch: ({ name }: { name: string }) => {
|
||||
const map: Record<string, unknown> = {
|
||||
tools: [],
|
||||
tools: mockWatchedTools,
|
||||
skills: [],
|
||||
execute_code: false,
|
||||
web_search: false,
|
||||
|
|
@ -31,7 +33,7 @@ jest.mock('~/Providers', () => ({
|
|||
useAgentPanelContext: () => ({
|
||||
agentsConfig: { capabilities: ['execute_code', 'tools'] },
|
||||
regularTools: [{ pluginKey: 'dalle', name: 'DALL-E', description: 'Images' }],
|
||||
mcpServersMap: new Map(),
|
||||
mcpServersMap: mockMcpServersMap,
|
||||
actions: [],
|
||||
}),
|
||||
}));
|
||||
|
|
@ -153,6 +155,8 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
mockSetValue.mockClear();
|
||||
mockGetValues.mockClear();
|
||||
mockGetValues.mockReturnValue([]);
|
||||
mockWatchedTools = [];
|
||||
mockMcpServersMap = new Map();
|
||||
mockToggleFavorite.mockClear();
|
||||
mockFavoriteKeys = new Set<string>();
|
||||
});
|
||||
|
|
@ -198,6 +202,94 @@ describe('ToolsMarketplaceDialog', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('clicking a connected request-scoped zero-tool server attaches its runtime wildcard', () => {
|
||||
mockMcpServersMap = new Map([
|
||||
[
|
||||
'runtime',
|
||||
{
|
||||
serverName: 'runtime',
|
||||
tools: [],
|
||||
isConfigured: true,
|
||||
isConnected: true,
|
||||
requestScoped: true,
|
||||
metadata: { name: 'runtime', pluginKey: 'runtime', description: '' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
render(<ToolsMarketplaceDialog open onOpenChange={jest.fn()} agentId="a1" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /runtime/ }));
|
||||
|
||||
expect(screen.queryByTestId('item-dialog')).not.toBeInTheDocument();
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tools',
|
||||
['sys__server__sys_mcp_runtime', 'sys__all__sys_mcp_runtime'],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking a selected zero-tool server removes all of its tokens directly', () => {
|
||||
const selectedTools = [
|
||||
'sys__server__sys_mcp_runtime',
|
||||
'sys__all__sys_mcp_runtime',
|
||||
'search_mcp_runtime',
|
||||
'dalle',
|
||||
];
|
||||
mockWatchedTools = selectedTools;
|
||||
mockGetValues.mockReturnValue(selectedTools);
|
||||
mockMcpServersMap = new Map([
|
||||
[
|
||||
'runtime',
|
||||
{
|
||||
serverName: 'runtime',
|
||||
tools: [],
|
||||
isConfigured: true,
|
||||
isConnected: true,
|
||||
requestScoped: true,
|
||||
metadata: { name: 'runtime', pluginKey: 'runtime', description: '' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
render(<ToolsMarketplaceDialog open onOpenChange={jest.fn()} agentId="a1" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /runtime/ }));
|
||||
|
||||
expect(screen.queryByTestId('item-dialog')).not.toBeInTheDocument();
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tools', ['dalle'], { shouldDirty: true });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['an ordinary connected', false, true],
|
||||
['a disconnected request-scoped', true, false],
|
||||
])(
|
||||
'clicking %s zero-tool server opens setup without changing the form',
|
||||
(_description, requestScoped, isConnected) => {
|
||||
mockMcpServersMap = new Map([
|
||||
[
|
||||
'setup-required',
|
||||
{
|
||||
serverName: 'setup-required',
|
||||
tools: [],
|
||||
isConfigured: true,
|
||||
isConnected,
|
||||
requestScoped,
|
||||
metadata: {
|
||||
name: 'setup-required',
|
||||
pluginKey: 'setup-required',
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
render(<ToolsMarketplaceDialog open onOpenChange={jest.fn()} agentId="a1" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /setup-required/ }));
|
||||
|
||||
expect(screen.getByTestId('item-dialog')).toBeInTheDocument();
|
||||
expect(mockSetValue).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test('clicking a card star toggles the favorite without selecting the tool', () => {
|
||||
render(<ToolsMarketplaceDialog open onOpenChange={jest.fn()} agentId="a1" />);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'com_ui_favorite' })[0]);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { AgentItem } from '../items/types';
|
|||
import ToolsSection from '../ToolsSection';
|
||||
|
||||
let mockSelected: AgentItem[] = [];
|
||||
let mockAgentTools: string[] = [];
|
||||
let mockFileEntries: {
|
||||
contextFiles: unknown[];
|
||||
knowledgeFiles: unknown[];
|
||||
|
|
@ -47,7 +48,7 @@ jest.mock('~/hooks/MCP', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('../hooks', () => ({
|
||||
useAgentItems: () => ({ catalog: [], selected: mockSelected, tools: [] }),
|
||||
useAgentItems: () => ({ catalog: [], selected: mockSelected, tools: mockAgentTools }),
|
||||
useResolvedSkills: (skills?: unknown[]) => skills,
|
||||
useAgentFileEntries: () => mockFileEntries,
|
||||
useUninstallToolCredentials: () => jest.fn(),
|
||||
|
|
@ -58,6 +59,7 @@ jest.mock('../ToolRow', () => ({
|
|||
default: ({ item, onRemove }: { item: AgentItem; onRemove: (item: AgentItem) => void }) => (
|
||||
<button type="button" aria-label={`remove-${item.id}`} onClick={() => onRemove(item)}>
|
||||
{item.id}
|
||||
{item.kind === 'mcp' ? <span>{item.toolCount}</span> : null}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
|
@ -123,6 +125,7 @@ const fileSearchItem: AgentItem = {
|
|||
|
||||
beforeEach(() => {
|
||||
mockSelected = [];
|
||||
mockAgentTools = [];
|
||||
mockFileEntries = { contextFiles: [], knowledgeFiles: [], codeFiles: [] };
|
||||
mockFormValues = {};
|
||||
mockSetValue.mockClear();
|
||||
|
|
@ -153,6 +156,31 @@ describe('ToolsSection', () => {
|
|||
expect(screen.getByText('com_ui_skills_empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('counts every enumerable MCP tool when the server is attached by wildcard', () => {
|
||||
mockAgentTools = ['sys__all__sys_mcp_runtime'];
|
||||
mockSelected = [
|
||||
{
|
||||
kind: 'mcp',
|
||||
id: 'runtime',
|
||||
name: 'runtime',
|
||||
description: '',
|
||||
iconKey: 'mcp',
|
||||
toolCount: 0,
|
||||
server: {
|
||||
serverName: 'runtime',
|
||||
tools: [{ tool_id: 'search_mcp_runtime' }, { tool_id: 'read_mcp_runtime' }],
|
||||
isConfigured: true,
|
||||
isConnected: true,
|
||||
metadata: { name: 'runtime', pluginKey: 'runtime', description: '' },
|
||||
} as never,
|
||||
},
|
||||
];
|
||||
|
||||
render(<ToolsSection agentId="a" />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'remove-runtime' })).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
test('opens the config dialog instead of toggling when a file-backed built-in holds files', () => {
|
||||
mockSelected = [fileSearchItem];
|
||||
mockFileEntries = { contextFiles: [], knowledgeFiles: [['f1', {}]], codeFiles: [] };
|
||||
|
|
|
|||
|
|
@ -152,6 +152,34 @@ describe('deriveSelectedItems', () => {
|
|||
expect(result.find((i) => i.kind === 'mcp')?.id).toBe('srv');
|
||||
});
|
||||
|
||||
test('an exact normalized MCP token selects only its collision owner', () => {
|
||||
const serverNames = ['foo mcp bar', 'foo_mcp_bar'];
|
||||
const catalog: AgentItem[] = [
|
||||
...sampleCatalog,
|
||||
...serverNames.map(
|
||||
(serverName): AgentItem => ({
|
||||
kind: 'mcp',
|
||||
id: serverName,
|
||||
name: serverName,
|
||||
description: '',
|
||||
iconKey: 'mcp',
|
||||
server: makeMcpServer({ serverName }),
|
||||
toolCount: 0,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
const result = deriveSelectedItems(
|
||||
{ ...emptyFormState, tools: ['mcp_foo_mcp_bar'] },
|
||||
catalog,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result.filter((item) => item.kind === 'mcp').map((item) => item.id)).toEqual([
|
||||
'foo_mcp_bar',
|
||||
]);
|
||||
});
|
||||
|
||||
test('deselect-all (empty tools) leaves no MCP server selected', () => {
|
||||
const catalog: AgentItem[] = [
|
||||
...sampleCatalog,
|
||||
|
|
@ -236,6 +264,27 @@ describe('matchesMcpServer', () => {
|
|||
expect(matchesMcpServer('search_mcp_bar', 'foo mcp bar', allServers)).toBe(false);
|
||||
});
|
||||
|
||||
test('exact MCP tokens belong only to the configured owner of a normalized name', () => {
|
||||
/** `foo mcp bar` and the literal `foo_mcp_bar` normalize to the same
|
||||
* model-facing name. An exact `mcp_foo_mcp_bar` token must follow the
|
||||
* alias registry's identity-first ownership instead of selecting both. */
|
||||
const collidingServers = ['foo mcp bar', 'foo_mcp_bar'];
|
||||
expect(matchesMcpServer('mcp_foo_mcp_bar', 'foo mcp bar', collidingServers)).toBe(false);
|
||||
expect(matchesMcpServer('mcp_foo_mcp_bar', 'foo_mcp_bar', collidingServers)).toBe(true);
|
||||
|
||||
/** Without an identity-name collision, the normalized exact token still
|
||||
* resolves back to its special-character raw server as before. */
|
||||
expect(matchesMcpServer('mcp_foo_mcp_bar', 'foo mcp bar', ['foo mcp bar'])).toBe(true);
|
||||
expect(matchesMcpServer('mcp_foo mcp bar', 'foo mcp bar', collidingServers)).toBe(true);
|
||||
expect(matchesMcpServer('mcp_foo mcp bar', 'foo_mcp_bar', collidingServers)).toBe(false);
|
||||
|
||||
/** If neither raw name is already normalized, the alias registry's
|
||||
* deterministic first-configured owner is the only match. */
|
||||
const aliasCollision = ['foo!', 'foo?'];
|
||||
expect(matchesMcpServer('mcp_foo', 'foo!', aliasCollision)).toBe(true);
|
||||
expect(matchesMcpServer('mcp_foo', 'foo?', aliasCollision)).toBe(false);
|
||||
});
|
||||
|
||||
test('matches normalized-spelling tool ids for a special-character server', () => {
|
||||
/** Model-facing tool ids embed `normalizeServerName(server)`, while the
|
||||
* marketplace/server cards are keyed raw — both spellings must count as
|
||||
|
|
|
|||
|
|
@ -102,6 +102,22 @@ export function matchesMcpServer(
|
|||
): boolean {
|
||||
const prefixed = `${MCP_PREFIX}${serverName}`;
|
||||
const normalized = normalizeServerName(serverName);
|
||||
const aliases = allServerNames?.length ? buildServerNameAliases(allServerNames) : undefined;
|
||||
if (aliases && allServerNames) {
|
||||
/** Exact `mcp_<server>` entries need the same single-owner resolution as
|
||||
* tool-key suffixes. A literal configured name wins over another name
|
||||
* that merely normalizes to it; otherwise the alias registry maps the
|
||||
* normalized spelling back to its raw owner. Without this early global
|
||||
* check, each colliding target could independently satisfy its own exact
|
||||
* comparison and one token would select/remove both servers. */
|
||||
if (token.startsWith(MCP_PREFIX)) {
|
||||
const exactName = token.slice(MCP_PREFIX.length);
|
||||
const exactOwner = allServerNames.includes(exactName) ? exactName : aliases.get(exactName);
|
||||
if (exactOwner != null) {
|
||||
return exactOwner === serverName;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
token === mcpServerToken(serverName) ||
|
||||
token === serverName ||
|
||||
|
|
@ -110,13 +126,12 @@ export function matchesMcpServer(
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
if (allServerNames?.length) {
|
||||
if (aliases && allServerNames) {
|
||||
/** Boundary-exact: resolve the token ONCE against every configured
|
||||
* server (longest match, both spellings) — a normalized name that
|
||||
* itself contains the delimiter (`foo mcp bar` → `foo_mcp_bar`) must
|
||||
* not ALSO suffix-match a server named `bar`, or both cards select
|
||||
* together and removing one strips the other's tool. */
|
||||
const aliases = buildServerNameAliases(allServerNames);
|
||||
const [, parsed] = splitMCPToolKey(token, [...allServerNames, ...aliases.keys()]);
|
||||
if (parsed == null) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,25 @@ describe('useRemoveMCPTool', () => {
|
|||
expect(mockShowToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not remove a longer configured server whose tool key shares the suffix', () => {
|
||||
const tools = [
|
||||
`search${Constants.mcp_delimiter}bar`,
|
||||
`search${Constants.mcp_delimiter}foo_mcp_bar`,
|
||||
];
|
||||
let next: string[] = [];
|
||||
const { result } = renderHook(() => useRemoveMCPTool({ serverNames: ['bar', 'foo mcp bar'] }), {
|
||||
wrapper: makeWrapper(tools, (value) => {
|
||||
next = value;
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeTool('bar');
|
||||
});
|
||||
|
||||
expect(next).toEqual([`search${Constants.mcp_delimiter}foo_mcp_bar`]);
|
||||
});
|
||||
|
||||
test('ignores an empty server name', () => {
|
||||
let called = false;
|
||||
const { result } = renderHook(() => useRemoveMCPTool(), {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface MCPServerDefinition {
|
|||
dbId?: string; // MongoDB ObjectId for database servers (used for permissions)
|
||||
effectivePermissions: number; // Permission bits (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
|
||||
consumeOnly?: boolean;
|
||||
/** True when chat request fields are required before the server can connect. */
|
||||
requestScoped?: boolean;
|
||||
}
|
||||
|
||||
// Poll intervals are kept local since they're timer references that can't be serialized
|
||||
|
|
@ -80,7 +82,7 @@ export function useMCPServerManager({
|
|||
const definitions: MCPServerDefinition[] = [];
|
||||
if (loadedServers) {
|
||||
for (const [serverName, metadata] of Object.entries(loadedServers)) {
|
||||
const { dbId, consumeOnly, ...config } = metadata;
|
||||
const { dbId, consumeOnly, requestScoped, ...config } = metadata;
|
||||
|
||||
// Get effective permissions from the permissions map using _id
|
||||
// Fall back to 1 (VIEW) for YAML-based servers without _id
|
||||
|
|
@ -91,6 +93,7 @@ export function useMCPServerManager({
|
|||
dbId,
|
||||
effectivePermissions,
|
||||
consumeOnly,
|
||||
requestScoped,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@ import { useLocalize } from '~/hooks';
|
|||
* Hook for removing an MCP server (and all of its tools) from the agent form.
|
||||
* Note: This only removes the tool from the form, it does not delete associated auth credentials
|
||||
*/
|
||||
export function useRemoveMCPTool(options?: { showToast?: boolean }) {
|
||||
export function useRemoveMCPTool(options?: {
|
||||
showToast?: boolean;
|
||||
serverNames?: readonly string[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const shouldShowToast = options?.showToast !== false;
|
||||
const serverNames = options?.serverNames;
|
||||
|
||||
const removeTool = useCallback(
|
||||
(serverName: string) => {
|
||||
|
|
@ -22,11 +26,14 @@ export function useRemoveMCPTool(options?: { showToast?: boolean }) {
|
|||
}
|
||||
|
||||
const currentTools = getValues('tools');
|
||||
const allServerNames = Array.from(new Set([...(serverNames ?? []), serverName]));
|
||||
/** Strip every token format the selection logic counts as this server —
|
||||
* removal lagging behind `matchesMcpServer` leaves the row permanently
|
||||
* selected with no way to clean it up. */
|
||||
const remainingToolIds =
|
||||
currentTools?.filter((currentToolId) => !matchesMcpServer(currentToolId, serverName)) || [];
|
||||
currentTools?.filter(
|
||||
(currentToolId) => !matchesMcpServer(currentToolId, serverName, allServerNames),
|
||||
) || [];
|
||||
setValue('tools', remainingToolIds, { shouldDirty: true });
|
||||
|
||||
if (shouldShowToast) {
|
||||
|
|
@ -36,7 +43,7 @@ export function useRemoveMCPTool(options?: { showToast?: boolean }) {
|
|||
});
|
||||
}
|
||||
},
|
||||
[getValues, setValue, showToast, localize, shouldShowToast],
|
||||
[getValues, setValue, showToast, localize, shouldShowToast, serverNames],
|
||||
);
|
||||
|
||||
return { removeTool };
|
||||
|
|
|
|||
|
|
@ -2103,6 +2103,7 @@
|
|||
"com_ui_tools_marketplace_search": "Search tools…",
|
||||
"com_ui_tools_mcp_deselect_all": "Deselect all",
|
||||
"com_ui_tools_mcp_no_tools": "This server has not exposed any tools yet.",
|
||||
"com_ui_tools_mcp_runtime_tools_available": "This server's tools are resolved at runtime, during chat.",
|
||||
"com_ui_tools_mcp_runtime_tools": "All of this server's tools are attached; they are resolved at runtime, during chat.",
|
||||
"com_ui_tools_mcp_select_all": "Select all",
|
||||
"com_ui_tools_mcp_status_unconfigured": "Needs configuration",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue