diff --git a/packages/api/src/mcp/oauth/handler.test.ts b/packages/api/src/mcp/oauth/handler.test.ts new file mode 100644 index 0000000000..c65ffc151b --- /dev/null +++ b/packages/api/src/mcp/oauth/handler.test.ts @@ -0,0 +1,190 @@ +import { MCPOAuthHandler } from './handler'; +import type { MCPOptions } from 'librechat-data-provider'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ + startAuthorization: jest.fn(), +})); + +import { startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js'; + +const mockStartAuthorization = startAuthorization as jest.MockedFunction; + +describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { + const mockServerName = 'test-server'; + const mockServerUrl = 'https://example.com/mcp'; + const mockUserId = 'user-123'; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + + // Mock startAuthorization to return a successful response + mockStartAuthorization.mockResolvedValue({ + authorizationUrl: new URL('https://auth.example.com/oauth/authorize?client_id=test'), + codeVerifier: 'test-code-verifier', + }); + }); + + afterEach(() => { + delete process.env.DOMAIN_SERVER; + }); + + describe('Pre-configured OAuth Metadata Fields', () => { + const baseConfig: MCPOptions['oauth'] = { + authorization_url: 'https://auth.example.com/oauth/authorize', + token_url: 'https://auth.example.com/oauth/token', + client_id: 'test-client-id', + client_secret: 'test-client-secret', + }; + + it('should use default values when OAuth metadata fields are not configured', async () => { + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + baseConfig, + ); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256', 'plain'], + }), + }), + ); + }); + + it('should use custom grant_types_supported when provided', async () => { + const config = { + ...baseConfig, + grant_types_supported: ['authorization_code'], + }; + + await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + grant_types_supported: ['authorization_code'], + }), + }), + ); + }); + + it('should use custom token_endpoint_auth_methods_supported when provided', async () => { + const config = { + ...baseConfig, + token_endpoint_auth_methods_supported: ['client_secret_post'], + }; + + await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + token_endpoint_auth_methods_supported: ['client_secret_post'], + }), + }), + ); + }); + + it('should use custom response_types_supported when provided', async () => { + const config = { + ...baseConfig, + response_types_supported: ['code', 'token'], + }; + + await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + response_types_supported: ['code', 'token'], + }), + }), + ); + }); + + it('should use custom code_challenge_methods_supported when provided', async () => { + const config = { + ...baseConfig, + code_challenge_methods_supported: ['S256'], + }; + + await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + code_challenge_methods_supported: ['S256'], + }), + }), + ); + }); + + it('should use all custom OAuth metadata fields when provided together', async () => { + const config = { + ...baseConfig, + grant_types_supported: ['authorization_code', 'client_credentials'], + token_endpoint_auth_methods_supported: ['none'], + response_types_supported: ['code', 'token', 'id_token'], + code_challenge_methods_supported: ['S256'], + }; + + await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + grant_types_supported: ['authorization_code', 'client_credentials'], + token_endpoint_auth_methods_supported: ['none'], + response_types_supported: ['code', 'token', 'id_token'], + code_challenge_methods_supported: ['S256'], + }), + }), + ); + }); + + it('should handle empty arrays as valid custom values', async () => { + const config = { + ...baseConfig, + grant_types_supported: [], + token_endpoint_auth_methods_supported: [], + response_types_supported: [], + code_challenge_methods_supported: [], + }; + + await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + + expect(mockStartAuthorization).toHaveBeenCalledWith( + mockServerUrl, + expect.objectContaining({ + metadata: expect.objectContaining({ + grant_types_supported: [], + token_endpoint_auth_methods_supported: [], + response_types_supported: [], + code_challenge_methods_supported: [], + }), + }), + ); + }); + }); +}); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 1c776739f2..739a5c6516 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -181,9 +181,22 @@ export class MCPOAuthHandler { authorization_endpoint: config.authorization_url, token_endpoint: config.token_url, issuer: serverUrl, - scopes_supported: config.scope?.split(' '), + scopes_supported: config.scope?.split(' ') ?? [], + grant_types_supported: config?.grant_types_supported ?? [ + 'authorization_code', + 'refresh_token', + ], + token_endpoint_auth_methods_supported: config?.token_endpoint_auth_methods_supported ?? [ + 'client_secret_basic', + 'client_secret_post', + ], + response_types_supported: config?.response_types_supported ?? ['code'], + code_challenge_methods_supported: config?.code_challenge_methods_supported ?? [ + 'S256', + 'plain', + ], }; - + logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`); const clientInfo: OAuthClientInformation = { client_id: config.client_id, client_secret: config.client_secret, diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 2b85449a06..b244f7fe5f 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -36,6 +36,14 @@ const BaseOptionsSchema = z.object({ redirect_uri: z.string().url().optional(), /** Token exchange method */ token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(), + /** Supported grant types (defaults to ['authorization_code', 'refresh_token']) */ + grant_types_supported: z.array(z.string()).optional(), + /** Supported token endpoint authentication methods (defaults to ['client_secret_basic', 'client_secret_post']) */ + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + /** Supported response types (defaults to ['code']) */ + response_types_supported: z.array(z.string()).optional(), + /** Supported code challenge methods (defaults to ['S256', 'plain']) */ + code_challenge_methods_supported: z.array(z.string()).optional(), }) .optional(), customUserVars: z