mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30: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 {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 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)) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue