🔐 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:
Atef Bellaaj 2025-12-12 19:51:49 +01:00 committed by GitHub
parent abeaab6e17
commit e15d37b399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 836 additions and 84 deletions

View file

@ -28,6 +28,7 @@ enum AuthorizationTypeEnum {
export interface AuthConfig {
auth_type?: AuthTypeEnum;
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_custom_header?: string;
oauth_client_id?: string;
@ -171,8 +172,6 @@ export default function MCPAuth({
{localize('com_ui_none')}
</label>
</div>
{/*
TODO Support API keys for auth
<div className="flex items-center gap-2">
<label htmlFor="auth-apikey" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
@ -189,7 +188,7 @@ export default function MCPAuth({
</RadioGroup.Item>
{localize('com_ui_api_key')}
</label>
</div> */}
</div>
<div className="flex items-center gap-2">
<label htmlFor="auth-oauth" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
@ -228,21 +227,84 @@ export default function MCPAuth({
const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
const localize = useLocalize();
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 (
<>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
<input
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
className={inputClasses}
{...register('api_key')}
/>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_auth_type')}</label>
{/* API Key Source selection */}
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key_source')}</label>
<RadioGroup.Root
defaultValue={AuthorizationTypeEnum.Basic}
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>
<input
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
className={inputClasses}
{...register('api_key')}
/>
</>
)}
{/* 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
defaultValue={AuthorizationTypeEnum.Bearer}
onValueChange={(value) => setValue('api_key_authorization_type', value)}
value={authorization_type}
role="radiogroup"
@ -251,23 +313,6 @@ const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
className="mb-2 flex gap-6 overflow-hidden rounded-lg"
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">
<label htmlFor="auth-bearer" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
@ -285,6 +330,23 @@ const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
{localize('com_ui_bearer')}
</label>
</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">
<label htmlFor="auth-custom" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item

View file

@ -85,10 +85,13 @@ export default function MCPServerDialog({
let authType: AuthTypeEnum = AuthTypeEnum.None;
if (server.config.oauth) {
authType = AuthTypeEnum.OAuth;
} else if ('api_key' in server.config) {
} else if ('apiKey' in server.config && server.config.apiKey) {
authType = AuthTypeEnum.ServiceHttp;
}
// Extract apiKey config if present
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
return {
title: server.config.title || '',
description: server.config.description || '',
@ -97,9 +100,12 @@ export default function MCPServerDialog({
icon: server.config.iconPath || '',
auth: {
auth_type: authType,
api_key: '',
api_key_authorization_type: AuthorizationTypeEnum.Basic,
api_key_custom_header: '',
api_key: '', // NEVER pre-fill secrets
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
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_secret: '', // NEVER pre-fill secrets
oauth_authorization_url: server.config.oauth?.authorization_url || '',
@ -119,7 +125,8 @@ export default function MCPServerDialog({
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_authorization_type: AuthorizationTypeEnum.Basic,
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
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 = {
config,
};

View file

@ -704,6 +704,11 @@
"com_ui_analyzing": "Analyzing",
"com_ui_analyzing_finished": "Finished analyzing",
"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_delete_error": "Failed to delete archived conversation",
"com_ui_archive_error": "Failed to archive conversation",

View file

@ -68,6 +68,12 @@ export class MCPServerInspector {
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);
this.config.requiresOAuth = result.requiresOAuth;
this.config.oauthMetadata = result.metadata;

View file

@ -95,9 +95,25 @@ export class MCPServersRegistry {
userId?: string,
): Promise<t.ParsedServerConfig> {
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;
try {
parsedConfig = await MCPServerInspector.inspect(serverName, config);
parsedConfig = await MCPServerInspector.inspect(serverName, configForInspection);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
throw new Error(`MCP_INSPECTION_FAILED: Failed to connect to MCP server "${serverName}"`);

View file

@ -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 () => {
const rawConfig: t.MCPOptions = {
type: 'stdio',

View file

@ -265,6 +265,250 @@ describe('ServerConfigsDB', () => {
expect(retrieved?.oauth?.client_id).toBe('my-client-id');
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()', () => {
@ -997,5 +1241,36 @@ describe('ServerConfigsDB', () => {
expect(result[created2.serverName]?.oauth?.client_id).toBe('client-2');
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();
});
});
});

View file

@ -95,8 +95,10 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
'[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
const encryptedConfig = await this.encryptConfig(config);
const encryptedConfig = await this.encryptConfig(transformedConfig);
const createdServer = await this._dbMethods.createMCPServer({
config: encryptedConfig,
author: userId,
@ -132,25 +134,44 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
);
}
// Handle secret preservation and encryption
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) {
// No new secret provided - preserve the existing encrypted secret from DB (don't re-encrypt)
configToSave = {
...config,
...configToSave,
oauth: {
...config.oauth,
...configToSave.oauth,
client_secret: existingServer.config.oauth.client_secret,
},
};
} else if (config.oauth?.client_secret) {
// New secret provided - encrypt it
configToSave = await this.encryptConfig(config);
} else {
// No secret in config or DB - nothing to encrypt
configToSave = config;
}
// Preserve existing API key if not provided in update (already encrypted)
// Only preserve if both old and new configs use admin mode to avoid cross-mode key leakage
if (
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
@ -366,56 +387,149 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface {
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.
* Currently encrypts only oauth.client_secret.
* Encrypts oauth.client_secret and apiKey.key (when source === 'admin').
* Throws on failure to prevent storing plaintext secrets.
*/
private async encryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
if (!config.oauth?.client_secret) {
return config;
let result = { ...config };
// Encrypt admin-provided API key
if (result.apiKey?.source === 'admin' && result.apiKey.key) {
try {
result.apiKey = {
...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');
}
}
try {
return {
...config,
oauth: {
...config.oauth,
client_secret: await encryptV2(config.oauth.client_secret),
},
};
} catch (error) {
logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt client_secret', error);
throw new Error('Failed to encrypt MCP server configuration');
// Encrypt OAuth client_secret
if (result.oauth?.client_secret) {
try {
result = {
...result,
oauth: {
...result.oauth,
client_secret: await encryptV2(result.oauth.client_secret),
},
};
} catch (error) {
logger.error('[ServerConfigsDB.encryptConfig] Failed to encrypt client_secret', error);
throw new Error('Failed to encrypt MCP server configuration');
}
}
return result;
}
/**
* 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).
*/
private async decryptConfig(config: ParsedServerConfig): Promise<ParsedServerConfig> {
if (!config.oauth?.client_secret) {
return config;
let result = { ...config };
// Handle API key decryption (admin-provided only)
if (result.apiKey?.source === 'admin' && result.apiKey.key) {
try {
result.apiKey = {
...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;
}
}
try {
return {
...config,
oauth: {
...config.oauth,
client_secret: await decryptV2(config.oauth.client_secret),
},
};
} catch (error) {
logger.warn(
'[ServerConfigsDB.decryptConfig] Failed to decrypt client_secret, returning config without secret',
error,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { client_secret: _removed, ...oauthWithoutSecret } = config.oauth;
return {
...config,
oauth: oauthWithoutSecret,
};
// 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: {
...oauthConfig,
client_secret: await decryptV2(oauthConfig.client_secret),
},
};
} catch (error) {
logger.warn(
'[ServerConfigsDB.decryptConfig] Failed to decrypt client_secret, returning config without secret',
error,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { client_secret: _removed, ...oauthWithoutSecret } = oauthConfig;
result = {
...result,
oauth: oauthWithoutSecret,
};
}
}
return result;
}
}

View file

@ -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');
}
});
});
});

View file

@ -205,6 +205,38 @@ export function processMCPEnv(params: {
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) {
const processedEnv: Record<string, string> = {};
for (const [key, originalValue] of Object.entries(newObj.env)) {

View file

@ -73,6 +73,23 @@ const BaseOptionsSchema = z.object({
.optional(),
/** Custom headers to send with OAuth requests (registration, discovery, token exchange, etc.) */
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
.record(
z.string(),