mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 08:20:14 +01:00
🧩 refactor: Decouple MCP Config from Startup Config (#10689)
* Decouple mcp config from start up config * Chore: Work on AI Review and Copilot Comments - setRawConfig is not needed since the private raw config is not needed any more - !!serversLoading bug fixed - added unit tests for route /api/mcp/servers - copilot comments addressed * chore: remove comments * chore: rename data-provider dir for MCP * chore: reorganize mcp specific query hooks * fix: consolidate imports for MCP server manager * chore: add dev-staging branch to frontend review workflow triggers * feat: add GitHub Actions workflow for building and pushing Docker images to GitHub Container Registry and Docker Hub * fix: update label for tag input in BookmarkForm tests to improve clarity --------- Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
98b188f26c
commit
ef1b7f0157
36 changed files with 548 additions and 301 deletions
1
.github/workflows/frontend-review.yml
vendored
1
.github/workflows/frontend-review.yml
vendored
|
|
@ -5,6 +5,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
- dev-staging
|
||||||
- release/*
|
- release/*
|
||||||
paths:
|
paths:
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const {
|
||||||
createSafeUser,
|
createSafeUser,
|
||||||
mcpToolPattern,
|
mcpToolPattern,
|
||||||
loadWebSearchAuth,
|
loadWebSearchAuth,
|
||||||
|
mcpServersRegistry,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
|
|
@ -347,7 +348,7 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new
|
||||||
/** Placeholder used for UI purposes */
|
/** Placeholder used for UI purposes */
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) {
|
if (serverName && (await mcpServersRegistry.getServerConfig(serverName, user)) == undefined) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
|
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,7 @@
|
||||||
*/
|
*/
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
const {
|
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
|
||||||
cacheMCPServerTools,
|
|
||||||
getMCPServerTools,
|
|
||||||
getAppConfig,
|
|
||||||
} = require('~/server/services/Config');
|
|
||||||
const { getMCPManager } = require('~/config');
|
const { getMCPManager } = require('~/config');
|
||||||
const { mcpServersRegistry } = require('@librechat/api');
|
const { mcpServersRegistry } = require('@librechat/api');
|
||||||
|
|
||||||
|
|
@ -23,13 +19,14 @@ const getMCPTools = async (req, res) => {
|
||||||
return res.status(401).json({ message: 'Unauthorized' });
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role }));
|
const mcpConfig = await mcpServersRegistry.getAllServerConfigs(userId);
|
||||||
if (!appConfig?.mcpConfig) {
|
const configuredServers = mcpConfig ? Object.keys(mcpConfig) : [];
|
||||||
|
|
||||||
|
if (!mcpConfig || Object.keys(mcpConfig).length == 0) {
|
||||||
return res.status(200).json({ servers: {} });
|
return res.status(200).json({ servers: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
const configuredServers = Object.keys(appConfig.mcpConfig);
|
|
||||||
const mcpServers = {};
|
const mcpServers = {};
|
||||||
|
|
||||||
const cachePromises = configuredServers.map((serverName) =>
|
const cachePromises = configuredServers.map((serverName) =>
|
||||||
|
|
@ -71,7 +68,7 @@ const getMCPTools = async (req, res) => {
|
||||||
const serverTools = serverToolsMap.get(serverName);
|
const serverTools = serverToolsMap.get(serverName);
|
||||||
|
|
||||||
// Get server config once
|
// Get server config once
|
||||||
const serverConfig = appConfig.mcpConfig[serverName];
|
const serverConfig = mcpConfig[serverName];
|
||||||
const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
||||||
|
|
||||||
// Initialize server object with all server-level data
|
// Initialize server object with all server-level data
|
||||||
|
|
@ -127,7 +124,29 @@ const getMCPTools = async (req, res) => {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Get all MCP servers with permissions
|
||||||
|
* @route GET /api/mcp/servers
|
||||||
|
*/
|
||||||
|
const getMCPServersList = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
// TODO - Ensure DB servers loaded into registry (configs only)
|
||||||
|
|
||||||
|
// 2. Get all server configs from registry (YAML + DB)
|
||||||
|
const serverConfigs = await mcpServersRegistry.getAllServerConfigs(userId);
|
||||||
|
|
||||||
|
return res.json(serverConfigs);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[getMCPServersList]', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getMCPTools,
|
getMCPTools,
|
||||||
|
getMCPServersList,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ jest.mock('@librechat/api', () => ({
|
||||||
mcpServersRegistry: {
|
mcpServersRegistry: {
|
||||||
getServerConfig: jest.fn(),
|
getServerConfig: jest.fn(),
|
||||||
getOAuthServers: jest.fn(),
|
getOAuthServers: jest.fn(),
|
||||||
|
getAllServerConfigs: jest.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1415,4 +1416,62 @@ describe('MCP Routes', () => {
|
||||||
expect(response.headers.location).toContain('/oauth/success');
|
expect(response.headers.location).toContain('/oauth/success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /servers', () => {
|
||||||
|
const { mcpServersRegistry } = require('@librechat/api');
|
||||||
|
|
||||||
|
it('should return all server configs for authenticated user', async () => {
|
||||||
|
const mockServerConfigs = {
|
||||||
|
'server-1': {
|
||||||
|
endpoint: 'http://server1.com',
|
||||||
|
name: 'Server 1',
|
||||||
|
},
|
||||||
|
'server-2': {
|
||||||
|
endpoint: 'http://server2.com',
|
||||||
|
name: 'Server 2',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/mcp/servers');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(mockServerConfigs);
|
||||||
|
expect(mcpServersRegistry.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when no servers are configured', async () => {
|
||||||
|
mcpServersRegistry.getAllServerConfigs.mockResolvedValue({});
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/mcp/servers');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when user is not authenticated', async () => {
|
||||||
|
const unauthApp = express();
|
||||||
|
unauthApp.use(express.json());
|
||||||
|
unauthApp.use((req, _res, next) => {
|
||||||
|
req.user = null;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
unauthApp.use('/api/mcp', mcpRouter);
|
||||||
|
|
||||||
|
const response = await request(unauthApp).get('/api/mcp/servers');
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body).toEqual({ message: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 500 when server config retrieval fails', async () => {
|
||||||
|
mcpServersRegistry.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/mcp/servers');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Database error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { isEnabled, getBalanceConfig } = require('@librechat/api');
|
const { isEnabled, getBalanceConfig } = require('@librechat/api');
|
||||||
const {
|
const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
|
||||||
Constants,
|
|
||||||
CacheKeys,
|
|
||||||
removeNullishValues,
|
|
||||||
defaultSocialLogins,
|
|
||||||
} = require('librechat-data-provider');
|
|
||||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||||
const { getAppConfig } = require('~/server/services/Config/app');
|
const { getAppConfig } = require('~/server/services/Config/app');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { getMCPManager } = require('~/config');
|
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { mcpServersRegistry } = require('@librechat/api');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const emailLoginEnabled =
|
const emailLoginEnabled =
|
||||||
|
|
@ -30,46 +23,11 @@ const publicSharedLinksEnabled =
|
||||||
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
||||||
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches MCP servers from registry and adds them to the payload.
|
|
||||||
* Registry now includes all configured servers (from YAML) plus inspection data when available.
|
|
||||||
* Always fetches fresh to avoid caching incomplete initialization state.
|
|
||||||
*/
|
|
||||||
const getMCPServers = async (payload, appConfig) => {
|
|
||||||
try {
|
|
||||||
if (appConfig?.mcpConfig == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mcpManager = getMCPManager();
|
|
||||||
if (!mcpManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
|
|
||||||
if (!mcpServers) return;
|
|
||||||
for (const serverName in mcpServers) {
|
|
||||||
if (!payload.mcpServers) {
|
|
||||||
payload.mcpServers = {};
|
|
||||||
}
|
|
||||||
const serverConfig = mcpServers[serverName];
|
|
||||||
payload.mcpServers[serverName] = removeNullishValues({
|
|
||||||
startup: serverConfig?.startup,
|
|
||||||
chatMenu: serverConfig?.chatMenu,
|
|
||||||
isOAuth: serverConfig.requiresOAuth,
|
|
||||||
customUserVars: serverConfig?.customUserVars,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error loading MCP servers', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
router.get('/', async function (req, res) {
|
router.get('/', async function (req, res) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
|
||||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||||
if (cachedStartupConfig) {
|
if (cachedStartupConfig) {
|
||||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
|
||||||
await getMCPServers(cachedStartupConfig, appConfig);
|
|
||||||
res.send(cachedStartupConfig);
|
res.send(cachedStartupConfig);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +148,6 @@ router.get('/', async function (req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
||||||
await getMCPServers(payload, appConfig);
|
|
||||||
return res.status(200).send(payload);
|
return res.status(200).send(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Error in startup config', err);
|
logger.error('Error in startup config', err);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const { findToken, updateToken, createToken, deleteTokens } = require('~/models'
|
||||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
||||||
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
||||||
|
const { getMCPServersList } = require('~/server/controllers/mcp');
|
||||||
const { getMCPTools } = require('~/server/controllers/mcp');
|
const { getMCPTools } = require('~/server/controllers/mcp');
|
||||||
const { requireJwtAuth } = require('~/server/middleware');
|
const { requireJwtAuth } = require('~/server/middleware');
|
||||||
const { findPluginAuthsByKeys } = require('~/models');
|
const { findPluginAuthsByKeys } = require('~/models');
|
||||||
|
|
@ -428,11 +429,12 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
|
||||||
);
|
);
|
||||||
const connectionStatus = {};
|
const connectionStatus = {};
|
||||||
|
|
||||||
for (const [serverName] of Object.entries(mcpConfig)) {
|
for (const [serverName, config] of Object.entries(mcpConfig)) {
|
||||||
try {
|
try {
|
||||||
connectionStatus[serverName] = await getServerConnectionStatus(
|
connectionStatus[serverName] = await getServerConnectionStatus(
|
||||||
user.id,
|
user.id,
|
||||||
serverName,
|
serverName,
|
||||||
|
config,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -487,6 +489,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
|
||||||
const serverStatus = await getServerConnectionStatus(
|
const serverStatus = await getServerConnectionStatus(
|
||||||
user.id,
|
user.id,
|
||||||
serverName,
|
serverName,
|
||||||
|
mcpConfig[serverName],
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -566,5 +569,11 @@ async function getOAuthHeaders(serverName, userId) {
|
||||||
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
|
||||||
return serverConfig?.oauth_headers ?? {};
|
return serverConfig?.oauth_headers ?? {};
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Get list of accessible MCP servers
|
||||||
|
* @route GET /api/mcp/servers
|
||||||
|
* @returns {MCPServersListResponse} 200 - Success response - application/json
|
||||||
|
*/
|
||||||
|
router.get('/servers', requireJwtAuth, getMCPServersList);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -435,8 +435,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
|
||||||
* @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers
|
* @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers
|
||||||
*/
|
*/
|
||||||
async function getMCPSetupData(userId) {
|
async function getMCPSetupData(userId) {
|
||||||
const config = await getAppConfig();
|
const mcpConfig = await mcpServersRegistry.getAllServerConfigs(userId);
|
||||||
const mcpConfig = config?.mcpConfig;
|
|
||||||
|
|
||||||
if (!mcpConfig) {
|
if (!mcpConfig) {
|
||||||
throw new Error('MCP config not found');
|
throw new Error('MCP config not found');
|
||||||
|
|
@ -451,7 +450,7 @@ async function getMCPSetupData(userId) {
|
||||||
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
|
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
|
||||||
}
|
}
|
||||||
const userConnections = mcpManager.getUserConnections(userId) || new Map();
|
const userConnections = mcpManager.getUserConnections(userId) || new Map();
|
||||||
const oauthServers = await mcpServersRegistry.getOAuthServers();
|
const oauthServers = await mcpServersRegistry.getOAuthServers(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mcpConfig,
|
mcpConfig,
|
||||||
|
|
@ -524,24 +523,26 @@ async function checkOAuthFlowStatus(userId, serverName) {
|
||||||
* Get connection status for a specific MCP server
|
* Get connection status for a specific MCP server
|
||||||
* @param {string} userId - The user ID
|
* @param {string} userId - The user ID
|
||||||
* @param {string} serverName - The server name
|
* @param {string} serverName - The server name
|
||||||
* @param {Map} appConnections - App-level connections
|
* @param {import('@librechat/api').ParsedServerConfig} config - The server configuration
|
||||||
* @param {Map} userConnections - User-level connections
|
* @param {Map<string, import('@librechat/api').MCPConnection>} appConnections - App-level connections
|
||||||
|
* @param {Map<string, import('@librechat/api').MCPConnection>} userConnections - User-level connections
|
||||||
* @param {Set} oauthServers - Set of OAuth servers
|
* @param {Set} oauthServers - Set of OAuth servers
|
||||||
* @returns {Object} Object containing requiresOAuth and connectionState
|
* @returns {Object} Object containing requiresOAuth and connectionState
|
||||||
*/
|
*/
|
||||||
async function getServerConnectionStatus(
|
async function getServerConnectionStatus(
|
||||||
userId,
|
userId,
|
||||||
serverName,
|
serverName,
|
||||||
|
config,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
) {
|
) {
|
||||||
const getConnectionState = () =>
|
const connection = appConnections.get(serverName) || userConnections.get(serverName);
|
||||||
appConnections.get(serverName)?.connectionState ??
|
const isStaleOrDoNotExist = connection ? connection?.isStale(config.lastUpdatedAt) : true;
|
||||||
userConnections.get(serverName)?.connectionState ??
|
|
||||||
'disconnected';
|
|
||||||
|
|
||||||
const baseConnectionState = getConnectionState();
|
const baseConnectionState = isStaleOrDoNotExist
|
||||||
|
? 'disconnected'
|
||||||
|
: connection?.connectionState || 'disconnected';
|
||||||
let finalConnectionState = baseConnectionState;
|
let finalConnectionState = baseConnectionState;
|
||||||
|
|
||||||
// connection state overrides specific to OAuth servers
|
// connection state overrides specific to OAuth servers
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ jest.mock('@librechat/api', () => ({
|
||||||
convertWithResolvedRefs: jest.fn((params) => params),
|
convertWithResolvedRefs: jest.fn((params) => params),
|
||||||
mcpServersRegistry: {
|
mcpServersRegistry: {
|
||||||
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
|
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
|
||||||
|
getAllServerConfigs: jest.fn(() => Promise.resolve({})),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -118,31 +119,28 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
describe('getMCPSetupData', () => {
|
describe('getMCPSetupData', () => {
|
||||||
const mockUserId = 'user-123';
|
const mockUserId = 'user-123';
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
mcpServers: {
|
server1: { type: 'stdio' },
|
||||||
server1: { type: 'stdio' },
|
server2: { type: 'http' },
|
||||||
server2: { type: 'http' },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
let mockGetAppConfig;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetAppConfig = require('./Config').getAppConfig;
|
|
||||||
mockGetMCPManager.mockReturnValue({
|
mockGetMCPManager.mockReturnValue({
|
||||||
appConnections: { getAll: jest.fn(() => new Map()) },
|
appConnections: { getAll: jest.fn(() => new Map()) },
|
||||||
getUserConnections: jest.fn(() => new Map()),
|
getUserConnections: jest.fn(() => new Map()),
|
||||||
});
|
});
|
||||||
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
|
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
|
||||||
|
mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully return MCP setup data', async () => {
|
it('should successfully return MCP setup data', async () => {
|
||||||
mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers });
|
mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig);
|
||||||
|
|
||||||
const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
|
const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
|
||||||
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
|
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
|
||||||
const mockOAuthServers = new Set(['server2']);
|
const mockOAuthServers = new Set(['server2']);
|
||||||
|
|
||||||
const mockMCPManager = {
|
const mockMCPManager = {
|
||||||
appConnections: { getAll: jest.fn(() => mockAppConnections) },
|
appConnections: { getAll: jest.fn(() => Promise.resolve(mockAppConnections)) },
|
||||||
getUserConnections: jest.fn(() => mockUserConnections),
|
getUserConnections: jest.fn(() => mockUserConnections),
|
||||||
};
|
};
|
||||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||||
|
|
@ -150,14 +148,14 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
|
|
||||||
const result = await getMCPSetupData(mockUserId);
|
const result = await getMCPSetupData(mockUserId);
|
||||||
|
|
||||||
expect(mockGetAppConfig).toHaveBeenCalled();
|
expect(mockMcpServersRegistry.getAllServerConfigs).toHaveBeenCalledWith(mockUserId);
|
||||||
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
|
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
|
||||||
expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
|
expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
|
||||||
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
|
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
|
||||||
expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled();
|
expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalledWith(mockUserId);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
mcpConfig: mockConfig.mcpServers,
|
mcpConfig: mockConfig,
|
||||||
appConnections: mockAppConnections,
|
appConnections: mockAppConnections,
|
||||||
userConnections: mockUserConnections,
|
userConnections: mockUserConnections,
|
||||||
oauthServers: mockOAuthServers,
|
oauthServers: mockOAuthServers,
|
||||||
|
|
@ -165,15 +163,15 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when MCP config not found', async () => {
|
it('should throw error when MCP config not found', async () => {
|
||||||
mockGetAppConfig.mockResolvedValue({});
|
mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(null);
|
||||||
await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found');
|
await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle null values from MCP manager gracefully', async () => {
|
it('should handle null values from MCP manager gracefully', async () => {
|
||||||
mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers });
|
mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig);
|
||||||
|
|
||||||
const mockMCPManager = {
|
const mockMCPManager = {
|
||||||
appConnections: { getAll: jest.fn(() => null) },
|
appConnections: { getAll: jest.fn(() => Promise.resolve(null)) },
|
||||||
getUserConnections: jest.fn(() => null),
|
getUserConnections: jest.fn(() => null),
|
||||||
};
|
};
|
||||||
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
||||||
|
|
@ -182,7 +180,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getMCPSetupData(mockUserId);
|
const result = await getMCPSetupData(mockUserId);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
mcpConfig: mockConfig.mcpServers,
|
mcpConfig: mockConfig,
|
||||||
appConnections: new Map(),
|
appConnections: new Map(),
|
||||||
userConnections: new Map(),
|
userConnections: new Map(),
|
||||||
oauthServers: new Set(),
|
oauthServers: new Set(),
|
||||||
|
|
@ -329,15 +327,25 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
describe('getServerConnectionStatus', () => {
|
describe('getServerConnectionStatus', () => {
|
||||||
const mockUserId = 'user-123';
|
const mockUserId = 'user-123';
|
||||||
const mockServerName = 'test-server';
|
const mockServerName = 'test-server';
|
||||||
|
const mockConfig = { lastUpdatedAt: Date.now() };
|
||||||
|
|
||||||
it('should return app connection state when available', async () => {
|
it('should return app connection state when available', async () => {
|
||||||
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
|
const appConnections = new Map([
|
||||||
|
[
|
||||||
|
mockServerName,
|
||||||
|
{
|
||||||
|
connectionState: 'connected',
|
||||||
|
isStale: jest.fn(() => false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
const userConnections = new Map();
|
const userConnections = new Map();
|
||||||
const oauthServers = new Set();
|
const oauthServers = new Set();
|
||||||
|
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -351,12 +359,21 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
|
|
||||||
it('should fallback to user connection state when app connection not available', async () => {
|
it('should fallback to user connection state when app connection not available', async () => {
|
||||||
const appConnections = new Map();
|
const appConnections = new Map();
|
||||||
const userConnections = new Map([[mockServerName, { connectionState: 'connecting' }]]);
|
const userConnections = new Map([
|
||||||
|
[
|
||||||
|
mockServerName,
|
||||||
|
{
|
||||||
|
connectionState: 'connecting',
|
||||||
|
isStale: jest.fn(() => false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
const oauthServers = new Set();
|
const oauthServers = new Set();
|
||||||
|
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -376,6 +393,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -388,13 +406,30 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prioritize app connection over user connection', async () => {
|
it('should prioritize app connection over user connection', async () => {
|
||||||
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
|
const appConnections = new Map([
|
||||||
const userConnections = new Map([[mockServerName, { connectionState: 'disconnected' }]]);
|
[
|
||||||
|
mockServerName,
|
||||||
|
{
|
||||||
|
connectionState: 'connected',
|
||||||
|
isStale: jest.fn(() => false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const userConnections = new Map([
|
||||||
|
[
|
||||||
|
mockServerName,
|
||||||
|
{
|
||||||
|
connectionState: 'disconnected',
|
||||||
|
isStale: jest.fn(() => false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
const oauthServers = new Set();
|
const oauthServers = new Set();
|
||||||
|
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -420,6 +455,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -454,6 +490,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -491,6 +528,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -524,6 +562,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -549,6 +588,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -571,13 +611,22 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
mockGetLogStores.mockReturnValue({});
|
mockGetLogStores.mockReturnValue({});
|
||||||
|
|
||||||
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]);
|
const appConnections = new Map([
|
||||||
|
[
|
||||||
|
mockServerName,
|
||||||
|
{
|
||||||
|
connectionState: 'connected',
|
||||||
|
isStale: jest.fn(() => false),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
const userConnections = new Map();
|
const userConnections = new Map();
|
||||||
const oauthServers = new Set([mockServerName]);
|
const oauthServers = new Set([mockServerName]);
|
||||||
|
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
@ -606,6 +655,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
||||||
const result = await getServerConnectionStatus(
|
const result = await getServerConnectionStatus(
|
||||||
mockUserId,
|
mockUserId,
|
||||||
mockServerName,
|
mockServerName,
|
||||||
|
mockConfig,
|
||||||
appConnections,
|
appConnections,
|
||||||
userConnections,
|
userConnections,
|
||||||
oauthServers,
|
oauthServers,
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
|
||||||
|
|
||||||
/** @type {Record<string, Record<string, string>>} */
|
/** @type {Record<string, Record<string, string>>} */
|
||||||
let userMCPAuthMap;
|
let userMCPAuthMap;
|
||||||
|
//TODO pass config from registry
|
||||||
if (hasCustomUserVars(req.config)) {
|
if (hasCustomUserVars(req.config)) {
|
||||||
userMCPAuthMap = await getUserMCPAuthMap({
|
userMCPAuthMap = await getUserMCPAuthMap({
|
||||||
tools: agent.tools,
|
tools: agent.tools,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ import {
|
||||||
useGetStartupConfig,
|
useGetStartupConfig,
|
||||||
useMCPToolsQuery,
|
useMCPToolsQuery,
|
||||||
} from '~/data-provider';
|
} from '~/data-provider';
|
||||||
import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks';
|
import {
|
||||||
|
useLocalize,
|
||||||
|
useGetAgentsConfig,
|
||||||
|
useMCPConnectionStatus,
|
||||||
|
useMCPServerManager,
|
||||||
|
} from '~/hooks';
|
||||||
import { Panel, isEphemeralAgent } from '~/common';
|
import { Panel, isEphemeralAgent } from '~/common';
|
||||||
|
|
||||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||||
|
|
@ -29,7 +34,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
const [action, setAction] = useState<Action | undefined>(undefined);
|
const [action, setAction] = useState<Action | undefined>(undefined);
|
||||||
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
|
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
|
||||||
const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined);
|
const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined);
|
||||||
|
const { availableMCPServers, isLoading, availableMCPServersMap } = useMCPServerManager();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
|
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
|
||||||
enabled: !isEphemeralAgent(agent_id),
|
enabled: !isEphemeralAgent(agent_id),
|
||||||
|
|
@ -38,19 +43,23 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
|
||||||
|
|
||||||
const { data: mcpData } = useMCPToolsQuery({
|
const { data: mcpData } = useMCPToolsQuery({
|
||||||
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,
|
enabled:
|
||||||
|
!isEphemeralAgent(agent_id) &&
|
||||||
|
!isLoading &&
|
||||||
|
availableMCPServers != null &&
|
||||||
|
availableMCPServers.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
|
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
|
||||||
const mcpServerNames = useMemo(
|
const mcpServerNames = useMemo(
|
||||||
() => Object.keys(startupConfig?.mcpServers ?? {}),
|
() => availableMCPServers.map((s) => s.serverName),
|
||||||
[startupConfig],
|
[availableMCPServers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { connectionStatus } = useMCPConnectionStatus({
|
const { connectionStatus } = useMCPConnectionStatus({
|
||||||
enabled: !isEphemeralAgent(agent_id) && mcpServerNames.length > 0,
|
enabled: !isEphemeralAgent(agent_id) && mcpServerNames.length > 0,
|
||||||
});
|
});
|
||||||
|
//TODO to refactor when tools come from tool box
|
||||||
const mcpServersMap = useMemo(() => {
|
const mcpServersMap = useMemo(() => {
|
||||||
const configuredServers = new Set(mcpServerNames);
|
const configuredServers = new Set(mcpServerNames);
|
||||||
const serversMap = new Map<string, MCPServerInfo>();
|
const serversMap = new Map<string, MCPServerInfo>();
|
||||||
|
|
@ -127,6 +136,8 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
setActivePanel,
|
setActivePanel,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
setCurrentAgentId,
|
setCurrentAgentId,
|
||||||
|
availableMCPServers,
|
||||||
|
availableMCPServersMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import type * as t from 'librechat-data-provider';
|
import type * as t from 'librechat-data-provider';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import type { TranslationKeys } from '~/hooks';
|
import type { TranslationKeys } from '~/hooks';
|
||||||
|
import { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||||
|
|
||||||
export function isEphemeralAgent(agentId: string | null | undefined): boolean {
|
export function isEphemeralAgent(agentId: string | null | undefined): boolean {
|
||||||
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
|
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
|
||||||
|
|
@ -246,6 +247,8 @@ export type AgentPanelContextType = {
|
||||||
endpointsConfig?: t.TEndpointsConfig | null;
|
endpointsConfig?: t.TEndpointsConfig | null;
|
||||||
/** Pre-computed MCP server information indexed by server key */
|
/** Pre-computed MCP server information indexed by server key */
|
||||||
mcpServersMap: Map<string, MCPServerInfo>;
|
mcpServersMap: Map<string, MCPServerInfo>;
|
||||||
|
availableMCPServers: MCPServerDefinition[];
|
||||||
|
availableMCPServersMap: t.MCPServersListResponse | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentModelPanelProps = {
|
export type AgentModelPanelProps = {
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagInput = screen.getByLabelText('Edit Bookmark');
|
const tagInput = screen.getByLabelText('Title');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
|
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
|
||||||
|
|
@ -265,7 +265,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagInput = screen.getByLabelText('Edit Bookmark');
|
const tagInput = screen.getByLabelText('Title');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
|
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
|
||||||
|
|
@ -308,7 +308,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagInput = screen.getByLabelText('Edit Bookmark');
|
const tagInput = screen.getByLabelText('Title');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } });
|
fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } });
|
||||||
|
|
@ -401,7 +401,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagInput = screen.getByLabelText('Edit Bookmark');
|
const tagInput = screen.getByLabelText('Title');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(tagInput, { target: { value: 'Props Tag' } });
|
fireEvent.change(tagInput, { target: { value: 'Props Tag' } });
|
||||||
|
|
@ -477,7 +477,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagInput = screen.getByLabelText('Edit Bookmark');
|
const tagInput = screen.getByLabelText('Title');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(tagInput, { target: { value: 'New Tag' } });
|
fireEvent.change(tagInput, { target: { value: 'New Tag' } });
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ function MCPSelectContent() {
|
||||||
mcpValues,
|
mcpValues,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
placeholderText,
|
placeholderText,
|
||||||
configuredServers,
|
|
||||||
batchToggleServers,
|
batchToggleServers,
|
||||||
getConfigDialogProps,
|
getConfigDialogProps,
|
||||||
getServerStatusIconProps,
|
getServerStatusIconProps,
|
||||||
|
availableMCPServers,
|
||||||
} = mcpServerManager;
|
} = mcpServerManager;
|
||||||
|
|
||||||
const renderSelectedValues = useCallback(
|
const renderSelectedValues = useCallback(
|
||||||
|
|
@ -78,7 +78,7 @@ function MCPSelectContent() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
items={configuredServers}
|
items={availableMCPServers?.map((s) => s.serverName)}
|
||||||
selectedValues={mcpValues ?? []}
|
selectedValues={mcpValues ?? []}
|
||||||
setSelectedValues={batchToggleServers}
|
setSelectedValues={batchToggleServers}
|
||||||
renderSelectedValues={renderSelectedValues}
|
renderSelectedValues={renderSelectedValues}
|
||||||
|
|
@ -99,9 +99,9 @@ function MCPSelectContent() {
|
||||||
|
|
||||||
function MCPSelect() {
|
function MCPSelect() {
|
||||||
const { mcpServerManager } = useBadgeRowContext();
|
const { mcpServerManager } = useBadgeRowContext();
|
||||||
const { configuredServers } = mcpServerManager;
|
const { availableMCPServers } = mcpServerManager;
|
||||||
|
|
||||||
if (!configuredServers || configuredServers.length === 0) {
|
if (!availableMCPServers || availableMCPServers.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||||
setIsPinned,
|
setIsPinned,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
placeholderText,
|
placeholderText,
|
||||||
configuredServers,
|
availableMCPServers,
|
||||||
getConfigDialogProps,
|
getConfigDialogProps,
|
||||||
toggleServerSelection,
|
toggleServerSelection,
|
||||||
getServerStatusIconProps,
|
getServerStatusIconProps,
|
||||||
|
|
@ -33,7 +33,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't render if no MCP servers are configured
|
// Don't render if no MCP servers are configured
|
||||||
if (!configuredServers || configuredServers.length === 0) {
|
if (!availableMCPServers || availableMCPServers.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,19 +85,19 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||||
'border border-border-light bg-surface-secondary p-1 shadow-lg',
|
'border border-border-light bg-surface-secondary p-1 shadow-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{configuredServers.map((serverName) => {
|
{availableMCPServers.map((s) => {
|
||||||
const statusIconProps = getServerStatusIconProps(serverName);
|
const statusIconProps = getServerStatusIconProps(s.serverName);
|
||||||
const isSelected = mcpValues?.includes(serverName) ?? false;
|
const isSelected = mcpValues?.includes(s.serverName) ?? false;
|
||||||
const isServerInitializing = isInitializing(serverName);
|
const isServerInitializing = isInitializing(s.serverName);
|
||||||
|
|
||||||
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ariakit.MenuItem
|
<Ariakit.MenuItem
|
||||||
key={serverName}
|
key={s.serverName}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleServerSelection(serverName);
|
toggleServerSelection(s.serverName);
|
||||||
}}
|
}}
|
||||||
disabled={isServerInitializing}
|
disabled={isServerInitializing}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -112,7 +112,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||||
>
|
>
|
||||||
<div className="flex flex-grow items-center gap-2">
|
<div className="flex flex-grow items-center gap-2">
|
||||||
<Ariakit.MenuItemCheck checked={isSelected} />
|
<Ariakit.MenuItemCheck checked={isSelected} />
|
||||||
<span>{serverName}</span>
|
<span>{s.serverName}</span>
|
||||||
</div>
|
</div>
|
||||||
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
||||||
</Ariakit.MenuItem>
|
</Ariakit.MenuItem>
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,17 @@ export default function ServerInitializationSection({
|
||||||
}: ServerInitializationSectionProps) {
|
}: ServerInitializationSectionProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const { initializeServer, cancelOAuthFlow, isInitializing, isCancellable, getOAuthUrl } =
|
const {
|
||||||
useMCPServerManager({ conversationId });
|
initializeServer,
|
||||||
|
availableMCPServers,
|
||||||
|
cancelOAuthFlow,
|
||||||
|
isInitializing,
|
||||||
|
isCancellable,
|
||||||
|
getOAuthUrl,
|
||||||
|
} = useMCPServerManager({ conversationId });
|
||||||
|
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
|
||||||
const { connectionStatus } = useMCPConnectionStatus({
|
const { connectionStatus } = useMCPConnectionStatus({
|
||||||
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
|
enabled: !!availableMCPServers && availableMCPServers.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverStatus = connectionStatus?.[serverName];
|
const serverStatus = connectionStatus?.[serverName];
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export default function AgentConfig() {
|
||||||
setAction,
|
setAction,
|
||||||
regularTools,
|
regularTools,
|
||||||
agentsConfig,
|
agentsConfig,
|
||||||
startupConfig,
|
availableMCPServers,
|
||||||
mcpServersMap,
|
mcpServersMap,
|
||||||
setActivePanel,
|
setActivePanel,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
|
|
@ -305,7 +305,7 @@ export default function AgentConfig() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* MCP Section */}
|
{/* MCP Section */}
|
||||||
{startupConfig?.mcpServers != null && (
|
{availableMCPServers != null && availableMCPServers.length > 0 && (
|
||||||
<MCPTools
|
<MCPTools
|
||||||
agentId={agent_id}
|
agentId={agent_id}
|
||||||
mcpServerNames={mcpServerNames}
|
mcpServerNames={mcpServerNames}
|
||||||
|
|
@ -491,7 +491,7 @@ export default function AgentConfig() {
|
||||||
setIsOpen={setShowToolDialog}
|
setIsOpen={setShowToolDialog}
|
||||||
endpoint={EModelEndpoint.agents}
|
endpoint={EModelEndpoint.agents}
|
||||||
/>
|
/>
|
||||||
{startupConfig?.mcpServers != null && (
|
{availableMCPServers != null && availableMCPServers.length > 0 && (
|
||||||
<MCPToolSelectDialog
|
<MCPToolSelectDialog
|
||||||
agentId={agent_id}
|
agentId={agent_id}
|
||||||
isOpen={showMCPToolDialog}
|
isOpen={showMCPToolDialog}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { ChevronLeft, Trash2 } from 'lucide-react';
|
import { ChevronLeft, Trash2 } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button, useToastContext } from '@librechat/client';
|
import { Button, useToastContext } from '@librechat/client';
|
||||||
|
|
@ -8,8 +8,7 @@ import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||||
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
|
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
|
||||||
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
|
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
|
||||||
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
|
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
|
||||||
import { useLocalize, useMCPConnectionStatus } from '~/hooks';
|
import { useLocalize, useMCPServerManager } from '~/hooks';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
|
||||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||||
|
|
||||||
function MCPPanelContent() {
|
function MCPPanelContent() {
|
||||||
|
|
@ -17,9 +16,8 @@ function MCPPanelContent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const { conversationId } = useMCPPanelContext();
|
const { conversationId } = useMCPPanelContext();
|
||||||
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
const { availableMCPServers, isLoading, connectionStatus } = useMCPServerManager({
|
||||||
const { connectionStatus } = useMCPConnectionStatus({
|
conversationId,
|
||||||
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||||
|
|
@ -45,20 +43,6 @@ function MCPPanelContent() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mcpServerDefinitions = useMemo(() => {
|
|
||||||
if (!startupConfig?.mcpServers) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Object.entries(startupConfig.mcpServers).map(([serverName, config]) => ({
|
|
||||||
serverName,
|
|
||||||
iconPath: null,
|
|
||||||
config: {
|
|
||||||
...config,
|
|
||||||
customUserVars: config.customUserVars ?? {},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, [startupConfig?.mcpServers]);
|
|
||||||
|
|
||||||
const handleServerClickToEdit = (serverName: string) => {
|
const handleServerClickToEdit = (serverName: string) => {
|
||||||
setSelectedServerNameForEditing(serverName);
|
setSelectedServerNameForEditing(serverName);
|
||||||
};
|
};
|
||||||
|
|
@ -94,11 +78,11 @@ function MCPPanelContent() {
|
||||||
[updateUserPluginsMutation],
|
[updateUserPluginsMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (startupConfigLoading) {
|
if (isLoading) {
|
||||||
return <MCPPanelSkeleton />;
|
return <MCPPanelSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mcpServerDefinitions.length === 0) {
|
if (availableMCPServers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-center text-sm text-gray-500">
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
{localize('com_sidepanel_mcp_no_servers_with_vars')}
|
{localize('com_sidepanel_mcp_no_servers_with_vars')}
|
||||||
|
|
@ -108,7 +92,7 @@ function MCPPanelContent() {
|
||||||
|
|
||||||
if (selectedServerNameForEditing) {
|
if (selectedServerNameForEditing) {
|
||||||
// Editing View
|
// Editing View
|
||||||
const serverBeingEdited = mcpServerDefinitions.find(
|
const serverBeingEdited = availableMCPServers.find(
|
||||||
(s) => s.serverName === selectedServerNameForEditing,
|
(s) => s.serverName === selectedServerNameForEditing,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -140,7 +124,7 @@ function MCPPanelContent() {
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<CustomUserVarsSection
|
<CustomUserVarsSection
|
||||||
serverName={selectedServerNameForEditing}
|
serverName={selectedServerNameForEditing}
|
||||||
fields={serverBeingEdited.config.customUserVars}
|
fields={serverBeingEdited.config.customUserVars || {}}
|
||||||
onSave={(authData) => {
|
onSave={(authData) => {
|
||||||
if (selectedServerNameForEditing) {
|
if (selectedServerNameForEditing) {
|
||||||
handleConfigSave(selectedServerNameForEditing, authData);
|
handleConfigSave(selectedServerNameForEditing, authData);
|
||||||
|
|
@ -184,7 +168,7 @@ function MCPPanelContent() {
|
||||||
return (
|
return (
|
||||||
<div className="h-auto max-w-full overflow-x-hidden py-2">
|
<div className="h-auto max-w-full overflow-x-hidden py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{mcpServerDefinitions.map((server) => {
|
{availableMCPServers.map((server) => {
|
||||||
const serverStatus = connectionStatus?.[server.serverName];
|
const serverStatus = connectionStatus?.[server.serverName];
|
||||||
const isConnected = serverStatus?.connectionState === 'connected';
|
const isConnected = serverStatus?.connectionState === 'connected';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ function MCPToolSelectDialog({
|
||||||
const { initializeServer } = useMCPServerManager();
|
const { initializeServer } = useMCPServerManager();
|
||||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
const { removeTool } = useRemoveMCPTool({ showToast: false });
|
const { removeTool } = useRemoveMCPTool({ showToast: false });
|
||||||
const { mcpServersMap, startupConfig } = useAgentPanelContext();
|
const { mcpServersMap, availableMCPServersMap } = useAgentPanelContext();
|
||||||
const { refetch: refetchMCPTools } = useMCPToolsQuery({
|
const { refetch: refetchMCPTools } = useMCPToolsQuery({
|
||||||
enabled: mcpServersMap.size > 0,
|
enabled: mcpServersMap.size > 0,
|
||||||
});
|
});
|
||||||
|
|
@ -191,7 +191,7 @@ function MCPToolSelectDialog({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverConfig = startupConfig?.mcpServers?.[serverName];
|
const serverConfig = availableMCPServersMap?.[serverName];
|
||||||
const hasCustomUserVars =
|
const hasCustomUserVars =
|
||||||
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
|
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
|
||||||
|
|
||||||
|
|
@ -300,7 +300,7 @@ function MCPToolSelectDialog({
|
||||||
<CustomUserVarsSection
|
<CustomUserVarsSection
|
||||||
serverName={configuringServer}
|
serverName={configuringServer}
|
||||||
isSubmitting={isSavingCustomVars}
|
isSubmitting={isSavingCustomVars}
|
||||||
fields={startupConfig?.mcpServers?.[configuringServer]?.customUserVars || {}}
|
fields={availableMCPServersMap?.[configuringServer]?.customUserVars || {}}
|
||||||
onSave={(authData) => handleSaveCustomVars(configuringServer, authData)}
|
onSave={(authData) => handleSaveCustomVars(configuringServer, authData)}
|
||||||
onRevoke={() => handleRevokeCustomVars(configuringServer)}
|
onRevoke={() => handleRevokeCustomVars(configuringServer)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
1
client/src/data-provider/MCP/index.ts
Normal file
1
client/src/data-provider/MCP/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './queries';
|
||||||
44
client/src/data-provider/MCP/queries.ts
Normal file
44
client/src/data-provider/MCP/queries.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useQuery, UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
|
||||||
|
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||||
|
import type * as t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching all accessible MCP servers with permission metadata
|
||||||
|
*/
|
||||||
|
export const useMCPServersQuery = <TData = t.MCPServersListResponse>(
|
||||||
|
config?: UseQueryOptions<t.MCPServersListResponse, unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
return useQuery<t.MCPServersListResponse, unknown, TData>(
|
||||||
|
[QueryKeys.mcpServers],
|
||||||
|
() => dataService.getMCPServers(),
|
||||||
|
{
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes - data stays fresh longer
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
retry: false,
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching MCP-specific tools
|
||||||
|
* @param config - React Query configuration
|
||||||
|
* @returns MCP servers with their tools
|
||||||
|
*/
|
||||||
|
export const useMCPToolsQuery = <TData = t.MCPServersResponse>(
|
||||||
|
config?: UseQueryOptions<t.MCPServersResponse, unknown, TData>,
|
||||||
|
): QueryObserverResult<TData> => {
|
||||||
|
return useQuery<t.MCPServersResponse, unknown, TData>(
|
||||||
|
[QueryKeys.mcpTools],
|
||||||
|
() => dataService.getMCPTools(),
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -11,6 +11,6 @@ export * from './connection';
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
export * from './prompts';
|
export * from './prompts';
|
||||||
export * from './queries';
|
export * from './queries';
|
||||||
export * from './mcp';
|
|
||||||
export * from './roles';
|
export * from './roles';
|
||||||
export * from './tags';
|
export * from './tags';
|
||||||
|
export * from './MCP';
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
/**
|
|
||||||
* Dedicated queries for MCP (Model Context Protocol) tools
|
|
||||||
* Decoupled from regular LibreChat tools
|
|
||||||
*/
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
|
||||||
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
|
|
||||||
import type { MCPServersResponse } from 'librechat-data-provider';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for fetching MCP-specific tools
|
|
||||||
* @param config - React Query configuration
|
|
||||||
* @returns MCP servers with their tools
|
|
||||||
*/
|
|
||||||
export const useMCPToolsQuery = <TData = MCPServersResponse>(
|
|
||||||
config?: UseQueryOptions<MCPServersResponse, unknown, TData>,
|
|
||||||
): QueryObserverResult<TData> => {
|
|
||||||
return useQuery<MCPServersResponse, unknown, TData>(
|
|
||||||
[QueryKeys.mcpTools],
|
|
||||||
() => dataService.getMCPTools(),
|
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
...config,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -74,7 +74,6 @@ export function useApplyAgentTemplate() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge model spec fields into ephemeral agent
|
|
||||||
const mergedAgent = {
|
const mergedAgent = {
|
||||||
...ephemeralAgent,
|
...ephemeralAgent,
|
||||||
mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])],
|
mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])],
|
||||||
|
|
@ -83,7 +82,6 @@ export function useApplyAgentTemplate() {
|
||||||
execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false,
|
execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deduplicate MCP servers
|
|
||||||
mergedAgent.mcp = [...new Set(mergedAgent.mcp)];
|
mergedAgent.mcp = [...new Set(mergedAgent.mcp)];
|
||||||
|
|
||||||
applyAgentTemplate(targetId, sourceId, mergedAgent);
|
applyAgentTemplate(targetId, sourceId, mergedAgent);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
|
||||||
import type { TStartupConfig, TUser } from 'librechat-data-provider';
|
import type { TStartupConfig, TUser } from 'librechat-data-provider';
|
||||||
import { cleanupTimestampedStorage } from '~/utils/timestamps';
|
import { cleanupTimestampedStorage } from '~/utils/timestamps';
|
||||||
import useSpeechSettingsInit from './useSpeechSettingsInit';
|
import useSpeechSettingsInit from './useSpeechSettingsInit';
|
||||||
import { useMCPToolsQuery } from '~/data-provider';
|
import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function useAppStartup({
|
export default function useAppStartup({
|
||||||
|
|
@ -18,9 +18,10 @@ export default function useAppStartup({
|
||||||
const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset);
|
const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset);
|
||||||
|
|
||||||
useSpeechSettingsInit(!!user);
|
useSpeechSettingsInit(!!user);
|
||||||
|
const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery();
|
||||||
|
|
||||||
useMCPToolsQuery({
|
useMCPToolsQuery({
|
||||||
enabled: !!startupConfig?.mcpServers && !!user,
|
enabled: !serversLoading && !!loadedServers && Object.keys(loadedServers).length > 0 && !!user,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Clean up old localStorage entries on startup */
|
/** Clean up old localStorage entries on startup */
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||||
import { ephemeralAgentByConvoId } from '~/store';
|
import { ephemeralAgentByConvoId } from '~/store';
|
||||||
import { setTimestamp } from '~/utils/timestamps';
|
import { setTimestamp } from '~/utils/timestamps';
|
||||||
import { useMCPSelect } from '../useMCPSelect';
|
import { useMCPSelect } from '../useMCPSelect';
|
||||||
import * as dataProvider from '~/data-provider';
|
import { MCPServerDefinition } from '../useMCPServerManager';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
jest.mock('~/utils/timestamps', () => ({
|
jest.mock('~/utils/timestamps', () => ({
|
||||||
|
|
@ -15,27 +15,28 @@ jest.mock('~/utils/timestamps', () => ({
|
||||||
|
|
||||||
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
|
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
|
||||||
|
|
||||||
jest.mock('~/data-provider', () => ({
|
// Helper to create MCPServerDefinition objects
|
||||||
...jest.requireActual('~/data-provider'),
|
const createMCPServers = (serverNames: string[]): MCPServerDefinition[] => {
|
||||||
useGetStartupConfig: jest.fn(),
|
return serverNames.map((serverName) => ({
|
||||||
}));
|
serverName,
|
||||||
|
config: {
|
||||||
|
url: 'http://mcp',
|
||||||
|
},
|
||||||
|
effectivePermissions: 15, // All permissions (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const createWrapper = (mcpServers: string[] = []) => {
|
const createWrapper = (mcpServers: string[] = []) => {
|
||||||
// Create a new Jotai store for each test to ensure clean state
|
// Create a new Jotai store for each test to ensure clean state
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
|
const servers = createMCPServers(mcpServers);
|
||||||
// Mock the startup config
|
|
||||||
(dataProvider.useGetStartupConfig as jest.Mock).mockReturnValue({
|
|
||||||
data: { mcpServers: Object.fromEntries(mcpServers.map((v) => [v, {}])) },
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
<RecoilRoot>
|
<RecoilRoot>
|
||||||
<Provider store={store}>{children}</Provider>
|
<Provider store={store}>{children}</Provider>
|
||||||
</RecoilRoot>
|
</RecoilRoot>
|
||||||
);
|
);
|
||||||
return Wrapper;
|
return { Wrapper, servers };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('useMCPSelect', () => {
|
describe('useMCPSelect', () => {
|
||||||
|
|
@ -46,8 +47,9 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
describe('Basic Functionality', () => {
|
describe('Basic Functionality', () => {
|
||||||
it('should initialize with default values', () => {
|
it('should initialize with default values', () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.mcpValues).toEqual([]);
|
expect(result.current.mcpValues).toEqual([]);
|
||||||
|
|
@ -58,16 +60,18 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
it('should use conversationId when provided', () => {
|
it('should use conversationId when provided', () => {
|
||||||
const conversationId = 'test-convo-123';
|
const conversationId = 'test-convo-123';
|
||||||
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.mcpValues).toEqual([]);
|
expect(result.current.mcpValues).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use NEW_CONVO constant when conversationId is null', () => {
|
it('should use NEW_CONVO constant when conversationId is null', () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({ conversationId: null }), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ conversationId: null, servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.mcpValues).toEqual([]);
|
expect(result.current.mcpValues).toEqual([]);
|
||||||
|
|
@ -76,8 +80,9 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
describe('State Updates', () => {
|
describe('State Updates', () => {
|
||||||
it('should update mcpValues when setMCPValues is called', async () => {
|
it('should update mcpValues when setMCPValues is called', async () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper(['value1', 'value2']);
|
||||||
wrapper: createWrapper(['value1', 'value2']),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newValues = ['value1', 'value2'];
|
const newValues = ['value1', 'value2'];
|
||||||
|
|
@ -92,8 +97,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not update mcpValues if non-array is passed', () => {
|
it('should not update mcpValues if non-array is passed', () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -105,8 +111,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update isPinned state', () => {
|
it('should update isPinned state', () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default is true
|
// Default is true
|
||||||
|
|
@ -131,8 +138,9 @@ describe('useMCPSelect', () => {
|
||||||
describe('Timestamp Management', () => {
|
describe('Timestamp Management', () => {
|
||||||
it('should set timestamp when mcpValues is updated with values', async () => {
|
it('should set timestamp when mcpValues is updated with values', async () => {
|
||||||
const conversationId = 'test-convo';
|
const conversationId = 'test-convo';
|
||||||
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newValues = ['value1', 'value2'];
|
const newValues = ['value1', 'value2'];
|
||||||
|
|
@ -148,8 +156,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set timestamp when mcpValues is empty', async () => {
|
it('should not set timestamp when mcpValues is empty', async () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -164,8 +173,9 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
describe('Race Conditions and Infinite Loops Prevention', () => {
|
describe('Race Conditions and Infinite Loops Prevention', () => {
|
||||||
it('should not create infinite loop when syncing between Jotai and Recoil states', async () => {
|
it('should not create infinite loop when syncing between Jotai and Recoil states', async () => {
|
||||||
const { result, rerender } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result, rerender } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
let renderCount = 0;
|
let renderCount = 0;
|
||||||
|
|
@ -196,8 +206,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle rapid consecutive updates without race conditions', async () => {
|
it('should handle rapid consecutive updates without race conditions', async () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updates = [
|
const updates = [
|
||||||
|
|
@ -222,8 +233,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should maintain stable setter function reference', () => {
|
it('should maintain stable setter function reference', () => {
|
||||||
const { result, rerender } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result, rerender } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstSetMCPValues = result.current.setMCPValues;
|
const firstSetMCPValues = result.current.setMCPValues;
|
||||||
|
|
@ -238,10 +250,11 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle switching conversation IDs without issues', async () => {
|
it('should handle switching conversation IDs without issues', async () => {
|
||||||
|
const { Wrapper, servers } = createWrapper(['convo1-value', 'convo2-value']);
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ conversationId }) => useMCPSelect({ conversationId }),
|
({ conversationId }) => useMCPSelect({ conversationId, servers }),
|
||||||
{
|
{
|
||||||
wrapper: createWrapper(['convo1-value', 'convo2-value']),
|
wrapper: Wrapper,
|
||||||
initialProps: { conversationId: 'convo1' },
|
initialProps: { conversationId: 'convo1' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -283,18 +296,18 @@ describe('useMCPSelect', () => {
|
||||||
describe('Ephemeral Agent Synchronization', () => {
|
describe('Ephemeral Agent Synchronization', () => {
|
||||||
it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
|
it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
|
||||||
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context
|
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context
|
||||||
const wrapper = createWrapper(['external-value1', 'external-value2']);
|
const { Wrapper, servers } = createWrapper(['external-value1', 'external-value2']);
|
||||||
|
|
||||||
// Create a component that uses both hooks to ensure they share state
|
// Create a component that uses both hooks to ensure they share state
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
|
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
|
||||||
ephemeralAgentByConvoId(Constants.NEW_CONVO),
|
ephemeralAgentByConvoId(Constants.NEW_CONVO),
|
||||||
);
|
);
|
||||||
return { mcpHook, ephemeralAgent, setEphemeralAgent };
|
return { mcpHook, ephemeralAgent, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
// Simulate external update to ephemeralAgent (e.g., from another component)
|
// Simulate external update to ephemeralAgent (e.g., from another component)
|
||||||
const externalMcpValues = ['external-value1', 'external-value2'];
|
const externalMcpValues = ['external-value1', 'external-value2'];
|
||||||
|
|
@ -311,15 +324,15 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out MCPs not in configured servers', async () => {
|
it('should filter out MCPs not in configured servers', async () => {
|
||||||
const wrapper = createWrapper(['server1', 'server2']);
|
const { Wrapper, servers } = createWrapper(['server1', 'server2']);
|
||||||
|
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, setEphemeralAgent };
|
return { mcpHook, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setEphemeralAgent({
|
result.current.setEphemeralAgent({
|
||||||
|
|
@ -333,15 +346,15 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear all MCPs when none are in configured servers', async () => {
|
it('should clear all MCPs when none are in configured servers', async () => {
|
||||||
const wrapper = createWrapper(['server1', 'server2']);
|
const { Wrapper, servers } = createWrapper(['server1', 'server2']);
|
||||||
|
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, setEphemeralAgent };
|
return { mcpHook, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setEphemeralAgent({
|
result.current.setEphemeralAgent({
|
||||||
|
|
@ -355,15 +368,15 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should keep all MCPs when all are in configured servers', async () => {
|
it('should keep all MCPs when all are in configured servers', async () => {
|
||||||
const wrapper = createWrapper(['server1', 'server2', 'server3']);
|
const { Wrapper, servers } = createWrapper(['server1', 'server2', 'server3']);
|
||||||
|
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, setEphemeralAgent };
|
return { mcpHook, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setEphemeralAgent({
|
result.current.setEphemeralAgent({
|
||||||
|
|
@ -378,16 +391,16 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
it('should update ephemeralAgent when mcpValues changes through hook', async () => {
|
it('should update ephemeralAgent when mcpValues changes through hook', async () => {
|
||||||
// Create a shared wrapper for both hooks
|
// Create a shared wrapper for both hooks
|
||||||
const wrapper = createWrapper(['hook-value1', 'hook-value2']);
|
const { Wrapper, servers } = createWrapper(['hook-value1', 'hook-value2']);
|
||||||
|
|
||||||
// Create a component that uses both the hook and accesses Recoil state
|
// Create a component that uses both the hook and accesses Recoil state
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, ephemeralAgent };
|
return { mcpHook, ephemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
const newValues = ['hook-value1', 'hook-value2'];
|
const newValues = ['hook-value1', 'hook-value2'];
|
||||||
|
|
||||||
|
|
@ -404,16 +417,16 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
it('should handle empty ephemeralAgent.mcp array correctly', async () => {
|
it('should handle empty ephemeralAgent.mcp array correctly', async () => {
|
||||||
// Create a shared wrapper
|
// Create a shared wrapper
|
||||||
const wrapper = createWrapper(['initial-value']);
|
const { Wrapper, servers } = createWrapper(['initial-value']);
|
||||||
|
|
||||||
// Create a component that uses both hooks
|
// Create a component that uses both hooks
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, setEphemeralAgent };
|
return { mcpHook, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
// Set initial values
|
// Set initial values
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -438,16 +451,16 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
it('should handle ephemeralAgent with clear mcp value', async () => {
|
it('should handle ephemeralAgent with clear mcp value', async () => {
|
||||||
// Create a shared wrapper
|
// Create a shared wrapper
|
||||||
const wrapper = createWrapper(['server1', 'server2']);
|
const { Wrapper, servers } = createWrapper(['server1', 'server2']);
|
||||||
|
|
||||||
// Create a component that uses both hooks
|
// Create a component that uses both hooks
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, setEphemeralAgent };
|
return { mcpHook, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
// Set initial values
|
// Set initial values
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -473,15 +486,21 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
it('should properly sync non-empty arrays from ephemeralAgent', async () => {
|
it('should properly sync non-empty arrays from ephemeralAgent', async () => {
|
||||||
// Additional test to ensure non-empty arrays DO sync
|
// Additional test to ensure non-empty arrays DO sync
|
||||||
const wrapper = createWrapper(['value1', 'value2', 'value3', 'value4', 'value5']);
|
const { Wrapper, servers } = createWrapper([
|
||||||
|
'value1',
|
||||||
|
'value2',
|
||||||
|
'value3',
|
||||||
|
'value4',
|
||||||
|
'value5',
|
||||||
|
]);
|
||||||
|
|
||||||
const TestComponent = () => {
|
const TestComponent = () => {
|
||||||
const mcpHook = useMCPSelect({});
|
const mcpHook = useMCPSelect({ servers });
|
||||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||||
return { mcpHook, setEphemeralAgent };
|
return { mcpHook, setEphemeralAgent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||||
|
|
||||||
// Set initial values through ephemeralAgent with non-empty array
|
// Set initial values through ephemeralAgent with non-empty array
|
||||||
const initialValues = ['value1', 'value2'];
|
const initialValues = ['value1', 'value2'];
|
||||||
|
|
@ -513,8 +532,9 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle undefined conversationId', () => {
|
it('should handle undefined conversationId', () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), {
|
const { Wrapper, servers } = createWrapper(['test']);
|
||||||
wrapper: createWrapper(['test']),
|
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined, servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.mcpValues).toEqual([]);
|
expect(result.current.mcpValues).toEqual([]);
|
||||||
|
|
@ -527,8 +547,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty string conversationId', () => {
|
it('should handle empty string conversationId', () => {
|
||||||
const { result } = renderHook(() => useMCPSelect({ conversationId: '' }), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result } = renderHook(() => useMCPSelect({ conversationId: '', servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.mcpValues).toEqual([]);
|
expect(result.current.mcpValues).toEqual([]);
|
||||||
|
|
@ -536,8 +557,9 @@ describe('useMCPSelect', () => {
|
||||||
|
|
||||||
it('should handle very large arrays without performance issues', async () => {
|
it('should handle very large arrays without performance issues', async () => {
|
||||||
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
|
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper(largeArray);
|
||||||
wrapper: createWrapper(largeArray),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
@ -558,8 +580,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should cleanup properly on unmount', () => {
|
it('should cleanup properly on unmount', () => {
|
||||||
const { unmount } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { unmount } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should unmount without errors
|
// Should unmount without errors
|
||||||
|
|
@ -570,8 +593,9 @@ describe('useMCPSelect', () => {
|
||||||
describe('Memory Leak Prevention', () => {
|
describe('Memory Leak Prevention', () => {
|
||||||
it('should not leak memory on repeated updates', async () => {
|
it('should not leak memory on repeated updates', async () => {
|
||||||
const values = Array.from({ length: 100 }, (_, i) => `value-${i}`);
|
const values = Array.from({ length: 100 }, (_, i) => `value-${i}`);
|
||||||
const { result } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper(values);
|
||||||
wrapper: createWrapper(values),
|
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Perform many updates to test for memory leaks
|
// Perform many updates to test for memory leaks
|
||||||
|
|
@ -586,8 +610,9 @@ describe('useMCPSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle component remounting', () => {
|
it('should handle component remounting', () => {
|
||||||
const { result, unmount } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper, servers } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result, unmount } = renderHook(() => useMCPSelect({ servers }), {
|
||||||
|
wrapper: Wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|
@ -597,8 +622,9 @@ describe('useMCPSelect', () => {
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
// Remount
|
// Remount
|
||||||
const { result: newResult } = renderHook(() => useMCPSelect({}), {
|
const { Wrapper: Wrapper2, servers: servers2 } = createWrapper();
|
||||||
wrapper: createWrapper(),
|
const { result: newResult } = renderHook(() => useMCPSelect({ servers: servers2 }), {
|
||||||
|
wrapper: Wrapper2,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should handle remounting gracefully
|
// Should handle remounting gracefully
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,20 @@ import isEqual from 'lodash/isEqual';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||||
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
|
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
|
||||||
import { setTimestamp } from '~/utils/timestamps';
|
import { setTimestamp } from '~/utils/timestamps';
|
||||||
|
import { MCPServerDefinition } from './useMCPServerManager';
|
||||||
|
|
||||||
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
|
export function useMCPSelect({
|
||||||
|
conversationId,
|
||||||
|
servers,
|
||||||
|
}: {
|
||||||
|
conversationId?: string | null;
|
||||||
|
servers: MCPServerDefinition[];
|
||||||
|
}) {
|
||||||
const key = conversationId ?? Constants.NEW_CONVO;
|
const key = conversationId ?? Constants.NEW_CONVO;
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
|
||||||
const configuredServers = useMemo(() => {
|
const configuredServers = useMemo(() => {
|
||||||
return new Set(Object.keys(startupConfig?.mcpServers ?? {}));
|
return new Set(servers?.map((s) => s.serverName));
|
||||||
}, [startupConfig?.mcpServers]);
|
}, [servers]);
|
||||||
|
|
||||||
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
|
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
|
||||||
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
|
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
|
import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useToastContext } from '@librechat/client';
|
import { useToastContext } from '@librechat/client';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
import { Constants, QueryKeys, MCPOptions } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
useCancelMCPOAuthMutation,
|
useCancelMCPOAuthMutation,
|
||||||
useUpdateUserPluginsMutation,
|
useUpdateUserPluginsMutation,
|
||||||
|
|
@ -10,7 +10,15 @@ import {
|
||||||
import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
|
import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
|
||||||
import type { ConfigFieldDetail } from '~/common';
|
import type { ConfigFieldDetail } from '~/common';
|
||||||
import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
|
import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
|
||||||
|
|
||||||
|
export interface MCPServerDefinition {
|
||||||
|
serverName: string;
|
||||||
|
config: MCPOptions;
|
||||||
|
mcp_id?: string;
|
||||||
|
_id?: string; // MongoDB ObjectId for database servers (used for permissions)
|
||||||
|
effectivePermissions: number; // Permission bits (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
|
|
@ -24,12 +32,40 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig(); // Keep for UI config only
|
||||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
|
|
||||||
|
const { data: loadedServers, isLoading } = useMCPServersQuery();
|
||||||
|
|
||||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const configuredServers = useMemo(() => {
|
||||||
|
if (!loadedServers) return [];
|
||||||
|
return Object.keys(loadedServers).filter((name) => loadedServers[name]?.chatMenu !== false);
|
||||||
|
}, [loadedServers]);
|
||||||
|
|
||||||
|
const availableMCPServers: MCPServerDefinition[] = useMemo<MCPServerDefinition[]>(() => {
|
||||||
|
const definitions: MCPServerDefinition[] = [];
|
||||||
|
if (loadedServers) {
|
||||||
|
for (const [serverName, metadata] of Object.entries(loadedServers)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { _id, mcp_id, effectivePermissions, author, updatedAt, createdAt, ...config } =
|
||||||
|
metadata;
|
||||||
|
definitions.push({
|
||||||
|
serverName,
|
||||||
|
mcp_id,
|
||||||
|
effectivePermissions: effectivePermissions || 1,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return definitions;
|
||||||
|
}, [loadedServers]);
|
||||||
|
|
||||||
|
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({
|
||||||
|
conversationId,
|
||||||
|
servers: availableMCPServers,
|
||||||
|
});
|
||||||
const mcpValuesRef = useRef(mcpValues);
|
const mcpValuesRef = useRef(mcpValues);
|
||||||
|
|
||||||
// fixes the issue where OAuth flows would deselect all the servers except the one that is being authenticated on success
|
// fixes the issue where OAuth flows would deselect all the servers except the one that is being authenticated on success
|
||||||
|
|
@ -37,13 +73,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
mcpValuesRef.current = mcpValues;
|
mcpValuesRef.current = mcpValues;
|
||||||
}, [mcpValues]);
|
}, [mcpValues]);
|
||||||
|
|
||||||
const configuredServers = useMemo(() => {
|
|
||||||
if (!startupConfig?.mcpServers) return [];
|
|
||||||
return Object.entries(startupConfig.mcpServers)
|
|
||||||
.filter(([, config]) => config.chatMenu !== false)
|
|
||||||
.map(([serverName]) => serverName);
|
|
||||||
}, [startupConfig?.mcpServers]);
|
|
||||||
|
|
||||||
const reinitializeMutation = useReinitializeMCPServerMutation();
|
const reinitializeMutation = useReinitializeMCPServerMutation();
|
||||||
const cancelOAuthMutation = useCancelMCPOAuthMutation();
|
const cancelOAuthMutation = useCancelMCPOAuthMutation();
|
||||||
|
|
||||||
|
|
@ -52,6 +81,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpServers]),
|
||||||
queryClient.invalidateQueries([QueryKeys.mcpTools]),
|
queryClient.invalidateQueries([QueryKeys.mcpTools]),
|
||||||
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
|
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
|
||||||
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
|
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
|
||||||
|
|
@ -81,7 +111,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
});
|
});
|
||||||
|
|
||||||
const { connectionStatus } = useMCPConnectionStatus({
|
const { connectionStatus } = useMCPConnectionStatus({
|
||||||
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
|
enabled: !isLoading && configuredServers.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
|
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
|
||||||
|
|
@ -289,7 +319,12 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
|
|
||||||
startServerPolling(serverName);
|
startServerPolling(serverName);
|
||||||
} else {
|
} else {
|
||||||
await queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpServers]),
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpTools]),
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
|
||||||
|
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
|
||||||
|
]);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
|
message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
|
||||||
|
|
@ -494,7 +529,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
]);
|
]);
|
||||||
const serverData = mcpData?.servers?.[serverName];
|
const serverData = mcpData?.servers?.[serverName];
|
||||||
const serverStatus = connectionStatus?.[serverName];
|
const serverStatus = connectionStatus?.[serverName];
|
||||||
const serverConfig = startupConfig?.mcpServers?.[serverName];
|
const serverConfig = loadedServers?.[serverName];
|
||||||
|
|
||||||
const handleConfigClick = (e: React.MouseEvent) => {
|
const handleConfigClick = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -548,14 +583,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
hasCustomUserVars,
|
hasCustomUserVars,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[
|
[queryClient, isCancellable, isInitializing, cancelOAuthFlow, connectionStatus, loadedServers],
|
||||||
queryClient,
|
|
||||||
isCancellable,
|
|
||||||
isInitializing,
|
|
||||||
cancelOAuthFlow,
|
|
||||||
connectionStatus,
|
|
||||||
startupConfig?.mcpServers,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getConfigDialogProps = useCallback(() => {
|
const getConfigDialogProps = useCallback(() => {
|
||||||
|
|
@ -600,7 +628,10 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configuredServers,
|
availableMCPServers,
|
||||||
|
availableMCPServersMap: loadedServers,
|
||||||
|
isLoading,
|
||||||
|
connectionStatus,
|
||||||
initializeServer,
|
initializeServer,
|
||||||
cancelOAuthFlow,
|
cancelOAuthFlow,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
||||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||||
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useHasAccess, useMCPServerManager } from '~/hooks';
|
||||||
import { useHasAccess } from '~/hooks';
|
|
||||||
|
|
||||||
export default function useSideNavLinks({
|
export default function useSideNavLinks({
|
||||||
hidePanel,
|
hidePanel,
|
||||||
|
|
@ -61,7 +60,8 @@ export default function useSideNavLinks({
|
||||||
permissionType: PermissionTypes.AGENTS,
|
permissionType: PermissionTypes.AGENTS,
|
||||||
permission: Permissions.CREATE,
|
permission: Permissions.CREATE,
|
||||||
});
|
});
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
|
||||||
|
const { availableMCPServers } = useMCPServerManager();
|
||||||
|
|
||||||
const Links = useMemo(() => {
|
const Links = useMemo(() => {
|
||||||
const links: NavLink[] = [];
|
const links: NavLink[] = [];
|
||||||
|
|
@ -153,12 +153,12 @@ export default function useSideNavLinks({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
startupConfig?.mcpServers &&
|
availableMCPServers &&
|
||||||
Object.values(startupConfig.mcpServers).some(
|
availableMCPServers.some(
|
||||||
(server: any) =>
|
(server: any) =>
|
||||||
(server.customUserVars && Object.keys(server.customUserVars).length > 0) ||
|
(server.config.customUserVars && Object.keys(server.config.customUserVars).length > 0) ||
|
||||||
server.isOAuth ||
|
server.config.isOAuth ||
|
||||||
server.startup === false,
|
server.config.startup === false,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
links.push({
|
links.push({
|
||||||
|
|
@ -192,7 +192,7 @@ export default function useSideNavLinks({
|
||||||
hasAccessToBookmarks,
|
hasAccessToBookmarks,
|
||||||
hasAccessToCreateAgents,
|
hasAccessToCreateAgents,
|
||||||
hidePanel,
|
hidePanel,
|
||||||
startupConfig,
|
availableMCPServers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Links;
|
return Links;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@
|
||||||
"e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets",
|
"e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets",
|
||||||
"test:client": "cd client && npm run test:ci",
|
"test:client": "cd client && npm run test:ci",
|
||||||
"test:api": "cd api && npm run test:ci",
|
"test:api": "cd api && npm run test:ci",
|
||||||
|
"test:packages:api": "cd packages/api && npm run test:ci",
|
||||||
|
"test:packages:data-provider": "cd packages/data-provider && npm run test:ci",
|
||||||
|
"test:packages:data-schemas": "cd packages/data-schemas && npm run test:ci",
|
||||||
|
"test:all": "npm run test:client && npm run test:api && npm run test:packages:api && npm run test:packages:data-provider && npm run test:packages:data-schemas",
|
||||||
"e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots",
|
"e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots",
|
||||||
"e2e:report": "npx playwright show-report e2e/playwright-report",
|
"e2e:report": "npx playwright show-report e2e/playwright-report",
|
||||||
"lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
|
"lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,6 @@ export class MCPServersInitializer {
|
||||||
public static async initialize(rawConfigs: t.MCPServers): Promise<void> {
|
public static async initialize(rawConfigs: t.MCPServers): Promise<void> {
|
||||||
if (await statusCache.isInitialized()) return;
|
if (await statusCache.isInitialized()) return;
|
||||||
|
|
||||||
/** Store raw configs immediately so they're available even if initialization fails/is slow */
|
|
||||||
registry.setRawConfigs(rawConfigs);
|
|
||||||
|
|
||||||
if (await isLeader()) {
|
if (await isLeader()) {
|
||||||
// Leader performs initialization
|
// Leader performs initialization
|
||||||
await statusCache.reset();
|
await statusCache.reset();
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,6 @@ class MCPServersRegistry {
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores the raw MCP configuration as a fallback when servers haven't been initialized yet.
|
|
||||||
* Should be called during initialization before inspecting servers.
|
|
||||||
*/
|
|
||||||
public setRawConfigs(configs: t.MCPServers): void {
|
|
||||||
this.rawConfigs = configs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly privateServersCache: PrivateServerConfigsCache =
|
public readonly privateServersCache: PrivateServerConfigsCache =
|
||||||
PrivateServerConfigsCacheFactory.create();
|
PrivateServerConfigsCacheFactory.create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
|
||||||
|
|
||||||
export const mcp = {
|
export const mcp = {
|
||||||
tools: `${BASE_URL}/api/mcp/tools`,
|
tools: `${BASE_URL}/api/mcp/tools`,
|
||||||
|
servers: `${BASE_URL}/api/mcp/servers`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import * as ag from './types/agents';
|
||||||
import * as m from './types/mutations';
|
import * as m from './types/mutations';
|
||||||
import * as q from './types/queries';
|
import * as q from './types/queries';
|
||||||
import * as f from './types/files';
|
import * as f from './types/files';
|
||||||
|
import * as mcp from './types/mcpServers';
|
||||||
import * as config from './config';
|
import * as config from './config';
|
||||||
import request from './request';
|
import request from './request';
|
||||||
import * as s from './schemas';
|
import * as s from './schemas';
|
||||||
|
|
@ -538,6 +539,18 @@ export const deleteAgentAction = async ({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Servers
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Ensure and List loaded mcp server configs from the cache Enriched with effective permissions.
|
||||||
|
*/
|
||||||
|
export const getMCPServers = async (): Promise<mcp.MCPServersListResponse> => {
|
||||||
|
return request.get(endpoints.mcp.servers);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports a conversations file.
|
* Imports a conversations file.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export enum QueryKeys {
|
||||||
resourcePermissions = 'resourcePermissions',
|
resourcePermissions = 'resourcePermissions',
|
||||||
effectivePermissions = 'effectivePermissions',
|
effectivePermissions = 'effectivePermissions',
|
||||||
graphToken = 'graphToken',
|
graphToken = 'graphToken',
|
||||||
|
/* MCP Servers */
|
||||||
|
mcpServers = 'mcpServers',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic query keys that require parameters
|
// Dynamic query keys that require parameters
|
||||||
|
|
|
||||||
|
|
@ -180,3 +180,32 @@ export const MCPOptionsSchema = z.union([
|
||||||
export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema);
|
export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema);
|
||||||
|
|
||||||
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to omit server-managed fields that should not come from UI
|
||||||
|
*/
|
||||||
|
const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
|
||||||
|
schema.omit({
|
||||||
|
startup: true,
|
||||||
|
timeout: true,
|
||||||
|
initTimeout: true,
|
||||||
|
chatMenu: true,
|
||||||
|
serverInstructions: true,
|
||||||
|
requiresOAuth: true,
|
||||||
|
customUserVars: true,
|
||||||
|
oauth_headers: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Server configuration that comes from UI input only
|
||||||
|
* Omits server-managed fields like startup, timeout, customUserVars, etc.
|
||||||
|
* Allows: title, description, url, iconPath, oauth (user credentials)
|
||||||
|
*/
|
||||||
|
export const MCPServerUserInputSchema = z.union([
|
||||||
|
omitServerManagedFields(StdioOptionsSchema),
|
||||||
|
omitServerManagedFields(WebSocketOptionsSchema),
|
||||||
|
omitServerManagedFields(SSEOptionsSchema),
|
||||||
|
omitServerManagedFields(StreamableHTTPOptionsSchema),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { MCPOptions } from '../mcp';
|
import { PermissionBits } from '../accessPermissions';
|
||||||
|
import type { MCPOptions, MCPServerUserInput } from '../mcp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base MCP Server interface
|
* Base MCP Server interface
|
||||||
|
|
@ -19,3 +20,33 @@ export interface IMCPServerDB {
|
||||||
* Similar to Agent type - includes populated author fields
|
* Similar to Agent type - includes populated author fields
|
||||||
*/
|
*/
|
||||||
export type MCPServerDB = IMCPServerDB;
|
export type MCPServerDB = IMCPServerDB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for creating a new user-managed MCP server
|
||||||
|
* Note: Only UI-editable fields are allowed (excludes server-managed fields)
|
||||||
|
*/
|
||||||
|
export type MCPServerCreateParams = {
|
||||||
|
config: MCPServerUserInput; // UI fields only (title, description, url, oauth, iconPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for updating an existing user-managed MCP server
|
||||||
|
* Note: Only UI-editable fields are allowed (excludes server-managed fields)
|
||||||
|
*/
|
||||||
|
export type MCPServerUpdateParams = {
|
||||||
|
config?: MCPServerUserInput; // UI fields only (title, description, url, oauth, iconPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for MCP server list endpoint
|
||||||
|
*/
|
||||||
|
export type MCPServerDBObjectResponse = {
|
||||||
|
_id?: string;
|
||||||
|
mcp_id?: string;
|
||||||
|
author?: string | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
effectivePermissions?: PermissionBits;
|
||||||
|
} & MCPOptions;
|
||||||
|
|
||||||
|
export type MCPServersListResponse = Record<string, MCPServerDBObjectResponse>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue