🔐 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:
Danny Avila 2026-02-01 19:37:04 -05:00 committed by GitHub
parent 5af1342dbb
commit d13037881a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 667 additions and 40 deletions

View file

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