mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-07 11:08:52 +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
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue