🛰️ 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:
Danny Avila 2026-08-12 23:43:02 -04:00 committed by GitHub
parent c44d11ebf4
commit 1a3e2aebcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 861 additions and 175 deletions

View file

@ -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,
});
}

View file

@ -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;
}

View file

@ -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 —

View file

@ -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>

View file

@ -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;

View file

@ -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,
);

View file

@ -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]);

View file

@ -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: [] };

View file

@ -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

View file

@ -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;

View file

@ -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(), {

View file

@ -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,
});
}

View file

@ -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 };

View file

@ -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",