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:
Dustin Healy 2025-07-11 19:44:19 -07:00
parent 62b4f3b795
commit abafbfeefa
14 changed files with 508 additions and 196 deletions

View file

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

View file

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