diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 7986e2ee5b..0ae9a29292 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -9,7 +9,7 @@ import { discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, } from '@modelcontextprotocol/sdk/client/auth.js'; -import type { MCPOptions } from 'librechat-data-provider'; +import { TokenExchangeMethodEnum, type MCPOptions } from 'librechat-data-provider'; import type { FlowStateManager } from '~/flow/manager'; import type { OAuthClientInformation, @@ -27,15 +27,117 @@ export class MCPOAuthHandler { private static readonly FLOW_TYPE = 'mcp_oauth'; private static readonly FLOW_TTL = 10 * 60 * 1000; // 10 minutes + private static getForcedTokenEndpointAuthMethod( + tokenExchangeMethod?: TokenExchangeMethodEnum, + ): 'client_secret_basic' | 'client_secret_post' | undefined { + if (tokenExchangeMethod === TokenExchangeMethodEnum.DefaultPost) { + return 'client_secret_post'; + } + if (tokenExchangeMethod === TokenExchangeMethodEnum.BasicAuthHeader) { + return 'client_secret_basic'; + } + return undefined; + } + + private static resolveTokenEndpointAuthMethod(options: { + tokenExchangeMethod?: TokenExchangeMethodEnum; + tokenAuthMethods: string[]; + preferredMethod?: string; + }): 'client_secret_basic' | 'client_secret_post' | undefined { + const forcedMethod = this.getForcedTokenEndpointAuthMethod(options.tokenExchangeMethod); + const preferredMethod = forcedMethod ?? options.preferredMethod; + + if (preferredMethod === 'client_secret_basic' || preferredMethod === 'client_secret_post') { + return preferredMethod; + } + + if (options.tokenAuthMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + if (options.tokenAuthMethods.includes('client_secret_post')) { + return 'client_secret_post'; + } + return undefined; + } + /** * Creates a fetch function with custom headers injected */ - private static createOAuthFetch(headers: Record): FetchLike { + private static createOAuthFetch( + headers: Record, + clientInfo?: OAuthClientInformation, + ): FetchLike { return async (url: string | URL, init?: RequestInit): Promise => { const newHeaders = new Headers(init?.headers ?? {}); for (const [key, value] of Object.entries(headers)) { newHeaders.set(key, value); } + + const method = (init?.method ?? 'GET').toUpperCase(); + const initBody = init?.body; + let params: URLSearchParams | undefined; + + if (initBody instanceof URLSearchParams) { + params = initBody; + } else if (typeof initBody === 'string') { + const parsed = new URLSearchParams(initBody); + if (parsed.has('grant_type')) { + params = parsed; + } + } + + /** + * FastMCP 2.14+/MCP SDK 1.24+ token endpoints can be strict about: + * - Content-Type (must be application/x-www-form-urlencoded) + * - where client_id/client_secret are supplied (default_post vs basic header) + */ + if (method === 'POST' && params?.has('grant_type')) { + newHeaders.set('Content-Type', 'application/x-www-form-urlencoded'); + + if (clientInfo?.client_id) { + let authMethod = clientInfo.token_endpoint_auth_method; + + if (!authMethod) { + if (newHeaders.has('Authorization')) { + authMethod = 'client_secret_basic'; + } else if (params.has('client_id') || params.has('client_secret')) { + authMethod = 'client_secret_post'; + } else if (clientInfo.client_secret) { + authMethod = 'client_secret_post'; + } else { + authMethod = 'none'; + } + } + + if (!clientInfo.client_secret || authMethod === 'none') { + newHeaders.delete('Authorization'); + if (!params.has('client_id')) { + params.set('client_id', clientInfo.client_id); + } + } else if (authMethod === 'client_secret_post') { + newHeaders.delete('Authorization'); + if (!params.has('client_id')) { + params.set('client_id', clientInfo.client_id); + } + if (!params.has('client_secret')) { + params.set('client_secret', clientInfo.client_secret); + } + } else if (authMethod === 'client_secret_basic') { + if (!newHeaders.has('Authorization')) { + const clientAuth = Buffer.from( + `${clientInfo.client_id}:${clientInfo.client_secret}`, + ).toString('base64'); + newHeaders.set('Authorization', `Basic ${clientAuth}`); + } + } + } + + return fetch(url, { + ...init, + body: params.toString(), + headers: newHeaders, + }); + } return fetch(url, { ...init, headers: newHeaders, @@ -157,6 +259,7 @@ export class MCPOAuthHandler { oauthHeaders: Record, resourceMetadata?: OAuthProtectedResourceMetadata, redirectUri?: string, + tokenExchangeMethod?: TokenExchangeMethodEnum, ): Promise { logger.debug( `[MCPOAuth] Starting client registration for ${sanitizeUrlForLogging(serverUrl)}, server metadata:`, @@ -197,7 +300,11 @@ export class MCPOAuthHandler { clientMetadata.response_types = metadata.response_types_supported || ['code']; - if (metadata.token_endpoint_auth_methods_supported) { + const forcedAuthMethod = this.getForcedTokenEndpointAuthMethod(tokenExchangeMethod); + + if (forcedAuthMethod) { + clientMetadata.token_endpoint_auth_method = forcedAuthMethod; + } else if (metadata.token_endpoint_auth_methods_supported) { // Prefer client_secret_basic if supported, otherwise use the first supported method if (metadata.token_endpoint_auth_methods_supported.includes('client_secret_basic')) { clientMetadata.token_endpoint_auth_method = 'client_secret_basic'; @@ -227,6 +334,12 @@ export class MCPOAuthHandler { fetchFn: this.createOAuthFetch(oauthHeaders), }); + if (forcedAuthMethod) { + clientInfo.token_endpoint_auth_method = forcedAuthMethod; + } else if (!clientInfo.token_endpoint_auth_method) { + clientInfo.token_endpoint_auth_method = clientMetadata.token_endpoint_auth_method; + } + logger.debug( `[MCPOAuth] Client registered successfully for ${sanitizeUrlForLogging(serverUrl)}:`, { @@ -281,6 +394,26 @@ export class MCPOAuthHandler { } /** Metadata based on pre-configured settings */ + let tokenEndpointAuthMethod: string; + if (!config.client_secret) { + tokenEndpointAuthMethod = 'none'; + } else { + // When token_exchange_method is undefined or not DefaultPost, default to using + // client_secret_basic (Basic Auth header) for token endpoint authentication. + tokenEndpointAuthMethod = + this.getForcedTokenEndpointAuthMethod(config.token_exchange_method) ?? + 'client_secret_basic'; + } + + let defaultTokenAuthMethods: string[]; + if (tokenEndpointAuthMethod === 'none') { + defaultTokenAuthMethods = ['none']; + } else if (tokenEndpointAuthMethod === 'client_secret_post') { + defaultTokenAuthMethods = ['client_secret_post', 'client_secret_basic']; + } else { + defaultTokenAuthMethods = ['client_secret_basic', 'client_secret_post']; + } + const metadata: OAuthMetadata = { authorization_endpoint: config.authorization_url, token_endpoint: config.token_url, @@ -290,10 +423,8 @@ export class MCPOAuthHandler { 'authorization_code', 'refresh_token', ], - token_endpoint_auth_methods_supported: config?.token_endpoint_auth_methods_supported ?? [ - 'client_secret_basic', - 'client_secret_post', - ], + token_endpoint_auth_methods_supported: + config?.token_endpoint_auth_methods_supported ?? defaultTokenAuthMethods, response_types_supported: config?.response_types_supported ?? ['code'], code_challenge_methods_supported: codeChallengeMethodsSupported, }; @@ -303,6 +434,7 @@ export class MCPOAuthHandler { client_secret: config.client_secret, redirect_uris: [config.redirect_uri || this.getDefaultRedirectUri(serverName)], scope: config.scope, + token_endpoint_auth_method: tokenEndpointAuthMethod, }; logger.debug(`[MCPOAuth] Starting authorization with pre-configured settings`); @@ -359,6 +491,7 @@ export class MCPOAuthHandler { oauthHeaders, resourceMetadata, redirectUri, + config?.token_exchange_method, ); logger.debug(`[MCPOAuth] Client registered with ID: ${clientInfo.client_id}`); @@ -490,7 +623,7 @@ export class MCPOAuthHandler { codeVerifier: metadata.codeVerifier, authorizationCode, resource, - fetchFn: this.createOAuthFetch(oauthHeaders), + fetchFn: this.createOAuthFetch(oauthHeaders, metadata.clientInfo), }); logger.debug('[MCPOAuth] Token exchange successful', { @@ -663,8 +796,8 @@ export class MCPOAuthHandler { } const headers: HeadersInit = { - 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', ...oauthHeaders, }; @@ -672,17 +805,20 @@ export class MCPOAuthHandler { if (metadata.clientInfo.client_secret) { /** Default to client_secret_basic if no methods specified (per RFC 8414) */ const tokenAuthMethods = authMethods ?? ['client_secret_basic']; - const usesBasicAuth = tokenAuthMethods.includes('client_secret_basic'); - const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post'); + const authMethod = this.resolveTokenEndpointAuthMethod({ + tokenExchangeMethod: config?.token_exchange_method, + tokenAuthMethods, + preferredMethod: metadata.clientInfo.token_endpoint_auth_method, + }); - if (usesBasicAuth) { + if (authMethod === 'client_secret_basic') { /** Use Basic auth */ logger.debug('[MCPOAuth] Using client_secret_basic authentication method'); const clientAuth = Buffer.from( `${metadata.clientInfo.client_id}:${metadata.clientInfo.client_secret}`, ).toString('base64'); headers['Authorization'] = `Basic ${clientAuth}`; - } else if (usesClientSecretPost) { + } else if (authMethod === 'client_secret_post') { /** Use client_secret_post */ logger.debug('[MCPOAuth] Using client_secret_post authentication method'); body.append('client_id', metadata.clientInfo.client_id); @@ -739,8 +875,8 @@ export class MCPOAuthHandler { } const headers: HeadersInit = { - 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', ...oauthHeaders, }; @@ -750,10 +886,12 @@ export class MCPOAuthHandler { const tokenAuthMethods = config.token_endpoint_auth_methods_supported ?? [ 'client_secret_basic', ]; - const usesBasicAuth = tokenAuthMethods.includes('client_secret_basic'); - const usesClientSecretPost = tokenAuthMethods.includes('client_secret_post'); + const authMethod = this.resolveTokenEndpointAuthMethod({ + tokenExchangeMethod: config.token_exchange_method, + tokenAuthMethods, + }); - if (usesBasicAuth) { + if (authMethod === 'client_secret_basic') { /** Use Basic auth */ logger.debug( '[MCPOAuth] Using client_secret_basic authentication method (pre-configured)', @@ -762,7 +900,7 @@ export class MCPOAuthHandler { 'base64', ); headers['Authorization'] = `Basic ${clientAuth}`; - } else if (usesClientSecretPost) { + } else if (authMethod === 'client_secret_post') { /** Use client_secret_post */ logger.debug( '[MCPOAuth] Using client_secret_post authentication method (pre-configured)',