mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-10 10:02:36 +01:00
🔐 fix: MCP OAuth Tool Discovery and Event Emission (#11599)
* fix: MCP OAuth tool discovery and event emission in event-driven mode - Add discoverServerTools method to MCPManager for tool discovery when OAuth is required - Fix OAuth event emission to send both ON_RUN_STEP and ON_RUN_STEP_DELTA events - Fix hasSubscriber flag reset in GenerationJobManager for proper event buffering - Add ToolDiscoveryOptions and ToolDiscoveryResult types - Update reinitMCPServer to use new discovery method and propagate OAuth URLs * refactor: Update ToolService and MCP modules for improved functionality - Reintroduced Constants in ToolService for better reference management. - Enhanced loadToolDefinitionsWrapper to handle both response and streamId scenarios. - Updated MCP module to correct type definitions for oauthStart parameter. - Improved MCPConnectionFactory to ensure proper disconnection handling during tool discovery. - Adjusted tests to reflect changes in mock implementations and ensure accurate behavior during OAuth handling. * fix: Refine OAuth handling in MCPConnectionFactory and related tests - Updated the OAuth URL assignment logic in reinitMCPServer to prevent overwriting existing URLs. - Enhanced error logging to provide clearer messages when tool discovery fails. - Adjusted tests to reflect changes in OAuth handling, ensuring accurate detection of OAuth requirements without generating URLs in discovery mode. * refactor: Clean up OAuth URL assignment in reinitMCPServer - Removed redundant OAuth URL assignment logic in the reinitMCPServer function to streamline the tool discovery process. - Enhanced error logging for tool discovery failures, improving clarity in debugging and monitoring. * fix: Update response handling in ToolService for event-driven mode - Changed the condition in loadToolDefinitionsWrapper to check for writableEnded instead of headersSent, ensuring proper event emission when the response is still writable. - This adjustment enhances the reliability of event handling during tool execution, particularly in streaming scenarios.
This commit is contained in:
parent
5af1342dbb
commit
d13037881a
12 changed files with 667 additions and 40 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { TokenMethods } from '@librechat/data-schemas';
|
||||
import type { MCPOAuthTokens, OAuthMetadata } from '~/mcp/oauth';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
|
|
@ -11,6 +12,13 @@ import { withTimeout } from '~/utils/promise';
|
|||
import { MCPConnection } from './connection';
|
||||
import { processMCPEnv } from '~/utils';
|
||||
|
||||
export interface ToolDiscoveryResult {
|
||||
tools: Tool[] | null;
|
||||
connection: MCPConnection | null;
|
||||
oauthRequired: boolean;
|
||||
oauthUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating MCP connections with optional OAuth authentication.
|
||||
* Handles OAuth flows, token management, and connection retry logic.
|
||||
|
|
@ -41,6 +49,137 @@ export class MCPConnectionFactory {
|
|||
return factory.createConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers tools from an MCP server, even when OAuth is required.
|
||||
* Per MCP spec, tool listing should be possible without authentication.
|
||||
* Returns tools if discoverable, plus OAuth status for tool execution.
|
||||
*/
|
||||
static async discoverTools(
|
||||
basic: t.BasicConnectionOptions,
|
||||
oauth?: Omit<t.OAuthConnectionOptions, 'returnOnOAuth'>,
|
||||
): Promise<ToolDiscoveryResult> {
|
||||
const factory = new this(basic, oauth ? { ...oauth, returnOnOAuth: true } : undefined);
|
||||
return factory.discoverToolsInternal();
|
||||
}
|
||||
|
||||
protected async discoverToolsInternal(): Promise<ToolDiscoveryResult> {
|
||||
const oauthUrl: string | null = null;
|
||||
let oauthRequired = false;
|
||||
|
||||
const oauthTokens = this.useOAuth ? await this.getOAuthTokens() : null;
|
||||
const connection = new MCPConnection({
|
||||
serverName: this.serverName,
|
||||
serverConfig: this.serverConfig,
|
||||
userId: this.userId,
|
||||
oauthTokens,
|
||||
});
|
||||
|
||||
const oauthHandler = async () => {
|
||||
logger.info(
|
||||
`${this.logPrefix} [Discovery] OAuth required; skipping URL generation in discovery mode`,
|
||||
);
|
||||
oauthRequired = true;
|
||||
connection.emit('oauthFailed', new Error('OAuth required during tool discovery'));
|
||||
};
|
||||
|
||||
if (this.useOAuth) {
|
||||
connection.on('oauthRequired', oauthHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const connectTimeout = this.connectionTimeout ?? this.serverConfig.initTimeout ?? 30000;
|
||||
await withTimeout(
|
||||
connection.connect(),
|
||||
connectTimeout,
|
||||
`Connection timeout after ${connectTimeout}ms`,
|
||||
);
|
||||
|
||||
if (await connection.isConnected()) {
|
||||
const tools = await connection.fetchTools();
|
||||
if (this.useOAuth) {
|
||||
connection.removeListener('oauthRequired', oauthHandler);
|
||||
}
|
||||
return { tools, connection, oauthRequired: false, oauthUrl: null };
|
||||
}
|
||||
} catch {
|
||||
logger.debug(
|
||||
`${this.logPrefix} [Discovery] Connection failed, attempting unauthenticated tool listing`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const tools = await this.attemptUnauthenticatedToolListing();
|
||||
if (this.useOAuth) {
|
||||
connection.removeListener('oauthRequired', oauthHandler);
|
||||
}
|
||||
if (tools && tools.length > 0) {
|
||||
logger.info(
|
||||
`${this.logPrefix} [Discovery] Successfully discovered ${tools.length} tools without auth`,
|
||||
);
|
||||
try {
|
||||
await connection.disconnect();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
return { tools, connection: null, oauthRequired, oauthUrl };
|
||||
}
|
||||
} catch (listError) {
|
||||
logger.debug(`${this.logPrefix} [Discovery] Unauthenticated tool listing failed:`, listError);
|
||||
}
|
||||
|
||||
if (this.useOAuth) {
|
||||
connection.removeListener('oauthRequired', oauthHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.disconnect();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return { tools: null, connection: null, oauthRequired, oauthUrl };
|
||||
}
|
||||
|
||||
protected async attemptUnauthenticatedToolListing(): Promise<Tool[] | null> {
|
||||
const unauthConnection = new MCPConnection({
|
||||
serverName: this.serverName,
|
||||
serverConfig: this.serverConfig,
|
||||
userId: this.userId,
|
||||
oauthTokens: null,
|
||||
});
|
||||
|
||||
unauthConnection.on('oauthRequired', () => {
|
||||
logger.debug(
|
||||
`${this.logPrefix} [Discovery] Unauthenticated connection requires OAuth, failing fast`,
|
||||
);
|
||||
unauthConnection.emit(
|
||||
'oauthFailed',
|
||||
new Error('OAuth not supported in unauthenticated discovery'),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const connectTimeout = this.connectionTimeout ?? this.serverConfig.initTimeout ?? 15000;
|
||||
await withTimeout(unauthConnection.connect(), connectTimeout, `Unauth connection timeout`);
|
||||
|
||||
if (await unauthConnection.isConnected()) {
|
||||
const tools = await unauthConnection.fetchTools();
|
||||
await unauthConnection.disconnect();
|
||||
return tools;
|
||||
}
|
||||
} catch {
|
||||
logger.debug(`${this.logPrefix} [Discovery] Unauthenticated connection attempt failed`);
|
||||
}
|
||||
|
||||
try {
|
||||
await unauthConnection.disconnect();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected constructor(basic: t.BasicConnectionOptions, oauth?: t.OAuthConnectionOptions) {
|
||||
this.serverConfig = processMCPEnv({
|
||||
options: basic.serverConfig,
|
||||
|
|
@ -56,7 +195,7 @@ export class MCPConnectionFactory {
|
|||
: `[MCP][${basic.serverName}]`;
|
||||
|
||||
if (oauth?.useOAuth) {
|
||||
this.userId = oauth.user.id;
|
||||
this.userId = oauth.user?.id;
|
||||
this.flowManager = oauth.flowManager;
|
||||
this.tokenMethods = oauth.tokenMethods;
|
||||
this.signal = oauth.signal;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue