🔒 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:
wartek69 2025-07-31 13:24:49 +02:00 committed by GitHub
parent 5eed5009e9
commit 056172f007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 2 deletions

View 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: [],
}),
}),
);
});
});
});

View file

@ -181,9 +181,22 @@ export class MCPOAuthHandler {
authorization_endpoint: config.authorization_url, authorization_endpoint: config.authorization_url,
token_endpoint: config.token_url, token_endpoint: config.token_url,
issuer: serverUrl, 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 = { const clientInfo: OAuthClientInformation = {
client_id: config.client_id, client_id: config.client_id,
client_secret: config.client_secret, client_secret: config.client_secret,

View file

@ -36,6 +36,14 @@ const BaseOptionsSchema = z.object({
redirect_uri: z.string().url().optional(), redirect_uri: z.string().url().optional(),
/** Token exchange method */ /** Token exchange method */
token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(), 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(), .optional(),
customUserVars: z customUserVars: z