mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20:15 +01:00
🔐 feat: Add API key authentication support for MCP servers (#10936)
* 🔐 feat: Add API key authentication support for MCP servers
* Chore: Copilot comments fixes
---------
Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
This commit is contained in:
parent
abeaab6e17
commit
e15d37b399
11 changed files with 836 additions and 84 deletions
|
|
@ -28,6 +28,7 @@ enum AuthorizationTypeEnum {
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
auth_type?: AuthTypeEnum;
|
auth_type?: AuthTypeEnum;
|
||||||
api_key?: string;
|
api_key?: string;
|
||||||
|
api_key_source?: 'admin' | 'user'; // Whether admin provides key for all or each user provides their own
|
||||||
api_key_authorization_type?: AuthorizationTypeEnum;
|
api_key_authorization_type?: AuthorizationTypeEnum;
|
||||||
api_key_custom_header?: string;
|
api_key_custom_header?: string;
|
||||||
oauth_client_id?: string;
|
oauth_client_id?: string;
|
||||||
|
|
@ -171,8 +172,6 @@ export default function MCPAuth({
|
||||||
{localize('com_ui_none')}
|
{localize('com_ui_none')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/*
|
|
||||||
TODO Support API keys for auth
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="auth-apikey" className="flex cursor-pointer items-center gap-1">
|
<label htmlFor="auth-apikey" className="flex cursor-pointer items-center gap-1">
|
||||||
<RadioGroup.Item
|
<RadioGroup.Item
|
||||||
|
|
@ -189,7 +188,7 @@ export default function MCPAuth({
|
||||||
</RadioGroup.Item>
|
</RadioGroup.Item>
|
||||||
{localize('com_ui_api_key')}
|
{localize('com_ui_api_key')}
|
||||||
</label>
|
</label>
|
||||||
</div> */}
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="auth-oauth" className="flex cursor-pointer items-center gap-1">
|
<label htmlFor="auth-oauth" className="flex cursor-pointer items-center gap-1">
|
||||||
<RadioGroup.Item
|
<RadioGroup.Item
|
||||||
|
|
@ -228,9 +227,61 @@ export default function MCPAuth({
|
||||||
const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
|
const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { register, watch, setValue } = useFormContext();
|
const { register, watch, setValue } = useFormContext();
|
||||||
const authorization_type = watch('api_key_authorization_type') || AuthorizationTypeEnum.Basic;
|
const api_key_source = watch('api_key_source') || 'admin';
|
||||||
|
const authorization_type = watch('api_key_authorization_type') || AuthorizationTypeEnum.Bearer;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* API Key Source selection */}
|
||||||
|
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key_source')}</label>
|
||||||
|
<RadioGroup.Root
|
||||||
|
defaultValue="admin"
|
||||||
|
onValueChange={(value) => setValue('api_key_source', value)}
|
||||||
|
value={api_key_source}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-required="true"
|
||||||
|
dir="ltr"
|
||||||
|
className="mb-3 flex flex-col gap-2"
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="source-admin" className="flex cursor-pointer items-center gap-1">
|
||||||
|
<RadioGroup.Item
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value="admin"
|
||||||
|
id="source-admin"
|
||||||
|
className={cn(
|
||||||
|
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||||
|
'border-border-heavy bg-surface-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
{localize('com_ui_admin_provides_key')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="source-user" className="flex cursor-pointer items-center gap-1">
|
||||||
|
<RadioGroup.Item
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value="user"
|
||||||
|
id="source-user"
|
||||||
|
className={cn(
|
||||||
|
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||||
|
'border-border-heavy bg-surface-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
{localize('com_ui_user_provides_key')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
|
||||||
|
{/* API Key input - only show for admin-provided mode */}
|
||||||
|
{api_key_source === 'admin' && (
|
||||||
<>
|
<>
|
||||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
|
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -240,9 +291,20 @@ const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
|
||||||
className={inputClasses}
|
className={inputClasses}
|
||||||
{...register('api_key')}
|
{...register('api_key')}
|
||||||
/>
|
/>
|
||||||
<label className="mb-1 block text-sm font-medium">{localize('com_ui_auth_type')}</label>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User-provided mode info */}
|
||||||
|
{api_key_source === 'user' && (
|
||||||
|
<div className="mb-3 rounded-lg border border-border-medium bg-surface-secondary p-3">
|
||||||
|
<p className="text-sm text-text-secondary">{localize('com_ui_user_provides_key_note')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header Format selection - shown for both modes */}
|
||||||
|
<label className="mb-1 block text-sm font-medium">{localize('com_ui_header_format')}</label>
|
||||||
<RadioGroup.Root
|
<RadioGroup.Root
|
||||||
defaultValue={AuthorizationTypeEnum.Basic}
|
defaultValue={AuthorizationTypeEnum.Bearer}
|
||||||
onValueChange={(value) => setValue('api_key_authorization_type', value)}
|
onValueChange={(value) => setValue('api_key_authorization_type', value)}
|
||||||
value={authorization_type}
|
value={authorization_type}
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
|
|
@ -251,23 +313,6 @@ const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
|
||||||
className="mb-2 flex gap-6 overflow-hidden rounded-lg"
|
className="mb-2 flex gap-6 overflow-hidden rounded-lg"
|
||||||
style={{ outline: 'none' }}
|
style={{ outline: 'none' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label htmlFor="auth-basic" className="flex cursor-pointer items-center gap-1">
|
|
||||||
<RadioGroup.Item
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
value={AuthorizationTypeEnum.Basic}
|
|
||||||
id="auth-basic"
|
|
||||||
className={cn(
|
|
||||||
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
|
||||||
'border-border-heavy bg-surface-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
|
||||||
</RadioGroup.Item>
|
|
||||||
{localize('com_ui_basic')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="auth-bearer" className="flex cursor-pointer items-center gap-1">
|
<label htmlFor="auth-bearer" className="flex cursor-pointer items-center gap-1">
|
||||||
<RadioGroup.Item
|
<RadioGroup.Item
|
||||||
|
|
@ -285,6 +330,23 @@ const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
|
||||||
{localize('com_ui_bearer')}
|
{localize('com_ui_bearer')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="auth-basic" className="flex cursor-pointer items-center gap-1">
|
||||||
|
<RadioGroup.Item
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value={AuthorizationTypeEnum.Basic}
|
||||||
|
id="auth-basic"
|
||||||
|
className={cn(
|
||||||
|
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
|
||||||
|
'border-border-heavy bg-surface-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
|
||||||
|
</RadioGroup.Item>
|
||||||
|
{localize('com_ui_basic')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label htmlFor="auth-custom" className="flex cursor-pointer items-center gap-1">
|
<label htmlFor="auth-custom" className="flex cursor-pointer items-center gap-1">
|
||||||
<RadioGroup.Item
|
<RadioGroup.Item
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,13 @@ export default function MCPServerDialog({
|
||||||
let authType: AuthTypeEnum = AuthTypeEnum.None;
|
let authType: AuthTypeEnum = AuthTypeEnum.None;
|
||||||
if (server.config.oauth) {
|
if (server.config.oauth) {
|
||||||
authType = AuthTypeEnum.OAuth;
|
authType = AuthTypeEnum.OAuth;
|
||||||
} else if ('api_key' in server.config) {
|
} else if ('apiKey' in server.config && server.config.apiKey) {
|
||||||
authType = AuthTypeEnum.ServiceHttp;
|
authType = AuthTypeEnum.ServiceHttp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract apiKey config if present
|
||||||
|
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: server.config.title || '',
|
title: server.config.title || '',
|
||||||
description: server.config.description || '',
|
description: server.config.description || '',
|
||||||
|
|
@ -97,9 +100,12 @@ export default function MCPServerDialog({
|
||||||
icon: server.config.iconPath || '',
|
icon: server.config.iconPath || '',
|
||||||
auth: {
|
auth: {
|
||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
api_key: '',
|
api_key: '', // NEVER pre-fill secrets
|
||||||
api_key_authorization_type: AuthorizationTypeEnum.Basic,
|
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
|
||||||
api_key_custom_header: '',
|
api_key_authorization_type:
|
||||||
|
(apiKeyConfig?.authorization_type as AuthorizationTypeEnum) ||
|
||||||
|
AuthorizationTypeEnum.Bearer,
|
||||||
|
api_key_custom_header: apiKeyConfig?.custom_header || '',
|
||||||
oauth_client_id: server.config.oauth?.client_id || '',
|
oauth_client_id: server.config.oauth?.client_id || '',
|
||||||
oauth_client_secret: '', // NEVER pre-fill secrets
|
oauth_client_secret: '', // NEVER pre-fill secrets
|
||||||
oauth_authorization_url: server.config.oauth?.authorization_url || '',
|
oauth_authorization_url: server.config.oauth?.authorization_url || '',
|
||||||
|
|
@ -119,7 +125,8 @@ export default function MCPServerDialog({
|
||||||
auth: {
|
auth: {
|
||||||
auth_type: AuthTypeEnum.None,
|
auth_type: AuthTypeEnum.None,
|
||||||
api_key: '',
|
api_key: '',
|
||||||
api_key_authorization_type: AuthorizationTypeEnum.Basic,
|
api_key_source: 'admin',
|
||||||
|
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
|
||||||
api_key_custom_header: '',
|
api_key_custom_header: '',
|
||||||
oauth_client_id: '',
|
oauth_client_id: '',
|
||||||
oauth_client_secret: '',
|
oauth_client_secret: '',
|
||||||
|
|
@ -251,6 +258,22 @@ export default function MCPServerDialog({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add API Key if auth type is service_http
|
||||||
|
if (formData.auth.auth_type === AuthTypeEnum.ServiceHttp) {
|
||||||
|
const source = formData.auth.api_key_source || 'admin';
|
||||||
|
const authorizationType = formData.auth.api_key_authorization_type || 'bearer';
|
||||||
|
|
||||||
|
config.apiKey = {
|
||||||
|
source,
|
||||||
|
authorization_type: authorizationType,
|
||||||
|
...(source === 'admin' && formData.auth.api_key && { key: formData.auth.api_key }),
|
||||||
|
...(authorizationType === 'custom' &&
|
||||||
|
formData.auth.api_key_custom_header && {
|
||||||
|
custom_header: formData.auth.api_key_custom_header,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const params: MCPServerCreateParams = {
|
const params: MCPServerCreateParams = {
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -704,6 +704,11 @@
|
||||||
"com_ui_analyzing": "Analyzing",
|
"com_ui_analyzing": "Analyzing",
|
||||||
"com_ui_analyzing_finished": "Finished analyzing",
|
"com_ui_analyzing_finished": "Finished analyzing",
|
||||||
"com_ui_api_key": "API Key",
|
"com_ui_api_key": "API Key",
|
||||||
|
"com_ui_api_key_source": "API Key Source",
|
||||||
|
"com_ui_admin_provides_key": "Provide a key for all users",
|
||||||
|
"com_ui_user_provides_key": "Each user provides their own key",
|
||||||
|
"com_ui_user_provides_key_note": "Users will be prompted to enter their API key when connecting to this server.",
|
||||||
|
"com_ui_header_format": "Header Format",
|
||||||
"com_ui_archive": "Archive",
|
"com_ui_archive": "Archive",
|
||||||
"com_ui_archive_delete_error": "Failed to delete archived conversation",
|
"com_ui_archive_delete_error": "Failed to delete archived conversation",
|
||||||
"com_ui_archive_error": "Failed to archive conversation",
|
"com_ui_archive_error": "Failed to archive conversation",
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,12 @@ export class MCPServerInspector {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin-provided API key means no OAuth flow is needed
|
||||||
|
if (this.config.apiKey?.source === 'admin') {
|
||||||
|
this.config.requiresOAuth = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await detectOAuthRequirement(this.config.url);
|
const result = await detectOAuthRequirement(this.config.url);
|
||||||
this.config.requiresOAuth = result.requiresOAuth;
|
this.config.requiresOAuth = result.requiresOAuth;
|
||||||
this.config.oauthMetadata = result.metadata;
|
this.config.oauthMetadata = result.metadata;
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,25 @@ export class MCPServersRegistry {
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<t.ParsedServerConfig> {
|
): Promise<t.ParsedServerConfig> {
|
||||||
const configRepo = this.getConfigRepository(storageLocation);
|
const configRepo = this.getConfigRepository(storageLocation);
|
||||||
|
|
||||||
|
// Merge existing admin API key if not provided in update (needed for inspection)
|
||||||
|
let configForInspection = { ...config };
|
||||||
|
if (config.apiKey?.source === 'admin' && !config.apiKey?.key) {
|
||||||
|
const existingConfig = await configRepo.get(serverName, userId);
|
||||||
|
if (existingConfig?.apiKey?.key) {
|
||||||
|
configForInspection = {
|
||||||
|
...configForInspection,
|
||||||
|
apiKey: {
|
||||||
|
...configForInspection.apiKey!,
|
||||||
|
key: existingConfig.apiKey.key,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let parsedConfig: t.ParsedServerConfig;
|
let parsedConfig: t.ParsedServerConfig;
|
||||||
try {
|
try {
|
||||||
parsedConfig = await MCPServerInspector.inspect(serverName, config);
|
parsedConfig = await MCPServerInspector.inspect(serverName, configForInspection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
|
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
|
||||||
throw new Error(`MCP_INSPECTION_FAILED: Failed to connect to MCP server "${serverName}"`);
|
throw new Error(`MCP_INSPECTION_FAILED: Failed to connect to MCP server "${serverName}"`);
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,55 @@ describe('MCPServerInspector', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set requiresOAuth to false when apiKey.source is admin', async () => {
|
||||||
|
const rawConfig: t.MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com/sse',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'my-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// OAuth detection should be skipped
|
||||||
|
mockDetectOAuthRequirement.mockResolvedValue({
|
||||||
|
requiresOAuth: true, // This would be returned if called, but it shouldn't be
|
||||||
|
method: 'protected-resource-metadata',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||||
|
|
||||||
|
// Should NOT call OAuth detection
|
||||||
|
expect(mockDetectOAuthRequirement).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// requiresOAuth should be false due to admin-provided API key
|
||||||
|
expect(result.requiresOAuth).toBe(false);
|
||||||
|
expect(result.apiKey?.source).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still detect OAuth when apiKey.source is user', async () => {
|
||||||
|
const rawConfig: t.MCPOptions = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://api.example.com/sse',
|
||||||
|
apiKey: {
|
||||||
|
source: 'user',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDetectOAuthRequirement.mockResolvedValue({
|
||||||
|
requiresOAuth: true,
|
||||||
|
method: 'protected-resource-metadata',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await MCPServerInspector.inspect('test_server', rawConfig, mockConnection);
|
||||||
|
|
||||||
|
// Should call OAuth detection for user-provided API key
|
||||||
|
expect(mockDetectOAuthRequirement).toHaveBeenCalled();
|
||||||
|
expect(result.requiresOAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fetch capabilities when server has no tools', async () => {
|
it('should fetch capabilities when server has no tools', async () => {
|
||||||
const rawConfig: t.MCPOptions = {
|
const rawConfig: t.MCPOptions = {
|
||||||
type: 'stdio',
|
type: 'stdio',
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,250 @@ describe('ServerConfigsDB', () => {
|
||||||
expect(retrieved?.oauth?.client_id).toBe('my-client-id');
|
expect(retrieved?.oauth?.client_id).toBe('my-client-id');
|
||||||
expect(retrieved?.oauth?.client_secret).toBeUndefined();
|
expect(retrieved?.oauth?.client_secret).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should encrypt apiKey.key when saving admin-provided API key', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'Admin API Key Server',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'my-secret-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Verify the key is encrypted in DB (not plaintext)
|
||||||
|
const MCPServer = mongoose.models.MCPServer;
|
||||||
|
const server = await MCPServer.findOne({ serverName: created.serverName });
|
||||||
|
expect(server?.config?.apiKey?.key).not.toBe('my-secret-api-key');
|
||||||
|
expect(server?.config?.apiKey?.source).toBe('admin');
|
||||||
|
expect(server?.config?.apiKey?.authorization_type).toBe('bearer');
|
||||||
|
|
||||||
|
// Verify the key is decrypted when accessed via get()
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
expect(retrieved?.apiKey?.key).toBe('my-secret-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve apiKey.key when not provided in update (admin mode)', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Preserve Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'original-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Update without providing the key
|
||||||
|
const updatedConfig: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Preserve Test',
|
||||||
|
description: 'Updated description',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
// key not provided - should be preserved
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||||
|
|
||||||
|
// Verify the key is still available and decrypted
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
expect(retrieved?.apiKey?.key).toBe('original-api-key');
|
||||||
|
expect(retrieved?.description).toBe('Updated description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow updating apiKey.key when explicitly provided', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Update Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'old-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Update with new key
|
||||||
|
const updatedConfig: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Update Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'new-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||||
|
|
||||||
|
// Verify the key is updated
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
expect(retrieved?.apiKey?.key).toBe('new-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve apiKey.key when authorization_type changes (bearer to custom)', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Auth Type Change Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'my-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Update: change from bearer to custom header, without providing key
|
||||||
|
const updatedConfig: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Auth Type Change Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'custom',
|
||||||
|
custom_header: 'X-My-Api-Key',
|
||||||
|
// key not provided - should be preserved
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||||
|
|
||||||
|
// Verify the key is preserved and authorization_type/custom_header updated
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
expect(retrieved?.apiKey?.key).toBe('my-api-key');
|
||||||
|
expect(retrieved?.apiKey?.authorization_type).toBe('custom');
|
||||||
|
expect(retrieved?.apiKey?.custom_header).toBe('X-My-Api-Key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT preserve apiKey.key when switching from admin to user source', async () => {
|
||||||
|
// Create server with admin-provided API key
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'Source Switch Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'admin-secret-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Update to user-provided mode (no key should be preserved)
|
||||||
|
const updatedConfig: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'Source Switch Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'user',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||||
|
|
||||||
|
// Verify the old admin key is NOT preserved (would be a security issue)
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
expect(retrieved?.apiKey?.source).toBe('user');
|
||||||
|
expect(retrieved?.apiKey?.key).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT preserve apiKey.key when switching from user to admin without providing key', async () => {
|
||||||
|
// Create server with user-provided API key mode
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'User to Admin Switch Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'user',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Update to admin mode without providing a key
|
||||||
|
const updatedConfig: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'User to Admin Switch Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
// key not provided - should NOT try to preserve from user mode
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await serverConfigsDB.update(created.serverName, updatedConfig, userId);
|
||||||
|
|
||||||
|
// Verify no key is present (user mode doesn't store keys)
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
expect(retrieved?.apiKey?.source).toBe('admin');
|
||||||
|
expect(retrieved?.apiKey?.key).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform user-provided API key config with customUserVars and headers', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'User API Key Server',
|
||||||
|
apiKey: {
|
||||||
|
source: 'user',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
// Cast to access headers (SSE config has headers)
|
||||||
|
const retrievedWithHeaders = retrieved as ParsedServerConfig & {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should have customUserVars with MCP_API_KEY
|
||||||
|
expect(retrieved?.customUserVars).toBeDefined();
|
||||||
|
expect(retrieved?.customUserVars?.MCP_API_KEY).toEqual({
|
||||||
|
title: 'API Key',
|
||||||
|
description: 'Your API key for this MCP server',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have headers with placeholder
|
||||||
|
expect(retrievedWithHeaders?.headers).toBeDefined();
|
||||||
|
expect(retrievedWithHeaders?.headers?.Authorization).toBe('Bearer {{MCP_API_KEY}}');
|
||||||
|
|
||||||
|
// Key should be undefined (user provides it)
|
||||||
|
expect(retrieved?.apiKey?.key).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform user-provided API key with custom header', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'Custom Header API Key Server',
|
||||||
|
apiKey: {
|
||||||
|
source: 'user',
|
||||||
|
authorization_type: 'custom',
|
||||||
|
custom_header: 'X-My-Api-Key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
// Cast to access headers (SSE config has headers)
|
||||||
|
const retrievedWithHeaders = retrieved as ParsedServerConfig & {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should have headers with custom header name
|
||||||
|
expect(retrievedWithHeaders?.headers?.['X-My-Api-Key']).toBe('{{MCP_API_KEY}}');
|
||||||
|
expect(retrievedWithHeaders?.headers?.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('remove()', () => {
|
describe('remove()', () => {
|
||||||
|
|
@ -997,5 +1241,36 @@ describe('ServerConfigsDB', () => {
|
||||||
expect(result[created2.serverName]?.oauth?.client_id).toBe('client-2');
|
expect(result[created2.serverName]?.oauth?.client_id).toBe('client-2');
|
||||||
expect(result[created2.serverName]?.oauth?.client_secret).toBeUndefined();
|
expect(result[created2.serverName]?.oauth?.client_secret).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return config without apiKey.key when decryption fails (graceful degradation)', async () => {
|
||||||
|
const config: ParsedServerConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
title: 'API Key Decryption Failure Test',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'test-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const created = await serverConfigsDB.add('temp-name', config, userId);
|
||||||
|
|
||||||
|
// Directly corrupt the encrypted key in the database to simulate decryption failure
|
||||||
|
const MCPServer = mongoose.models.MCPServer;
|
||||||
|
await MCPServer.updateOne(
|
||||||
|
{ serverName: created.serverName },
|
||||||
|
{ $set: { 'config.apiKey.key': 'invalid:corrupted:encrypted:value' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get should return config without the key (graceful degradation)
|
||||||
|
const retrieved = await serverConfigsDB.get(created.serverName, userId);
|
||||||
|
|
||||||
|
expect(retrieved).toBeDefined();
|
||||||
|
expect(retrieved?.title).toBe('API Key Decryption Failure Test');
|
||||||
|
expect(retrieved?.apiKey?.source).toBe('admin');
|
||||||
|
expect(retrieved?.apiKey?.authorization_type).toBe('bearer');
|
||||||
|
// Key should be removed due to decryption failure
|
||||||
|
expect(retrieved?.apiKey?.key).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,10 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
||||||
'[ServerConfigsDB.add] User ID is required to create a database-stored MCP server.',
|
'[ServerConfigsDB.add] User ID is required to create a database-stored MCP server.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Transform user-provided API key config (adds customUserVars and headers)
|
||||||
|
const transformedConfig = this.transformUserApiKeyConfig(config);
|
||||||
// Encrypt sensitive fields before storing in database
|
// Encrypt sensitive fields before storing in database
|
||||||
const encryptedConfig = await this.encryptConfig(config);
|
const encryptedConfig = await this.encryptConfig(transformedConfig);
|
||||||
const createdServer = await this._dbMethods.createMCPServer({
|
const createdServer = await this._dbMethods.createMCPServer({
|
||||||
config: encryptedConfig,
|
config: encryptedConfig,
|
||||||
author: userId,
|
author: userId,
|
||||||
|
|
@ -132,25 +134,44 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle secret preservation and encryption
|
|
||||||
const existingServer = await this._dbMethods.findMCPServerById(serverName);
|
const existingServer = await this._dbMethods.findMCPServerById(serverName);
|
||||||
let configToSave: ParsedServerConfig;
|
let configToSave: ParsedServerConfig = { ...config };
|
||||||
|
|
||||||
|
// Transform user-provided API key config (adds customUserVars and headers)
|
||||||
|
configToSave = this.transformUserApiKeyConfig(configToSave);
|
||||||
|
|
||||||
|
// Encrypt NEW secrets only (secrets provided in this update)
|
||||||
|
// We must do this BEFORE preserving existing encrypted secrets
|
||||||
|
configToSave = await this.encryptConfig(configToSave);
|
||||||
|
|
||||||
|
// Preserve existing OAuth client_secret if not provided in update (already encrypted)
|
||||||
if (!config.oauth?.client_secret && existingServer?.config?.oauth?.client_secret) {
|
if (!config.oauth?.client_secret && existingServer?.config?.oauth?.client_secret) {
|
||||||
// No new secret provided - preserve the existing encrypted secret from DB (don't re-encrypt)
|
|
||||||
configToSave = {
|
configToSave = {
|
||||||
...config,
|
...configToSave,
|
||||||
oauth: {
|
oauth: {
|
||||||
...config.oauth,
|
...configToSave.oauth,
|
||||||
client_secret: existingServer.config.oauth.client_secret,
|
client_secret: existingServer.config.oauth.client_secret,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (config.oauth?.client_secret) {
|
}
|
||||||
// New secret provided - encrypt it
|
|
||||||
configToSave = await this.encryptConfig(config);
|
// Preserve existing API key if not provided in update (already encrypted)
|
||||||
} else {
|
// Only preserve if both old and new configs use admin mode to avoid cross-mode key leakage
|
||||||
// No secret in config or DB - nothing to encrypt
|
if (
|
||||||
configToSave = config;
|
config.apiKey?.source === 'admin' &&
|
||||||
|
!config.apiKey?.key &&
|
||||||
|
existingServer?.config?.apiKey?.source === 'admin' &&
|
||||||
|
existingServer?.config?.apiKey?.key
|
||||||
|
) {
|
||||||
|
configToSave = {
|
||||||
|
...configToSave,
|
||||||
|
apiKey: {
|
||||||
|
source: configToSave.apiKey!.source,
|
||||||
|
authorization_type: configToSave.apiKey!.authorization_type,
|
||||||
|
custom_header: configToSave.apiKey?.custom_header,
|
||||||
|
key: existingServer.config.apiKey.key,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// specific user permissions for action permission will be handled in the controller calling the update method of the registry
|
// specific user permissions for action permission will be handled in the controller calling the update method of the registry
|
||||||
|
|
@ -366,21 +387,86 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
||||||
return await this.decryptConfig(config);
|
return await this.decryptConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms user-provided API key config by auto-generating customUserVars and headers.
|
||||||
|
* This is a config transformation, not encryption.
|
||||||
|
* @param config - The server config to transform
|
||||||
|
* @returns The transformed config with customUserVars and headers set up
|
||||||
|
*/
|
||||||
|
private transformUserApiKeyConfig(config: ParsedServerConfig): ParsedServerConfig {
|
||||||
|
if (!config.apiKey || config.apiKey.source !== 'user') {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...config };
|
||||||
|
const headerName =
|
||||||
|
result.apiKey!.authorization_type === 'custom'
|
||||||
|
? result.apiKey!.custom_header || 'X-Api-Key'
|
||||||
|
: 'Authorization';
|
||||||
|
|
||||||
|
let headerValue: string;
|
||||||
|
if (result.apiKey!.authorization_type === 'basic') {
|
||||||
|
headerValue = 'Basic {{MCP_API_KEY}}';
|
||||||
|
} else if (result.apiKey!.authorization_type === 'bearer') {
|
||||||
|
headerValue = 'Bearer {{MCP_API_KEY}}';
|
||||||
|
} else {
|
||||||
|
headerValue = '{{MCP_API_KEY}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
result.customUserVars = {
|
||||||
|
...result.customUserVars,
|
||||||
|
MCP_API_KEY: {
|
||||||
|
title: 'API Key',
|
||||||
|
description: 'Your API key for this MCP server',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cast to access headers property (not available on Stdio type)
|
||||||
|
const resultWithHeaders = result as ParsedServerConfig & {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
resultWithHeaders.headers = {
|
||||||
|
...resultWithHeaders.headers,
|
||||||
|
[headerName]: headerValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove key field since it's user-provided (destructure to omit, not set to undefined)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { key: _removed, ...apiKeyWithoutKey } = result.apiKey!;
|
||||||
|
result.apiKey = apiKeyWithoutKey;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts sensitive fields in config before database storage.
|
* Encrypts sensitive fields in config before database storage.
|
||||||
* Currently encrypts only oauth.client_secret.
|
* Encrypts oauth.client_secret and apiKey.key (when source === 'admin').
|
||||||
* Throws on failure to prevent storing plaintext secrets.
|
* Throws on failure to prevent storing plaintext secrets.
|
||||||
*/
|
*/
|
||||||
private async encryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
|
private async encryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
|
||||||
if (!config.oauth?.client_secret) {
|
let result = { ...config };
|
||||||
return config;
|
|
||||||
}
|
// Encrypt admin-provided API key
|
||||||
|
if (result.apiKey?.source === 'admin' && result.apiKey.key) {
|
||||||
try {
|
try {
|
||||||
return {
|
result.apiKey = {
|
||||||
...config,
|
...result.apiKey,
|
||||||
|
key: await encryptV2(result.apiKey.key),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt apiKey.key', error);
|
||||||
|
throw new Error('Failed to encrypt MCP server configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt OAuth client_secret
|
||||||
|
if (result.oauth?.client_secret) {
|
||||||
|
try {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
oauth: {
|
oauth: {
|
||||||
...config.oauth,
|
...result.oauth,
|
||||||
client_secret: await encryptV2(config.oauth.client_secret),
|
client_secret: await encryptV2(result.oauth.client_secret),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -389,20 +475,45 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypts sensitive fields in config after database retrieval.
|
* Decrypts sensitive fields in config after database retrieval.
|
||||||
|
* Decrypts oauth.client_secret and apiKey.key (when source === 'admin').
|
||||||
* Returns config without secret on failure (graceful degradation).
|
* Returns config without secret on failure (graceful degradation).
|
||||||
*/
|
*/
|
||||||
private async decryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
|
private async decryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
|
||||||
if (!config.oauth?.client_secret) {
|
let result = { ...config };
|
||||||
return config;
|
|
||||||
}
|
// Handle API key decryption (admin-provided only)
|
||||||
|
if (result.apiKey?.source === 'admin' && result.apiKey.key) {
|
||||||
try {
|
try {
|
||||||
return {
|
result.apiKey = {
|
||||||
...config,
|
...result.apiKey,
|
||||||
|
key: await decryptV2(result.apiKey.key),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
'[ServerConfigsDB.decryptConfig] Failed to decrypt apiKey.key, returning config without key',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { key: _removedKey, ...apiKeyWithoutKey } = result.apiKey;
|
||||||
|
result.apiKey = apiKeyWithoutKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OAuth client_secret decryption
|
||||||
|
if (result.oauth?.client_secret) {
|
||||||
|
// Cast oauth to type with client_secret since we've verified it exists
|
||||||
|
const oauthConfig = result.oauth as { client_secret: string } & typeof result.oauth;
|
||||||
|
try {
|
||||||
|
result = {
|
||||||
|
...result,
|
||||||
oauth: {
|
oauth: {
|
||||||
...config.oauth,
|
...oauthConfig,
|
||||||
client_secret: await decryptV2(config.oauth.client_secret),
|
client_secret: await decryptV2(oauthConfig.client_secret),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -411,11 +522,14 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { client_secret: _removed, ...oauthWithoutSecret } = config.oauth;
|
const { client_secret: _removed, ...oauthWithoutSecret } = oauthConfig;
|
||||||
return {
|
result = {
|
||||||
...config,
|
...result,
|
||||||
oauth: oauthWithoutSecret,
|
oauth: oauthWithoutSecret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1377,4 +1377,157 @@ describe('processMCPEnv', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admin-provided API key header injection', () => {
|
||||||
|
it('should apply admin-provided bearer API key to Authorization header', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'my-secret-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
expect(result.headers?.Authorization).toBe('Bearer my-secret-api-key');
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply admin-provided basic API key to Authorization header', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'basic',
|
||||||
|
key: 'base64encodedcreds',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
expect(result.headers?.Authorization).toBe('Basic base64encodedcreds');
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply admin-provided custom API key to custom header', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'custom',
|
||||||
|
custom_header: 'X-Api-Key',
|
||||||
|
key: 'my-custom-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
expect(result.headers?.['X-Api-Key']).toBe('my-custom-api-key');
|
||||||
|
expect(result.headers?.Authorization).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default X-Api-Key header when custom_header is not provided', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'custom',
|
||||||
|
key: 'my-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
expect(result.headers?.['X-Api-Key']).toBe('my-api-key');
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply user-provided API key (handled via placeholders)', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: {
|
||||||
|
source: 'user',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer {{MCP_API_KEY}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
// User-provided key should NOT be injected - placeholder remains
|
||||||
|
expect(result.headers?.Authorization).toBe('Bearer {{MCP_API_KEY}}');
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge admin API key header with existing headers', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Custom-Header': 'custom-value',
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
key: 'my-api-key',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
expect(result.headers?.['Content-Type']).toBe('application/json');
|
||||||
|
expect(result.headers?.['X-Custom-Header']).toBe('custom-value');
|
||||||
|
expect(result.headers?.Authorization).toBe('Bearer my-api-key');
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not inject header when apiKey.key is missing', () => {
|
||||||
|
const options: MCPOptions = {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: {
|
||||||
|
source: 'admin',
|
||||||
|
authorization_type: 'bearer',
|
||||||
|
// key is missing
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processMCPEnv({ options });
|
||||||
|
|
||||||
|
if (isStreamableHTTPOptions(result)) {
|
||||||
|
expect(result.headers?.Authorization).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected streamable-http options');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,38 @@ export function processMCPEnv(params: {
|
||||||
|
|
||||||
const newObj: MCPOptions = structuredClone(options);
|
const newObj: MCPOptions = structuredClone(options);
|
||||||
|
|
||||||
|
// Apply admin-provided API key to headers at runtime
|
||||||
|
// Note: User-provided keys use {{MCP_API_KEY}} placeholder in headers,
|
||||||
|
// which is processed later via customUserVars replacement
|
||||||
|
if ('apiKey' in newObj && newObj.apiKey) {
|
||||||
|
const apiKeyConfig = newObj.apiKey as {
|
||||||
|
key?: string;
|
||||||
|
source: 'admin' | 'user';
|
||||||
|
authorization_type: 'basic' | 'bearer' | 'custom';
|
||||||
|
custom_header?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (apiKeyConfig.source === 'admin' && apiKeyConfig.key) {
|
||||||
|
const { key, authorization_type, custom_header } = apiKeyConfig;
|
||||||
|
const headerName =
|
||||||
|
authorization_type === 'custom' ? custom_header || 'X-Api-Key' : 'Authorization';
|
||||||
|
|
||||||
|
let headerValue = key;
|
||||||
|
if (authorization_type === 'basic') {
|
||||||
|
headerValue = `Basic ${key}`;
|
||||||
|
} else if (authorization_type === 'bearer') {
|
||||||
|
headerValue = `Bearer ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize headers if needed and add the API key header (overwrites if header already exists)
|
||||||
|
const objWithHeaders = newObj as { headers?: Record<string, string> };
|
||||||
|
if (!objWithHeaders.headers) {
|
||||||
|
objWithHeaders.headers = {};
|
||||||
|
}
|
||||||
|
objWithHeaders.headers[headerName] = headerValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ('env' in newObj && newObj.env) {
|
if ('env' in newObj && newObj.env) {
|
||||||
const processedEnv: Record<string, string> = {};
|
const processedEnv: Record<string, string> = {};
|
||||||
for (const [key, originalValue] of Object.entries(newObj.env)) {
|
for (const [key, originalValue] of Object.entries(newObj.env)) {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,23 @@ const BaseOptionsSchema = z.object({
|
||||||
.optional(),
|
.optional(),
|
||||||
/** Custom headers to send with OAuth requests (registration, discovery, token exchange, etc.) */
|
/** Custom headers to send with OAuth requests (registration, discovery, token exchange, etc.) */
|
||||||
oauth_headers: z.record(z.string(), z.string()).optional(),
|
oauth_headers: z.record(z.string(), z.string()).optional(),
|
||||||
|
/**
|
||||||
|
* API Key authentication configuration for SSE and Streamable HTTP transports
|
||||||
|
* - source: 'admin' means the key is provided by admin and shared by all users
|
||||||
|
* - source: 'user' means each user provides their own key via customUserVars
|
||||||
|
*/
|
||||||
|
apiKey: z
|
||||||
|
.object({
|
||||||
|
/** API key value (only for admin-provided mode, stored encrypted) */
|
||||||
|
key: z.string().optional(),
|
||||||
|
/** Whether key is provided by admin or each user */
|
||||||
|
source: z.enum(['admin', 'user']),
|
||||||
|
/** How to format the authorization header */
|
||||||
|
authorization_type: z.enum(['basic', 'bearer', 'custom']),
|
||||||
|
/** Custom header name when authorization_type is 'custom' */
|
||||||
|
custom_header: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
customUserVars: z
|
customUserVars: z
|
||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue