feat(mcp): make allowed_response_headers configurable from the database and dashboard

The field was config.yaml only, so a server created through the dashboard or the
REST API could never surface upstream response headers.

Adds the column to all three prisma schemas plus a migration, carries it through
the create, edit and temporary-record paths, the row -> MCPServer build, and the
dashboard's edit form and detail view. The unsupported-transport warning now also
fires for servers loaded from the database, since the dashboard can create the
same misconfiguration as config.yaml.

The column is a Prisma String[], which rejects null, and the edit form clears a
field to null, so an explicit null normalizes to [] alongside allowed_tools.
This commit is contained in:
Tin Chi Lo 2026-07-16 16:43:53 -07:00
parent e0d32ea7f6
commit c8ef01bfbe
16 changed files with 175 additions and 2 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "allowed_response_headers" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -311,6 +311,7 @@ model LiteLLM_MCPServerTable {
tool_name_to_display_name Json? @default("{}")
tool_name_to_description Json? @default("{}")
extra_headers String[] @default([])
allowed_response_headers String[] @default([])
static_headers Json? @default("{}")
// Admin-configured environment variables interpolated into static_headers
// via ${NAME} syntax. Stored as an array of

View file

@ -67,6 +67,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
extra_headers: List[str] = Field(default_factory=list)
allowed_response_headers: list[str] = Field(default_factory=list)
mcp_info: Optional[MCPInfo] = None
static_headers: Optional[Dict[str, str]] = None
env_vars: Optional[List[MCPEnvVar]] = None

View file

@ -289,8 +289,9 @@ def _prepare_mcp_server_data(
data_dict.pop("alias", None)
# Prisma ``allowed_tools`` is a required String[]; ``null`` is invalid.
# The UI sends null to clear a whitelist — treat that as ``[]``.
if "allowed_tools" in data_dict and data_dict["allowed_tools"] is None:
data_dict["allowed_tools"] = []
for list_field in ("allowed_tools", "allowed_response_headers"):
if list_field in data_dict and data_dict[list_field] is None:
data_dict[list_field] = []
# Json map fields use ``@default("{}")``; explicit null means clear overrides.
for json_map_field in (
"tool_name_to_display_name",

View file

@ -1768,6 +1768,7 @@ class MCPServerManager:
authentication_token=auth_value,
mcp_info=mcp_info,
extra_headers=getattr(mcp_server, "extra_headers", None),
allowed_response_headers=getattr(mcp_server, "allowed_response_headers", None),
static_headers=static_headers_dict,
env_vars=env_vars_list,
client_id=client_id_value or getattr(mcp_server, "client_id", None),
@ -1825,6 +1826,7 @@ class MCPServerManager:
max_concurrent_requests=getattr(mcp_server, "max_concurrent_requests", None),
)
_warn_internal_delegate_pkce_if_applicable(new_server, source="database")
_warn_response_headers_unsupported_transport_if_applicable(new_server, source="database")
if persist_discovered_endpoints:
await self._persist_discovered_obo_token_url(
server_id=mcp_server.server_id,

View file

@ -1256,6 +1256,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
extra_headers: Optional[List[str]] = None
allowed_response_headers: list[str] | None = None
static_headers: Optional[Dict[str, str]] = None
env_vars: Optional[List[MCPEnvVar]] = None
instructions: Optional[str] = None
@ -1362,6 +1363,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
tool_name_to_display_name: Optional[Dict[str, str]] = None
tool_name_to_description: Optional[Dict[str, str]] = None
extra_headers: Optional[List[str]] = None
allowed_response_headers: list[str] | None = None
static_headers: Optional[Dict[str, str]] = None
env_vars: Optional[List[MCPEnvVar]] = None
instructions: Optional[str] = None

View file

@ -533,6 +533,7 @@ if MCP_AVAILABLE:
sanitized.spec_path = None
sanitized.static_headers = None
sanitized.extra_headers = []
sanitized.allowed_response_headers = []
sanitized.env = {}
sanitized.command = None
sanitized.args = []
@ -577,6 +578,7 @@ if MCP_AVAILABLE:
sanitized.command = None
sanitized.args = []
sanitized.extra_headers = []
sanitized.allowed_response_headers = []
sanitized.allowed_tools = []
sanitized.mcp_access_groups = []
sanitized.teams = []
@ -683,6 +685,7 @@ if MCP_AVAILABLE:
mcp_access_groups=payload.mcp_access_groups,
allowed_tools=payload.allowed_tools or [],
extra_headers=payload.extra_headers or [],
allowed_response_headers=payload.allowed_response_headers or [],
mcp_info=payload.mcp_info,
static_headers=payload.static_headers,
command=payload.command,

View file

@ -311,6 +311,7 @@ model LiteLLM_MCPServerTable {
tool_name_to_display_name Json? @default("{}")
tool_name_to_description Json? @default("{}")
extra_headers String[] @default([])
allowed_response_headers String[] @default([])
static_headers Json? @default("{}")
// Admin-configured environment variables interpolated into static_headers
// via ${NAME} syntax. Stored as an array of

View file

@ -311,6 +311,7 @@ model LiteLLM_MCPServerTable {
tool_name_to_display_name Json? @default("{}")
tool_name_to_description Json? @default("{}")
extra_headers String[] @default([])
allowed_response_headers String[] @default([])
static_headers Json? @default("{}")
// Admin-configured environment variables interpolated into static_headers
// via ${NAME} syntax. Stored as an array of

View file

@ -958,6 +958,44 @@ def test_prepare_mcp_server_data_create_carries_token_exchange_columns():
assert data["token_exchange_profile"] == "entra_obo"
def test_prepare_mcp_server_data_create_carries_allowed_response_headers():
"""The create path must emit allowed_response_headers as a column value, or a server registered
through the REST API / dashboard could never surface upstream response headers."""
request = NewMCPServerRequest(
server_name="hdr_write",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
allowed_response_headers=["X-Example-Header"],
)
data = _prepare_mcp_server_data(request)
assert data["allowed_response_headers"] == ["X-Example-Header"]
def test_prepare_mcp_server_data_update_maps_cleared_allowed_response_headers_to_empty_list():
"""The column is a Prisma String[], which rejects null. The edit form clears the field to null,
so an explicit null must become [] rather than reaching the DB and failing the update."""
request = UpdateMCPServerRequest(
server_id="hdr-1",
allowed_response_headers=None,
)
data = _prepare_mcp_server_data(request, exclude_unset=True)
assert data["allowed_response_headers"] == []
def test_prepare_mcp_server_data_update_omits_allowed_response_headers_when_untouched():
"""A partial update that never mentions the field must not write it, so an edit to an unrelated
field cannot silently wipe a configured allowlist."""
request = UpdateMCPServerRequest(server_id="hdr-1", alias="renamed")
data = _prepare_mcp_server_data(request, exclude_unset=True)
assert "allowed_response_headers" not in data
def test_prepare_mcp_server_data_update_carries_token_exchange_columns():
"""The partial-update path (PUT /v1/mcp/server, exclude_unset) must carry the three
token-exchange columns when the caller provides them."""

View file

@ -6560,6 +6560,57 @@ class TestWarnResponseHeadersUnsupportedTransport:
assert not any("allowed_response_headers" in m for m in caplog.messages)
@pytest.mark.asyncio
async def test_loading_such_a_server_from_the_database_emits_the_warning(self, caplog):
"""The field is DB-backed, so the dashboard can create the same misconfiguration as config.yaml."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="hdr-sse-db",
server_name="hdr_sse_db",
url="https://example.com/sse",
transport=MCPTransport.sse,
allowed_response_headers=["X-Example-Header"],
)
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.build_mcp_server_from_table(table_record)
assert any("allowed_response_headers is set but the transport is" in m for m in caplog.messages)
class TestAllowedResponseHeadersFromDatabase:
"""The DB row is the dashboard's storage, so the field must survive the row -> MCPServer build."""
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_carries_allowed_response_headers(self):
"""Without this the dashboard could save the allowlist and the gateway would silently ignore it."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="hdr-db-1",
server_name="hdr_db",
url="https://example.com/mcp",
transport=MCPTransport.http,
allowed_response_headers=["X-Example-Header"],
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
assert mcp_server.allowed_response_headers == ["X-Example-Header"]
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_defaults_to_no_headers(self):
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="hdr-db-2",
server_name="hdr_db_none",
url="https://example.com/mcp",
transport=MCPTransport.http,
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
assert not mcp_server.allowed_response_headers
class TestHasClientCredentialsOAuth2Flow:
"""

View file

@ -5376,3 +5376,30 @@ async def test_edit_mcp_server_snapshot_failure_skips_purge_but_edit_succeeds():
assert result.server_id == server_id
mock_purge.assert_not_awaited()
def test_temporary_mcp_server_record_carries_allowed_response_headers():
"""The pre-save 'test this server' flow builds an unpersisted record; dropping the field there
would make a temporary server behave differently from the one the admin is about to save."""
payload = NewMCPServerRequest(
server_name="hdr_tmp",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
allowed_response_headers=["X-Example-Header"],
)
record = mgmt_endpoints._build_temporary_mcp_server_record(payload, created_by="tester")
assert record.allowed_response_headers == ["X-Example-Header"]
def test_temporary_mcp_server_record_defaults_allowed_response_headers_to_empty():
payload = NewMCPServerRequest(
server_name="hdr_tmp_none",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
)
record = mgmt_endpoints._build_temporary_mcp_server_record(payload, created_by="tester")
assert record.allowed_response_headers == []

View file

@ -279,6 +279,36 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Allowed Response Headers
<Tooltip title="Surface these response headers from this MCP server back to the caller, on the tool result's _meta under 'ai.litellm/responseHeaders'. Streamable HTTP transport only. Credential, cookie, session and hop-by-hop headers are never forwarded.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
{mcpServer?.allowed_response_headers && mcpServer.allowed_response_headers.length > 0 && (
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
{mcpServer.allowed_response_headers.length} configured
</span>
)}
</span>
}
name="allowed_response_headers"
>
<Select
mode="tags"
placeholder={
mcpServer?.allowed_response_headers && mcpServer.allowed_response_headers.length > 0
? `Currently: ${mcpServer.allowed_response_headers.join(", ")}`
: "Enter header names (e.g., X-Request-Id, X-RateLimit-Remaining)"
}
className="rounded-lg"
size="large"
tokenSeparators={[","]}
allowClear
/>
</Form.Item>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">

View file

@ -295,6 +295,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
static_headers: initialStaticHeaders,
env_vars: initialEnvVars,
extra_headers: mcpServer.extra_headers || [],
allowed_response_headers: mcpServer.allowed_response_headers || [],
oauth_flow_type: oauth2FlowToFormValue(mcpServer.oauth2_flow),
dcr_bridge: Boolean(mcpServer.dcr_bridge),
token_validation_json: mcpServer.token_validation
@ -867,6 +868,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
alias: restValues.alias,
// Include permission management fields
extra_headers: restValues.extra_headers || [],
allowed_response_headers: restValues.allowed_response_headers || [],
...(toolAllowlistEnforced
? {
allowed_tools: allowedTools,

View file

@ -293,6 +293,16 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
)}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Allowed Response Headers</Text>
<div className="col-span-2 text-sm text-gray-900">
{mcpServer.allowed_response_headers && mcpServer.allowed_response_headers.length > 0 ? (
mcpServer.allowed_response_headers.join(", ")
) : (
<span className="text-gray-400"></span>
)}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Allow All Keys</Text>
<div className="col-span-2">

View file

@ -356,6 +356,7 @@ export interface MCPServer {
updated_at: string;
updated_by: string;
extra_headers?: string[] | null;
allowed_response_headers?: string[] | null;
static_headers?: Record<string, string> | null;
status?: "healthy" | "unhealthy" | "unknown";
last_health_check?: string | null;