[Feat] MCP Gateway - Allow customizing what client side header to use (#12460)

* add _get_mcp_auth_header_from_headers

* test_process_mcp_request_with_custom_auth_header

* Using a different Authentication Header

* fix customize MCP Auth header name
This commit is contained in:
Ishaan Jaff
2025-07-09 12:30:20 -07:00
committed by GitHub
parent 66d8c653fd
commit afd382d09f
3 changed files with 263 additions and 2 deletions
+52
View File
@@ -442,6 +442,58 @@ if __name__ == "__main__":
</Tabs>
### Customize the MCP Auth Header Name
By default, LiteLLM uses `x-mcp-auth` to pass your credentials to MCP servers. You can change this header name in one of the following ways:
1. Set the `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` environment variable
```bash title="Environment Variable" showLineNumbers
export LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME="authorization"
```
2. Set the `mcp_client_side_auth_header_name` in the general settings on the config.yaml file
```yaml title="config.yaml" showLineNumbers
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: sk-xxxxxxx
general_settings:
mcp_client_side_auth_header_name: "authorization"
```
#### Using the authorization header
In this example the `authorization` header will be passed to the MCP server for authentication.
```bash title="cURL with authorization header" showLineNumbers
curl --location '<your-litellm-proxy-base-url>/v1/responses' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $LITELLM_API_KEY" \
--data '{
"model": "gpt-4o",
"tools": [
{
"type": "mcp",
"server_label": "litellm",
"server_url": "<your-litellm-proxy-base-url>/mcp",
"require_approval": "never",
"headers": {
"x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY",
"authorization": "Bearer sk-zapier-token-123"
}
}
],
"input": "Run available tools",
"tool_choice": "required"
}'
```
## ✨ MCP Permission Management
LiteLLM supports managing permissions for MCP Servers by Keys, Teams, Organizations (entities) on LiteLLM. When a MCP client attempts to list tools, LiteLLM will only return the tools the entity has permissions to access.
@@ -51,7 +51,7 @@ class MCPRequestHandler:
litellm_api_key = (
MCPRequestHandler.get_litellm_api_key_from_headers(headers) or ""
)
mcp_auth_header = headers.get(MCPRequestHandler.LITELLM_MCP_AUTH_HEADER_NAME)
mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(headers)
mcp_servers_header = headers.get(MCPRequestHandler.LITELLM_MCP_SERVERS_HEADER_NAME)
verbose_logger.debug(f"Raw MCP servers header: {mcp_servers_header}")
mcp_servers = None
@@ -83,6 +83,42 @@ class MCPRequestHandler:
)
return validated_user_api_key_auth, mcp_auth_header, mcp_servers
@staticmethod
def _get_mcp_auth_header_from_headers(headers: Headers) -> Optional[str]:
"""
Get the header passed to LiteLLM to pass to downstream MCP servers
By default litellm will check for the header `x-mcp-auth` by setting one of the following:
1. `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` as an environment variable
2. `mcp_client_side_auth_header_name` in the general settings on the config.yaml file
Support this auth: https://docs.litellm.ai/docs/mcp#using-your-mcp-with-client-side-credentials
If you want to use a different header name, you can set the `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` in the secret manager or `mcp_client_side_auth_header_name` in the general settings.
"""
mcp_client_side_auth_header_name: str = MCPRequestHandler._get_mcp_client_side_auth_header_name()
return headers.get(mcp_client_side_auth_header_name)
@staticmethod
def _get_mcp_client_side_auth_header_name() -> str:
"""
Get the header name used to pass the MCP auth header to the MCP server
By default litellm will check for the header `x-mcp-auth` by setting one of the following:
1. `LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME` as an environment variable
2. `mcp_client_side_auth_header_name` in the general settings on the config.yaml file
"""
from litellm.proxy.proxy_server import general_settings
from litellm.secret_managers.main import get_secret_str
MCP_CLIENT_SIDE_AUTH_HEADER_NAME: str = MCPRequestHandler.LITELLM_MCP_AUTH_HEADER_NAME
if get_secret_str("LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME") is not None:
MCP_CLIENT_SIDE_AUTH_HEADER_NAME = get_secret_str("LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME") or MCP_CLIENT_SIDE_AUTH_HEADER_NAME
elif general_settings.get("mcp_client_side_auth_header_name") is not None:
MCP_CLIENT_SIDE_AUTH_HEADER_NAME = general_settings.get("mcp_client_side_auth_header_name") or MCP_CLIENT_SIDE_AUTH_HEADER_NAME
return MCP_CLIENT_SIDE_AUTH_HEADER_NAME
@staticmethod
def get_litellm_api_key_from_headers(headers: Headers) -> Optional[str]:
@@ -382,4 +382,177 @@ class TestMCPRequestHandler:
# Assert the results
assert auth_result == mock_auth_result
assert mcp_auth_header == expected_result["mcp_auth"]
assert mcp_servers_result == expected_result["mcp_servers"]
assert mcp_servers_result == expected_result["mcp_servers"]
class TestMCPCustomHeaderName:
"""Test suite for custom MCP authentication header name functionality"""
@pytest.mark.parametrize(
"env_var,general_setting,expected_header_name",
[
# Test case 1: Default behavior (no custom settings)
(None, None, "x-mcp-auth"),
# Test case 2: Environment variable set
("custom-mcp-header", None, "custom-mcp-header"),
# Test case 3: General setting set (env var takes precedence)
(None, "settings-mcp-header", "settings-mcp-header"),
# Test case 4: Both set (env var takes precedence)
("env-mcp-header", "settings-mcp-header", "env-mcp-header"),
# Test case 5: Empty env var (should fallback to default due to 'or' logic)
("", "settings-mcp-header", "x-mcp-auth"),
# Test case 6: Empty general setting (should fallback to default)
(None, "", "x-mcp-auth"),
],
)
def test_get_mcp_client_side_auth_header_name(
self, env_var, general_setting, expected_header_name
):
"""Test that custom header name configuration works correctly"""
# Mock the secret manager and general settings
with patch("litellm.secret_managers.main.get_secret_str") as mock_get_secret:
with patch("litellm.proxy.proxy_server.general_settings") as mock_general_settings:
# Configure mocks
mock_get_secret.return_value = env_var
mock_general_settings.get.return_value = general_setting
# Call the method
result = MCPRequestHandler._get_mcp_client_side_auth_header_name()
# Assert the result
assert result == expected_header_name
# Verify secret manager was called (the function calls it twice)
expected_secret_calls = 2 if env_var is not None else 1
assert mock_get_secret.call_count == expected_secret_calls
# Verify all calls were with the correct parameter
for call in mock_get_secret.call_args_list:
assert call.args == ("LITELLM_MCP_CLIENT_SIDE_AUTH_HEADER_NAME",)
# Verify general settings was called based on env var value
if env_var is None:
# When env var is None, general settings should be checked (twice if not None)
expected_general_calls = 2 if general_setting is not None else 1
assert mock_general_settings.get.call_count == expected_general_calls
for call in mock_general_settings.get.call_args_list:
assert call.args == ("mcp_client_side_auth_header_name",)
else:
# If env var is set (even empty string), general settings shouldn't be checked
mock_general_settings.get.assert_not_called()
@pytest.mark.parametrize(
"custom_header_name,headers,expected_auth_header",
[
# Test case 1: Default header name
(
"x-mcp-auth",
[(b"x-mcp-auth", b"default-auth-token")],
"default-auth-token"
),
# Test case 2: Custom header name
(
"custom-auth-header",
[(b"custom-auth-header", b"custom-auth-token")],
"custom-auth-token"
),
# Test case 3: Custom header name with case insensitive
(
"Custom-Auth-Header",
[(b"custom-auth-header", b"case-insensitive-token")],
"case-insensitive-token"
),
# Test case 4: Header not present
(
"missing-header",
[(b"x-mcp-auth", b"wrong-header-token")],
None
),
# Test case 5: Multiple headers, only custom one should be used
(
"my-custom-auth",
[
(b"x-mcp-auth", b"default-token"),
(b"my-custom-auth", b"custom-token")
],
"custom-token"
),
],
)
def test_get_mcp_auth_header_from_headers_with_custom_name(
self, custom_header_name, headers, expected_auth_header
):
"""Test that MCP auth header extraction uses custom header name"""
# Mock the header name method
with patch.object(
MCPRequestHandler,
'_get_mcp_client_side_auth_header_name',
return_value=custom_header_name
):
# Create headers from the test data
scope = {
"type": "http",
"method": "POST",
"path": "/test",
"headers": headers,
}
extracted_headers = MCPRequestHandler._safe_get_headers_from_scope(scope)
# Call the method
result = MCPRequestHandler._get_mcp_auth_header_from_headers(extracted_headers)
# Assert the result
assert result == expected_auth_header
@pytest.mark.asyncio
async def test_process_mcp_request_with_custom_auth_header(self):
"""Test that process_mcp_request works with custom authentication header"""
custom_header_name = "x-custom-mcp-auth"
custom_auth_token = "custom-auth-token-123"
api_key = "test-api-key"
# Create ASGI scope with custom header
scope = {
"type": "http",
"method": "POST",
"path": "/test",
"headers": [
(b"x-litellm-api-key", api_key.encode()),
(custom_header_name.encode(), custom_auth_token.encode()),
],
}
# Mock the custom header name method
with patch.object(
MCPRequestHandler,
'_get_mcp_client_side_auth_header_name',
return_value=custom_header_name
):
# Mock user_api_key_auth
mock_auth_result = UserAPIKeyAuth(
api_key=api_key,
user_id="test-user-id",
team_id="test-team-id",
)
with patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth"
) as mock_user_api_key_auth:
mock_user_api_key_auth.return_value = mock_auth_result
# Call the method
auth_result, mcp_auth_header, mcp_servers = await MCPRequestHandler.process_mcp_request(scope)
# Assert the results
assert auth_result == mock_auth_result
assert mcp_auth_header == custom_auth_token
assert mcp_servers is None
# Verify user_api_key_auth was called with correct API key
mock_user_api_key_auth.assert_called_once()
call_args = mock_user_api_key_auth.call_args
assert call_args.kwargs["api_key"] == api_key