mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 01:40:15 +01:00
✨ feat: Add MCP Reinitialization to MCPPanel
- Refactored tool caching to include user-specific tools in various service files. - Refactored MCPManager class for clarity - Added a new endpoint for reinitializing MCP servers, allowing for dynamic updates of server configurations. - Enhanced the MCPPanel component to support server reinitialization with user feedback.
This commit is contained in:
parent
62b4f3b795
commit
abafbfeefa
14 changed files with 508 additions and 196 deletions
|
|
@ -39,7 +39,7 @@ export class MCPManager {
|
|||
}
|
||||
|
||||
/** Stores configs and initializes app-level connections */
|
||||
public async initializeMCP({
|
||||
public async initializeMCPs({
|
||||
mcpServers,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
|
|
@ -60,173 +60,17 @@ export class MCPManager {
|
|||
const entries = Object.entries(mcpServers);
|
||||
const initializedServers = new Set();
|
||||
const connectionResults = await Promise.allSettled(
|
||||
entries.map(async ([serverName, _config], i) => {
|
||||
/** Process env for app-level connections */
|
||||
const config = processMCPEnv(_config);
|
||||
|
||||
/** Existing tokens for system-level connections */
|
||||
let tokens: MCPOAuthTokens | null = null;
|
||||
if (tokenMethods?.findToken) {
|
||||
try {
|
||||
/** Refresh function for app-level connections */
|
||||
const refreshTokensFunction = async (
|
||||
refreshToken: string,
|
||||
metadata: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
identifier: string;
|
||||
clientInfo?: OAuthClientInformation;
|
||||
},
|
||||
) => {
|
||||
/** URL from config if available */
|
||||
const serverUrl = (config as t.SSEOptions | t.StreamableHTTPOptions).url;
|
||||
return await MCPOAuthHandler.refreshOAuthTokens(
|
||||
refreshToken,
|
||||
{
|
||||
serverName: metadata.serverName,
|
||||
serverUrl,
|
||||
clientInfo: metadata.clientInfo,
|
||||
},
|
||||
config.oauth,
|
||||
);
|
||||
};
|
||||
|
||||
/** Flow state to prevent concurrent token operations */
|
||||
const tokenFlowId = `tokens:${CONSTANTS.SYSTEM_USER_ID}:${serverName}`;
|
||||
tokens = await flowManager.createFlowWithHandler(
|
||||
tokenFlowId,
|
||||
'mcp_get_tokens',
|
||||
async () => {
|
||||
return await MCPTokenStorage.getTokens({
|
||||
userId: CONSTANTS.SYSTEM_USER_ID,
|
||||
serverName,
|
||||
findToken: tokenMethods.findToken,
|
||||
refreshTokens: refreshTokensFunction,
|
||||
createToken: tokenMethods.createToken,
|
||||
updateToken: tokenMethods.updateToken,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
logger.debug(`[MCP][${serverName}] No existing tokens found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens) {
|
||||
logger.info(`[MCP][${serverName}] Loaded OAuth tokens`);
|
||||
}
|
||||
|
||||
const connection = new MCPConnection(serverName, config, undefined, tokens);
|
||||
|
||||
/** Listen for OAuth requirements */
|
||||
logger.info(`[MCP][${serverName}] Setting up OAuth event listener`);
|
||||
connection.on('oauthRequired', async (data) => {
|
||||
logger.debug(`[MCP][${serverName}] oauthRequired event received`);
|
||||
const result = await this.handleOAuthRequired({
|
||||
...data,
|
||||
flowManager,
|
||||
});
|
||||
|
||||
if (result?.tokens && tokenMethods?.createToken) {
|
||||
try {
|
||||
connection.setOAuthTokens(result.tokens);
|
||||
await MCPTokenStorage.storeTokens({
|
||||
userId: CONSTANTS.SYSTEM_USER_ID,
|
||||
serverName,
|
||||
tokens: result.tokens,
|
||||
createToken: tokenMethods.createToken,
|
||||
updateToken: tokenMethods.updateToken,
|
||||
findToken: tokenMethods.findToken,
|
||||
clientInfo: result.clientInfo,
|
||||
});
|
||||
logger.info(`[MCP][${serverName}] OAuth tokens saved to storage`);
|
||||
} catch (error) {
|
||||
logger.error(`[MCP][${serverName}] Failed to save OAuth tokens to storage`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Only emit oauthHandled if we actually got tokens (OAuth succeeded)
|
||||
if (result?.tokens) {
|
||||
connection.emit('oauthHandled');
|
||||
} else {
|
||||
// OAuth failed, emit oauthFailed to properly reject the promise
|
||||
logger.warn(`[MCP][${serverName}] OAuth failed, emitting oauthFailed event`);
|
||||
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
|
||||
}
|
||||
});
|
||||
|
||||
entries.map(async ([serverName, config], i) => {
|
||||
try {
|
||||
const connectTimeout = config.initTimeout ?? 30000;
|
||||
const connectionTimeout = new Promise<void>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Connection timeout after ${connectTimeout}ms`)),
|
||||
connectTimeout,
|
||||
),
|
||||
);
|
||||
|
||||
const connectionAttempt = this.initializeServer({
|
||||
connection,
|
||||
logPrefix: `[MCP][${serverName}]`,
|
||||
await this.initializeMCP({
|
||||
serverName,
|
||||
config,
|
||||
flowManager,
|
||||
handleOAuth: false,
|
||||
tokenMethods,
|
||||
});
|
||||
await Promise.race([connectionAttempt, connectionTimeout]);
|
||||
|
||||
if (await connection.isConnected()) {
|
||||
initializedServers.add(i);
|
||||
this.connections.set(serverName, connection);
|
||||
|
||||
/** Unified `serverInstructions` configuration */
|
||||
const configInstructions = config.serverInstructions;
|
||||
|
||||
if (configInstructions !== undefined) {
|
||||
if (typeof configInstructions === 'string') {
|
||||
this.serverInstructions.set(serverName, configInstructions);
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Custom instructions stored for context inclusion: ${configInstructions}`,
|
||||
);
|
||||
} else if (configInstructions === true) {
|
||||
/** Server-provided instructions */
|
||||
const serverInstructions = connection.client.getInstructions();
|
||||
|
||||
if (serverInstructions) {
|
||||
this.serverInstructions.set(serverName, serverInstructions);
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Server instructions stored for context inclusion: ${serverInstructions}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] serverInstructions=true but no server instructions available`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Instructions explicitly disabled (serverInstructions=false)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Instructions not included (serverInstructions not configured)`,
|
||||
);
|
||||
}
|
||||
|
||||
const serverCapabilities = connection.client.getServerCapabilities();
|
||||
logger.info(`[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`);
|
||||
|
||||
if (serverCapabilities?.tools) {
|
||||
const tools = await connection.client.listTools();
|
||||
if (tools.tools.length) {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Available tools: ${tools.tools
|
||||
.map((tool) => tool.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
initializedServers.add(i);
|
||||
} catch (error) {
|
||||
logger.error(`[MCP][${serverName}] Initialization failed`, error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -260,6 +104,176 @@ export class MCPManager {
|
|||
}
|
||||
}
|
||||
|
||||
/** Initializes a single MCP server connection (app-level) */
|
||||
public async initializeMCP({
|
||||
serverName,
|
||||
config,
|
||||
flowManager,
|
||||
tokenMethods,
|
||||
}: {
|
||||
serverName: string;
|
||||
config: t.MCPOptions;
|
||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||
tokenMethods?: TokenMethods;
|
||||
}): Promise<void> {
|
||||
const processedConfig = processMCPEnv(config);
|
||||
let tokens: MCPOAuthTokens | null = null;
|
||||
if (tokenMethods?.findToken) {
|
||||
try {
|
||||
/** Refresh function for app-level connections */
|
||||
const refreshTokensFunction = async (
|
||||
refreshToken: string,
|
||||
metadata: {
|
||||
userId: string;
|
||||
serverName: string;
|
||||
identifier: string;
|
||||
clientInfo?: OAuthClientInformation;
|
||||
},
|
||||
) => {
|
||||
const serverUrl = (processedConfig as t.SSEOptions | t.StreamableHTTPOptions).url;
|
||||
return await MCPOAuthHandler.refreshOAuthTokens(
|
||||
refreshToken,
|
||||
{
|
||||
serverName: metadata.serverName,
|
||||
serverUrl,
|
||||
clientInfo: metadata.clientInfo,
|
||||
},
|
||||
processedConfig.oauth,
|
||||
);
|
||||
};
|
||||
|
||||
/** Flow state to prevent concurrent token operations */
|
||||
const tokenFlowId = `tokens:${CONSTANTS.SYSTEM_USER_ID}:${serverName}`;
|
||||
tokens = await flowManager.createFlowWithHandler(
|
||||
tokenFlowId,
|
||||
'mcp_get_tokens',
|
||||
async () => {
|
||||
return await MCPTokenStorage.getTokens({
|
||||
userId: CONSTANTS.SYSTEM_USER_ID,
|
||||
serverName,
|
||||
findToken: tokenMethods.findToken,
|
||||
refreshTokens: refreshTokensFunction,
|
||||
createToken: tokenMethods.createToken,
|
||||
updateToken: tokenMethods.updateToken,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
logger.debug(`[MCP][${serverName}] No existing tokens found`);
|
||||
}
|
||||
}
|
||||
if (tokens) {
|
||||
logger.info(`[MCP][${serverName}] Loaded OAuth tokens`);
|
||||
}
|
||||
const connection = new MCPConnection(serverName, processedConfig, undefined, tokens);
|
||||
logger.info(`[MCP][${serverName}] Setting up OAuth event listener`);
|
||||
connection.on('oauthRequired', async (data) => {
|
||||
logger.debug(`[MCP][${serverName}] oauthRequired event received`);
|
||||
const result = await this.handleOAuthRequired({
|
||||
...data,
|
||||
flowManager,
|
||||
});
|
||||
if (result?.tokens && tokenMethods?.createToken) {
|
||||
try {
|
||||
connection.setOAuthTokens(result.tokens);
|
||||
await MCPTokenStorage.storeTokens({
|
||||
userId: CONSTANTS.SYSTEM_USER_ID,
|
||||
serverName,
|
||||
tokens: result.tokens,
|
||||
createToken: tokenMethods.createToken,
|
||||
updateToken: tokenMethods.updateToken,
|
||||
findToken: tokenMethods.findToken,
|
||||
clientInfo: result.clientInfo,
|
||||
});
|
||||
logger.info(`[MCP][${serverName}] OAuth tokens saved to storage`);
|
||||
} catch (error) {
|
||||
logger.error(`[MCP][${serverName}] Failed to save OAuth tokens to storage`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Only emit oauthHandled if we actually got tokens (OAuth succeeded)
|
||||
if (result?.tokens) {
|
||||
connection.emit('oauthHandled');
|
||||
} else {
|
||||
// OAuth failed, emit oauthFailed to properly reject the promise
|
||||
logger.warn(`[MCP][${serverName}] OAuth failed, emitting oauthFailed event`);
|
||||
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
|
||||
}
|
||||
});
|
||||
try {
|
||||
const connectTimeout = processedConfig.initTimeout ?? 30000;
|
||||
const connectionTimeout = new Promise<void>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Connection timeout after ${connectTimeout}ms`)),
|
||||
connectTimeout,
|
||||
),
|
||||
);
|
||||
const connectionAttempt = this.initializeServer({
|
||||
connection,
|
||||
logPrefix: `[MCP][${serverName}]`,
|
||||
flowManager,
|
||||
handleOAuth: false,
|
||||
});
|
||||
await Promise.race([connectionAttempt, connectionTimeout]);
|
||||
if (await connection.isConnected()) {
|
||||
this.connections.set(serverName, connection);
|
||||
|
||||
/** Unified `serverInstructions` configuration */
|
||||
const configInstructions = processedConfig.serverInstructions;
|
||||
if (configInstructions !== undefined) {
|
||||
if (typeof configInstructions === 'string') {
|
||||
this.serverInstructions.set(serverName, configInstructions);
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Custom instructions stored for context inclusion: ${configInstructions}`,
|
||||
);
|
||||
} else if (configInstructions === true) {
|
||||
/** Server-provided instructions */
|
||||
const serverInstructions = connection.client.getInstructions();
|
||||
|
||||
if (serverInstructions) {
|
||||
this.serverInstructions.set(serverName, serverInstructions);
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Server instructions stored for context inclusion: ${serverInstructions}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] serverInstructions=true but no server instructions available`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Instructions explicitly disabled (serverInstructions=false)`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Instructions not included (serverInstructions not configured)`,
|
||||
);
|
||||
}
|
||||
|
||||
const serverCapabilities = connection.client.getServerCapabilities();
|
||||
logger.info(`[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`);
|
||||
|
||||
if (serverCapabilities?.tools) {
|
||||
const tools = await connection.client.listTools();
|
||||
if (tools.tools.length) {
|
||||
logger.info(
|
||||
`[MCP][${serverName}] Available tools: ${tools.tools
|
||||
.map((tool) => tool.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info(`[MCP][${serverName}] ✓ Initialized`);
|
||||
} else {
|
||||
logger.info(`[MCP][${serverName}] ✗ Failed`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[MCP][${serverName}] Initialization failed`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generic server initialization logic */
|
||||
private async initializeServer({
|
||||
connection,
|
||||
|
|
|
|||
|
|
@ -708,5 +708,53 @@ describe('Environment Variable Extraction (MCP)', () => {
|
|||
SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment
|
||||
});
|
||||
});
|
||||
|
||||
it('should process GitHub MCP server configuration with PAT_TOKEN placeholder', () => {
|
||||
const user = createTestUser({ id: 'github-user-123', email: 'user@example.com' });
|
||||
const customUserVars = {
|
||||
PAT_TOKEN: 'ghp_1234567890abcdef1234567890abcdef12345678', // GitHub Personal Access Token
|
||||
};
|
||||
|
||||
// Simulate the GitHub MCP server configuration from librechat.yaml
|
||||
const obj: MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.githubcopilot.com/mcp/',
|
||||
headers: {
|
||||
Authorization: '{{PAT_TOKEN}}',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'LibreChat-MCP-Client',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user, customUserVars);
|
||||
|
||||
expect('headers' in result && result.headers).toEqual({
|
||||
Authorization: 'ghp_1234567890abcdef1234567890abcdef12345678',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'LibreChat-MCP-Client',
|
||||
});
|
||||
expect('url' in result && result.url).toBe('https://api.githubcopilot.com/mcp/');
|
||||
expect(result.type).toBe('streamable-http');
|
||||
});
|
||||
|
||||
it('should handle GitHub MCP server configuration without PAT_TOKEN (placeholder remains)', () => {
|
||||
const user = createTestUser({ id: 'github-user-123' });
|
||||
// No customUserVars provided - PAT_TOKEN should remain as placeholder
|
||||
const obj: MCPOptions = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://api.githubcopilot.com/mcp/',
|
||||
headers: {
|
||||
Authorization: '{{PAT_TOKEN}}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const result = processMCPEnv(obj, user);
|
||||
|
||||
expect('headers' in result && result.headers).toEqual({
|
||||
Authorization: '{{PAT_TOKEN}}', // Should remain unchanged since no customUserVars provided
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue