mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🔒 feat: MCP OAuth Config for Metadata Parameters (#8691)
* fix(mcp): add default metadata for pre-configured oauth * removed lingering comment * added configurable options & jest unit tests * Update handler.test.ts * Update handler.ts --------- Co-authored-by: Alex <aleksander.chernyavskiy@seafar.eu> Co-authored-by: Danny Avila <danacordially@gmail.com>
This commit is contained in:
parent
5eed5009e9
commit
056172f007
3 changed files with 213 additions and 2 deletions
190
packages/api/src/mcp/oauth/handler.test.ts
Normal file
190
packages/api/src/mcp/oauth/handler.test.ts
Normal file
|
|
@ -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<typeof startAuthorization>;
|
||||
|
||||
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: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue