mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue