🔐 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

@ -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)) {