🛰️ 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

@ -183,6 +183,24 @@ describe('getMCPServersList', () => {
expect(res.json).toHaveBeenCalledWith({});
});
it('exposes safe request-scoped metadata while redacting placeholder-bearing fields', async () => {
const reqUser = await createUser();
mockResolveAllMcpConfigs.mockResolvedValue({
runtimeServer: {
...yamlConfig,
headers: { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' },
},
});
const res = createRes();
await getMCPServersList({ user: reqUser }, res);
const payload = res.json.mock.calls[0][0];
expect(payload.runtimeServer.requestScoped).toBe(true);
expect(payload.runtimeServer.url).toBeUndefined();
expect(payload.runtimeServer.headers).toBeUndefined();
});
it('applies the capability bypass to all servers when a DB-backed server is present', async () => {
await seedManageMcpGrant();
const reqUser = await createUser(SystemRoles.ADMIN);

View file

@ -936,7 +936,7 @@ describe('MCP Routes', () => {
expect(response.headers.location).toContain(`${basePath}/oauth/success`);
});
it('should forward the merged server config so the tool cache gate sees request-scoped servers', async () => {
it('should use the merged server config to defer request-scoped post-OAuth reconnect', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
@ -959,8 +959,6 @@ describe('MCP Routes', () => {
url: 'https://override.example.com/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp',
source: 'config',
};
const fetchedTools = [{ name: 'search', inputSchema: { type: 'object' } }];
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
@ -973,12 +971,15 @@ describe('MCP Routes', () => {
const fetchOrderedToolsSnapshot = jest
.fn()
.mockResolvedValue({ tools: fetchedTools, complete: true });
.mockResolvedValue({ tools: [{ name: 'search' }], complete: true });
const mockMcpManager = createLeasedMcpManager({ fetchOrderedToolsSnapshot });
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getOAuthReconnectionManager.mockReturnValue({
const mockOAuthReconnectionManager = {
clearReconnection: jest.fn(),
});
};
require('~/config').getOAuthReconnectionManager.mockReturnValue(
mockOAuthReconnectionManager,
);
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
updateMCPServerTools.mockResolvedValue();
@ -988,17 +989,13 @@ describe('MCP Routes', () => {
expect(response.status).toBe(302);
expect(mockResolveAllMcpConfigs).toHaveBeenCalledWith('test-user-id');
expect(fetchOrderedToolsSnapshot).toHaveBeenCalledTimes(1);
expect(mockMcpManager.withUserConnectionLease).toHaveBeenCalledWith(
expect.objectContaining({ serverConfig: mergedServerConfig }),
expect.any(Function),
expect(fetchOrderedToolsSnapshot).not.toHaveBeenCalled();
expect(mockMcpManager.withUserConnectionLease).not.toHaveBeenCalled();
expect(mockOAuthReconnectionManager.clearReconnection).toHaveBeenCalledWith(
'test-user-id',
'test-server',
);
expect(updateMCPServerTools).toHaveBeenCalledWith({
userId: 'test-user-id',
serverName: 'test-server',
tools: fetchedTools,
serverConfig: mergedServerConfig,
});
expect(updateMCPServerTools).not.toHaveBeenCalled();
});
it('should resolve and forward customUserVars so header templates are substituted on first post-callback connection', async () => {
@ -1659,6 +1656,81 @@ describe('MCP Routes', () => {
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens');
});
it('defers request-scoped post-OAuth reconnect while completing the waiting tool flow', async () => {
const { logger } = require('@librechat/data-schemas');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(true),
deleteFlow: jest.fn().mockResolvedValue(true),
};
const mockFlowState = {
state: 'test-user-id:test-server',
serverName: 'test-server',
userId: 'test-user-id',
metadata: { toolFlowId: 'tool-flow-request-scoped' },
clientInfo: {},
codeVerifier: 'test-verifier',
};
const mockTokens = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
const mockMcpManager = {
withUserConnectionLease: jest.fn(),
};
const mockOAuthReconnectionManager = {
clearReconnection: jest.fn(),
};
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
mockOAuthCompletion(mockTokens);
MCPTokenStorage.storeTokens.mockResolvedValue(mockTokens);
mockResolveAllMcpConfigs.mockResolvedValue({
'test-server': {
type: 'streamable-http',
url: 'https://mcp.example.com/mcp',
source: 'yaml',
headers: {
'X-Conversation-ID': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
},
requiresOAuth: true,
},
});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
const flowId = 'test-user-id:test-server';
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({ code: 'test-auth-code', state: flowId });
expect(response.status).toBe(302);
expect(response.headers.location).toBe(
`${getBasePath()}/oauth/success?serverName=test-server`,
);
expect(MCPTokenStorage.storeTokens).toHaveBeenCalled();
expect(mockMcpManager.withUserConnectionLease).not.toHaveBeenCalled();
expect(updateMCPServerTools).not.toHaveBeenCalled();
expect(mockOAuthReconnectionManager.clearReconnection).toHaveBeenCalledWith(
'test-user-id',
'test-server',
);
expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
'tool-flow-request-scoped',
'mcp_oauth',
mockTokens,
);
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Failed to reconnect test-server after OAuth'),
expect.anything(),
);
});
it('should redirect to error page if token storage fails', async () => {
// mockRegistryInstance is defined at the top of the file
const mockFlowManager = {

View file

@ -23,6 +23,7 @@ const {
OAUTH_SESSION_COOKIE,
mcpConfig: mcpSettings,
getServerCustomUserVars,
requiresEphemeralUserConnection,
} = require('@librechat/api');
const {
createMCPServerController,
@ -476,9 +477,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
try {
const mcpManager = getMCPManager(flowState.userId);
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
if (flowState.userId !== 'system') {
const user = { id: flowState.userId };
@ -496,77 +494,90 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
);
}
/**
* Without this, getUserConnection resolves `headers`/`oauth_headers`
* customUserVars templates (e.g. `{{MY_VAR}}`) with no substitution
* data, so the literal placeholder is sent on this first post-callback
* connection attempt even though the user's value is already saved -
* surfaces upstream as a generic auth rejection from the MCP server.
* The other reconnect path (oauth/reinitialize route below) already
* resolves this the same way; this one was missing it.
*/
let userMCPAuthMap;
if (serverConfig?.customUserVars && typeof serverConfig.customUserVars === 'object') {
try {
userMCPAuthMap = await getUserMCPAuthMap({
const requestScoped = serverConfig
? requiresEphemeralUserConnection(serverConfig)
: false;
if (requestScoped) {
logger.info(
`[MCP OAuth] Deferring post-OAuth connection for request-scoped server ${serverName} until its first chat use`,
);
getOAuthReconnectionManager().clearReconnection(flowState.userId, serverName);
} else {
const mcpManager = getMCPManager(flowState.userId);
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
/**
* Without this, getUserConnection resolves `headers`/`oauth_headers`
* customUserVars templates (e.g. `{{MY_VAR}}`) with no substitution
* data, so the literal placeholder is sent on this first post-callback
* connection attempt even though the user's value is already saved -
* surfaces upstream as a generic auth rejection from the MCP server.
* The other reconnect path (oauth/reinitialize route below) already
* resolves this the same way; this one was missing it.
*/
let userMCPAuthMap;
if (serverConfig?.customUserVars && typeof serverConfig.customUserVars === 'object') {
try {
userMCPAuthMap = await getUserMCPAuthMap({
userId: flowState.userId,
servers: [serverName],
findPluginAuthsByKeys: db.findPluginAuthsByKeys,
});
} catch (error) {
logger.warn(
`[MCP OAuth] Could not resolve customUserVars for ${serverName} before reconnecting:`,
error,
);
}
}
const customUserVars = getServerCustomUserVars(userMCPAuthMap, serverName);
const { snapshot, publicationGeneration } = await mcpManager.withUserConnectionLease(
{
user,
serverName,
flowManager,
serverConfig,
customUserVars,
tokenMethods: {
findToken: db.findToken,
updateToken: db.updateToken,
createToken: db.createToken,
deleteTokens: db.deleteTokens,
},
},
async (userConnection) => {
logger.info(
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
const oauthReconnectionManager = getOAuthReconnectionManager();
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
const snapshot =
typeof userConnection.fetchOrderedToolsSnapshot === 'function'
? await userConnection.fetchOrderedToolsSnapshot()
: await userConnection.fetchToolsSnapshot();
return {
snapshot,
publicationGeneration: mcpManager.getToolPublicationGeneration?.(userConnection),
};
},
);
if (snapshot.complete) {
await updateMCPServerTools({
userId: flowState.userId,
servers: [serverName],
findPluginAuthsByKeys: db.findPluginAuthsByKeys,
serverName,
tools: snapshot.tools,
serverConfig,
publicationGeneration,
});
} catch (error) {
} else {
logger.warn(
`[MCP OAuth] Could not resolve customUserVars for ${serverName} before reconnecting:`,
error,
`[MCP OAuth] Preserving cached tools for ${serverName} because tools/list returned an incomplete snapshot`,
);
}
}
const customUserVars = getServerCustomUserVars(userMCPAuthMap, serverName);
const { snapshot, publicationGeneration } = await mcpManager.withUserConnectionLease(
{
user,
serverName,
flowManager,
serverConfig,
customUserVars,
tokenMethods: {
findToken: db.findToken,
updateToken: db.updateToken,
createToken: db.createToken,
deleteTokens: db.deleteTokens,
},
},
async (userConnection) => {
logger.info(
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
const oauthReconnectionManager = getOAuthReconnectionManager();
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
const snapshot =
typeof userConnection.fetchOrderedToolsSnapshot === 'function'
? await userConnection.fetchOrderedToolsSnapshot()
: await userConnection.fetchToolsSnapshot();
return {
snapshot,
publicationGeneration: mcpManager.getToolPublicationGeneration?.(userConnection),
};
},
);
if (snapshot.complete) {
await updateMCPServerTools({
userId: flowState.userId,
serverName,
tools: snapshot.tools,
serverConfig,
publicationGeneration,
});
} else {
logger.warn(
`[MCP OAuth] Preserving cached tools for ${serverName} because tools/list returned an incomplete snapshot`,
);
}
} else {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}

View file

@ -847,7 +847,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const config = {
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/{{LIBRECHAT_BODY_TENANT}}/mcp',
url: 'https://mcp.example.com/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp',
};
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({

View file

@ -1075,7 +1075,7 @@ async function loadToolDefinitionsWrapper({
connectionTimeout: Time.TWO_MINUTES,
});
if (result?.availableTools) {
if (result?.availableTools && Object.keys(result.availableTools).length > 0) {
rememberMCPAvailableTools(serverName, result.availableTools);
logger.info(`[Tool Definitions] OAuth completed for ${serverName}, tools available`);
return { serverName, success: true };

View file

@ -635,6 +635,49 @@ describe('ToolService - Action Capability Gating', () => {
]);
});
it('does not count an empty post-OAuth catalog as tools available or reload definitions', async () => {
const req = createMockReq([AgentCapabilities.tools]);
const res = { writableEnded: false };
const serverName = 'Empty-Catalog';
const mcpTool = `search${Constants.mcp_delimiter}${serverName}`;
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig([AgentCapabilities.tools]));
mockResolveConfigServers.mockResolvedValue({
[serverName]: {
type: 'streamable-http',
url: 'https://mcp.example.com/empty',
requiresOAuth: true,
},
});
mockGetMCPServerTools.mockResolvedValue(null);
mockFlowManager.getFlowState.mockResolvedValue(null);
mockLoadToolDefinitions.mockImplementationOnce(async (params, deps) => {
await deps.getOrFetchMCPServerTools(params.userId, serverName);
return {
toolDefinitions: [],
toolRegistry: new Map(),
hasDeferredTools: false,
mcpResolution: { resolvedToolCount: 1 },
};
});
reinitMCPServer
.mockImplementationOnce(async ({ oauthStart }) => {
await oauthStart(`https://auth.example.com/${serverName}`);
return { availableTools: null };
})
.mockResolvedValueOnce({ availableTools: {} });
const result = await loadAgentTools({
req,
res,
agent: { id: 'agent_123', tools: [mcpTool] },
definitionsOnly: true,
});
expect(result.toolDefinitions).toEqual([]);
expect(result.mcpAvailableTools).toEqual({});
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
});
it('fences resumable MCP OAuth definition events to the owning job epoch', async () => {
const req = createMockReq([AgentCapabilities.tools]);
const res = { writableEnded: false };

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

View file

@ -401,6 +401,38 @@ describe('redactServerSecrets', () => {
expect(redacted.customUserVars).toEqual(config.customUserVars);
});
it('should expose request-scoped behavior without exposing placeholder-bearing fields', () => {
const config: ParsedServerConfig = {
type: 'streamable-http',
url: 'https://infra.internal/mcp',
source: 'yaml',
headers: { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' },
};
const redacted = redactServerSecrets(config);
expect(redacted.requestScoped).toBe(true);
expect(redacted.url).toBeUndefined();
expect((redacted as Record<string, unknown>).headers).toBeUndefined();
});
it('should omit request-scoped metadata for ordinary and unsupported BODY placeholders', () => {
expect(
redactServerSecrets({
type: 'streamable-http',
url: 'https://example.com/mcp',
source: 'yaml',
}).requestScoped,
).toBeUndefined();
expect(
redactServerSecrets({
type: 'streamable-http',
url: 'https://example.com/{{LIBRECHAT_BODY_TENANT}}/mcp',
source: 'yaml',
}).requestScoped,
).toBeUndefined();
});
it('should pass URLs through unchanged when caller has edit authority', () => {
const config: ParsedServerConfig = {
type: 'sse',
@ -810,6 +842,39 @@ describe('hasRuntimeBodyPlaceholders', () => {
}),
).toBe(false);
});
it('ignores unsupported BODY placeholder names that the resolver leaves literal', () => {
const config = {
source: 'yaml' as const,
url: 'https://example.com/{{LIBRECHAT_BODY_TENANT}}/mcp',
};
expect(hasRuntimeContextPlaceholders(config)).toBe(false);
expect(hasRuntimeUrlPlaceholders(config)).toBe(false);
expect(hasRuntimeBodyPlaceholders(config)).toBe(false);
expect(getRuntimeBodyPlaceholderFields(config)).toEqual([]);
expect(getMissingRuntimeBodyPlaceholderFields(config)).toEqual([]);
expect(requiresEphemeralUserConnection(config)).toBe(false);
expect(requiresUserScopedConnection(config)).toBe(false);
});
it('ignores BODY and USER literals in plugin-sourced configs', () => {
const config = {
source: 'plugin' as const,
url: 'https://example.com/users/{{LIBRECHAT_USER_ID}}/mcp',
headers: {
'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
},
};
expect(hasRuntimeContextPlaceholders(config)).toBe(false);
expect(hasRuntimeUrlPlaceholders(config)).toBe(false);
expect(hasRuntimeBodyPlaceholders(config)).toBe(false);
expect(getRuntimeBodyPlaceholderFields(config)).toEqual([]);
expect(getMissingRuntimeBodyPlaceholderFields(config)).toEqual([]);
expect(requiresEphemeralUserConnection(config)).toBe(false);
expect(requiresUserScopedConnection(config)).toBe(false);
});
});
describe('getMissingRuntimeBodyPlaceholderFields', () => {

View file

@ -1,5 +1,6 @@
import { logger, TokenMethods } from '@librechat/data-schemas';
import type { IToken } from '@librechat/data-schemas';
import type { ParsedServerConfig } from '../..';
import { OAuthReconnectionManager } from './OAuthReconnectionManager';
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
import { FlowStateManager, MCPConnection, MCPOptions } from '../..';
@ -203,6 +204,8 @@ describe('OAuthReconnectionManager', () => {
await reconnectionManager.reconnectServers(userId);
expect(mockRegistryInstance.getOAuthServers).toHaveBeenCalledWith(userId);
// Verify server3 was marked as active
expect(reconnectionTracker.isActive(userId, 'server3')).toBe(true);
@ -213,6 +216,7 @@ describe('OAuthReconnectionManager', () => {
expect(mockMCPManager.getUserConnection).toHaveBeenCalledWith({
serverName: 'server3',
user: { id: userId },
serverConfig: { initTimeout: 5000 },
flowManager,
tokenMethods,
forceNew: false,
@ -452,6 +456,71 @@ describe('OAuthReconnectionManager', () => {
expect(result).toBe(true);
});
it('should defer request-scoped reconnection without reporting success', async () => {
const userId = 'user-123';
const serverName = 'request-scoped-server';
reconnectionTracker.setFailed(userId, serverName);
reconnectionTracker.setActive(userId, serverName);
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({
type: 'streamable-http',
url: 'https://example.com/mcp',
source: 'yaml',
headers: {
'X-Conversation-ID': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
},
} as unknown as MCPOptions);
const result = await reconnectionManager.reconnectServer(userId, serverName);
expect(result).toBe(false);
expect(reconnectionTracker.isFailed(userId, serverName)).toBe(false);
expect(reconnectionTracker.isActive(userId, serverName)).toBe(false);
expect(mockMCPManager.getUserConnection).not.toHaveBeenCalled();
expect(mockMCPManager.disconnectUserConnection).not.toHaveBeenCalled();
});
it('should classify request scope from the effective config-tier overlay', async () => {
const userId = 'user-123';
const serverName = 'overlaid-server';
const configServers: Record<string, ParsedServerConfig> = {
[serverName]: {
type: 'streamable-http',
url: 'https://example.com/mcp',
source: 'config',
headers: {
'X-Conversation-ID': '{{LIBRECHAT_BODY_CONVERSATIONID}}',
},
},
};
const effectiveConfig = {
...configServers[serverName],
source: 'yaml',
} as ParsedServerConfig;
(mockRegistryInstance.getServerConfig as jest.Mock).mockImplementation(
async (_name, _userId, candidates) =>
candidates === configServers
? effectiveConfig
: ({
type: 'streamable-http',
url: 'https://example.com/mcp',
source: 'yaml',
} as MCPOptions),
);
const result = await reconnectionManager.reconnectServer(userId, serverName, configServers);
expect(result).toBe(false);
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
serverName,
userId,
configServers,
);
expect(mockMCPManager.getUserConnection).not.toHaveBeenCalled();
expect(reconnectionTracker.isFailed(userId, serverName)).toBe(false);
expect(reconnectionTracker.isActive(userId, serverName)).toBe(false);
});
it('should return false on failed reconnection', async () => {
const userId = 'user-123';
const serverName = 'server1';

View file

@ -1,13 +1,16 @@
import { logger } from '@librechat/data-schemas';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { ParsedServerConfig } from '~/mcp/types';
import type { MCPOAuthTokens } from './types';
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
import { requiresEphemeralUserConnection } from '~/mcp/utils';
import { FlowStateManager } from '~/flow/manager';
import { MCPManager } from '~/mcp/MCPManager';
const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000; // ms
const RECONNECT_STAGGER_MS = 500; // ms between each server reconnection
type ReconnectOutcome = 'connected' | 'deferred' | 'failed';
export class OAuthReconnectionManager {
private static instance: OAuthReconnectionManager | null = null;
@ -62,7 +65,14 @@ export class OAuthReconnectionManager {
return this.reconnectionsTracker.isStillReconnecting(userId, serverName);
}
public async reconnectServers(userId: string): Promise<void> {
/**
* Reconnects the user's eligible OAuth servers.
* @param configServers Tenant-scoped Config-tier candidates used to resolve effective overlays.
*/
public async reconnectServers(
userId: string,
configServers?: Record<string, ParsedServerConfig>,
): Promise<void> {
// Check if MCPManager is available
if (this.mcpManager == null) {
logger.warn(
@ -73,7 +83,7 @@ export class OAuthReconnectionManager {
// 1. derive the servers to reconnect
const serversToReconnect = [];
for (const serverName of await MCPServersRegistry.getInstance().getOAuthServers()) {
for (const serverName of await MCPServersRegistry.getInstance().getOAuthServers(userId)) {
const canReconnect = await this.canReconnect(userId, serverName);
if (canReconnect) {
serversToReconnect.push(serverName);
@ -89,9 +99,12 @@ export class OAuthReconnectionManager {
for (let i = 0; i < serversToReconnect.length; i++) {
const serverName = serversToReconnect[i];
if (i === 0) {
this.safeTryReconnect(userId, serverName);
this.safeTryReconnect(userId, serverName, configServers);
} else {
setTimeout(() => this.safeTryReconnect(userId, serverName), i * RECONNECT_STAGGER_MS);
setTimeout(
() => this.safeTryReconnect(userId, serverName, configServers),
i * RECONNECT_STAGGER_MS,
);
}
}
}
@ -104,8 +117,12 @@ export class OAuthReconnectionManager {
* `RECONNECTION_TIMEOUT_MS` window if an error escapes
* {@link tryReconnect}'s internal try/catch.
*/
private safeTryReconnect(userId: string, serverName: string): void {
this.tryReconnect(userId, serverName).catch((error) => {
private safeTryReconnect(
userId: string,
serverName: string,
configServers?: Record<string, ParsedServerConfig>,
): void {
this.tryReconnect(userId, serverName, configServers).catch((error) => {
logger.error(
`[OAuthReconnectionManager][User: ${userId}][${serverName}] Unexpected reconnect error`,
error,
@ -122,17 +139,21 @@ export class OAuthReconnectionManager {
/**
* Attempts to reconnect a single OAuth MCP server.
* @param configServers Tenant-scoped Config-tier candidates used to resolve the effective config.
* @returns true if reconnection succeeded, false otherwise.
*/
public async reconnectServer(userId: string, serverName: string): Promise<boolean> {
public async reconnectServer(
userId: string,
serverName: string,
configServers?: Record<string, ParsedServerConfig>,
): Promise<boolean> {
if (this.mcpManager == null) {
return false;
}
this.reconnectionsTracker.setActive(userId, serverName);
try {
await this.tryReconnect(userId, serverName);
return !this.reconnectionsTracker.isFailed(userId, serverName);
return (await this.tryReconnect(userId, serverName, configServers)) === 'connected';
} catch {
return false;
}
@ -143,9 +164,13 @@ export class OAuthReconnectionManager {
this.reconnectionsTracker.removeActive(userId, serverName);
}
private async tryReconnect(userId: string, serverName: string) {
private async tryReconnect(
userId: string,
serverName: string,
configServers?: Record<string, ParsedServerConfig>,
): Promise<ReconnectOutcome> {
if (this.mcpManager == null) {
return;
return 'failed';
}
const logPrefix = `[tryReconnectOAuthMCPServer][User: ${userId}][${serverName}]`;
@ -153,12 +178,22 @@ export class OAuthReconnectionManager {
logger.info(`${logPrefix} Attempting reconnection`);
try {
const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId);
const config = await MCPServersRegistry.getInstance().getServerConfig(
serverName,
userId,
configServers,
);
if (config && requiresEphemeralUserConnection(config)) {
logger.info(`${logPrefix} Deferring request-scoped connection until chat use`);
this.clearReconnection(userId, serverName);
return 'deferred';
}
// attempt to get connection (this will use existing tokens and refresh if needed)
const connection = await this.mcpManager.getUserConnection({
serverName,
user: { id: userId } as IUser,
serverConfig: config,
flowManager: this.flowManager,
tokenMethods: this.tokenMethods,
// don't force new connection, let it reuse existing or create new as needed
@ -172,14 +207,17 @@ export class OAuthReconnectionManager {
if (connection && (await connection.isConnected())) {
logger.info(`${logPrefix} Successfully reconnected`);
this.clearReconnection(userId, serverName);
return 'connected';
} else {
logger.warn(`${logPrefix} Failed to reconnect`);
await connection?.disconnect();
this.cleanupOnFailedReconnect(userId, serverName);
return 'failed';
}
} catch (error) {
logger.warn(`${logPrefix} Failed to reconnect: ${error}`);
this.cleanupOnFailedReconnect(userId, serverName);
return 'failed';
}
}

View file

@ -8,6 +8,7 @@ import {
import type { AgentToolOptions } from 'librechat-data-provider';
import type { ParsedServerConfig } from '~/mcp/types';
import type { RequestBody } from '~/types';
import { ALLOWED_BODY_FIELDS, isPluginSourced } from '~/utils/env';
export const mcpToolPattern: RegExp = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
@ -178,15 +179,18 @@ export function normalizeAgentToolKeys(params: {
};
}
const RUNTIME_CONTEXT_PLACEHOLDER_PATTERN = /\{\{LIBRECHAT_(?:USER|OPENID|GRAPH|BODY)_[^}]+\}\}/;
const RUNTIME_BODY_PLACEHOLDER_PATTERN = /\{\{LIBRECHAT_BODY_[^}]+\}\}/;
const RUNTIME_BODY_PLACEHOLDER_CAPTURE_PATTERN = /\{\{LIBRECHAT_BODY_([^}]+)\}\}/g;
const BODY_PLACEHOLDER_FIELDS: Record<string, keyof RequestBody> = {
CONVERSATIONID: 'conversationId',
PARENTMESSAGEID: 'parentMessageId',
MESSAGEID: 'messageId',
};
const RUNTIME_CONTEXT_PLACEHOLDER_PATTERN = /\{\{LIBRECHAT_(?:USER|OPENID|GRAPH)_[^}]+\}\}/;
const BODY_PLACEHOLDER_FIELDS = Object.fromEntries(
ALLOWED_BODY_FIELDS.map((field) => [field.toUpperCase(), field]),
) as Record<string, keyof RequestBody>;
const RUNTIME_BODY_FIELD_NAMES = Object.keys(BODY_PLACEHOLDER_FIELDS).join('|');
const RUNTIME_BODY_PLACEHOLDER_PATTERN = new RegExp(
`\\{\\{LIBRECHAT_BODY_(?:${RUNTIME_BODY_FIELD_NAMES})\\}\\}`,
);
const RUNTIME_BODY_PLACEHOLDER_CAPTURE_PATTERN = new RegExp(
`\\{\\{LIBRECHAT_BODY_(${RUNTIME_BODY_FIELD_NAMES})\\}\\}`,
'g',
);
type PlaceholderValue =
| string
@ -255,7 +259,10 @@ export function hasCustomUserVars(
}
function hasRuntimeContextPlaceholder(value: PlaceholderValue): boolean {
return hasPlaceholder(value, RUNTIME_CONTEXT_PLACEHOLDER_PATTERN);
return (
hasPlaceholder(value, RUNTIME_CONTEXT_PLACEHOLDER_PATTERN) ||
hasPlaceholder(value, RUNTIME_BODY_PLACEHOLDER_PATTERN)
);
}
function hasPlaceholder(value: PlaceholderValue, pattern: RegExp): boolean {
@ -277,8 +284,9 @@ function addRuntimeBodyPlaceholderFields(value: PlaceholderValue, fields: Set<st
if (typeof value === 'string') {
for (const match of value.matchAll(RUNTIME_BODY_PLACEHOLDER_CAPTURE_PATTERN)) {
const placeholderKey = match[1];
if (placeholderKey) {
fields.add(BODY_PLACEHOLDER_FIELDS[placeholderKey] ?? placeholderKey);
const field = placeholderKey ? BODY_PLACEHOLDER_FIELDS[placeholderKey] : undefined;
if (field) {
fields.add(field);
}
}
return;
@ -300,13 +308,18 @@ function addRuntimeBodyPlaceholderFields(value: PlaceholderValue, fields: Set<st
}
}
function canResolveRuntimePlaceholders(config: UserScopedConnectionConfig): boolean {
return !isUserSourced(config) && !isPluginSourced(config);
}
/**
* Trusted YAML/config servers may use per-user/request placeholders that can
* only be resolved once a real request context exists. User-sourced DB servers
* deliberately stay sandboxed and only resolve customUserVars.
* deliberately stay sandboxed and only resolve customUserVars, while plugin
* configs preserve every placeholder literally as a security boundary.
*/
export function hasRuntimeContextPlaceholders(config: UserScopedConnectionConfig): boolean {
if (isUserSourced(config)) {
if (!canResolveRuntimePlaceholders(config)) {
return false;
}
@ -314,7 +327,7 @@ export function hasRuntimeContextPlaceholders(config: UserScopedConnectionConfig
}
export function hasRuntimeUrlPlaceholders(config: UserScopedConnectionConfig): boolean {
if (isUserSourced(config)) {
if (!canResolveRuntimePlaceholders(config)) {
return false;
}
@ -322,7 +335,7 @@ export function hasRuntimeUrlPlaceholders(config: UserScopedConnectionConfig): b
}
export function hasRuntimeBodyPlaceholders(config: UserScopedConnectionConfig): boolean {
if (isUserSourced(config)) {
if (!canResolveRuntimePlaceholders(config)) {
return false;
}
@ -332,7 +345,7 @@ export function hasRuntimeBodyPlaceholders(config: UserScopedConnectionConfig):
}
export function getRuntimeBodyPlaceholderFields(config: UserScopedConnectionConfig): string[] {
if (isUserSourced(config)) {
if (!canResolveRuntimePlaceholders(config)) {
return [];
}
@ -366,7 +379,7 @@ export function getMissingRuntimeBodyPlaceholderFields(
* connection without forcing a reconnect for every invocation.
*/
export function requiresEphemeralUserConnection(config: UserScopedConnectionConfig): boolean {
if (isUserSourced(config)) {
if (!canResolveRuntimePlaceholders(config)) {
return false;
}
@ -427,6 +440,11 @@ export function isUserSourced(config: Pick<ParsedServerConfig, 'source' | 'dbId'
return config.source != null ? config.source === 'user' : !!config.dbId;
}
export type RedactedServerConfig = Partial<ParsedServerConfig> & {
/** True when this config needs chat request fields before it can connect. */
requestScoped?: boolean;
};
/**
* Allowlist-based sanitization for API responses. Only explicitly listed fields are included;
* new fields added to ParsedServerConfig are excluded by default until allowlisted here.
@ -443,8 +461,8 @@ export function isUserSourced(config: Pick<ParsedServerConfig, 'source' | 'dbId'
export function redactServerSecrets(
config: ParsedServerConfig,
options?: { canEdit?: boolean },
): Partial<ParsedServerConfig> {
const safe: Partial<ParsedServerConfig> = {
): RedactedServerConfig {
const safe: RedactedServerConfig = {
type: config.type,
url: config.url,
title: config.title,
@ -464,6 +482,9 @@ export function redactServerSecrets(
inspectionFailed: config.inspectionFailed,
customUserVars: config.customUserVars,
serverInstructions: config.serverInstructions,
/** Safe derived metadata: it exposes no placeholder-bearing value, but lets
* clients attach tools that can only be discovered during a chat turn. */
requestScoped: requiresEphemeralUserConnection(config) || undefined,
};
if (config.apiKey) {
@ -498,15 +519,15 @@ export function redactServerSecrets(
return Object.fromEntries(
Object.entries(safe).filter(([, v]) => v !== undefined),
) as Partial<ParsedServerConfig>;
) as RedactedServerConfig;
}
/** Applies allowlist-based sanitization to a map of server configs. */
export function redactAllServerSecrets(
configs: Record<string, ParsedServerConfig>,
options?: { canEditByServer?: ReadonlyMap<string, boolean> },
): Record<string, Partial<ParsedServerConfig>> {
const result: Record<string, Partial<ParsedServerConfig>> = {};
): Record<string, RedactedServerConfig> {
const result: Record<string, RedactedServerConfig> = {};
for (const [key, config] of Object.entries(configs)) {
const canEdit = options?.canEditByServer?.get(key) ?? false;
result[key] = redactServerSecrets(config, { canEdit });

View file

@ -142,7 +142,7 @@ export function createSafeUser(
* List of allowed request body fields that can be used in header placeholders.
* These are common fields from the request body that are safe to expose in headers.
*/
const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const;
export const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const;
/**
* Matches every placeholder this module knows how to resolve: the enumerated

View file

@ -44,6 +44,8 @@ export type MCPServerDBObjectResponse = {
serverName: string;
/** True if access is only via agent (not directly shared with user) */
consumeOnly?: boolean;
/** True when chat request fields are required before the server can connect. */
requestScoped?: boolean;
} & MCPOptions;
export type MCPServersListResponse = Record<string, MCPServerDBObjectResponse>;