mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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:
parent
e0d32ea7f6
commit
c8ef01bfbe
16 changed files with 175 additions and 2 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "allowed_response_headers" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue