fix(mcp): load DB-stored servers whose env_vars hydrate as MCPEnvVar models

Prisma hydrates the env_vars JSON column on LiteLLM_MCPServerTable into
MCPEnvVar models, but build_mcp_server_from_table fed them straight into
MCPServer.env_vars (typed List[Dict[str, Any]]). Pydantic rejected the
model instances with a dict_type error, reload_servers_from_database
swallowed the exception, and the server was dropped from the registry; any
request for it then returned "server_not_found". This silently broke every
dashboard/DB-created MCP server that declared env_vars (the whole per-user
env-vars flow), while config-defined servers kept working because their
env_vars arrive from YAML as plain dicts.

_deserialize_json_list now normalizes Pydantic-model entries to plain dicts
via model_dump(mode="json"), matching the config path and the
List[Dict[str, Any]] contract.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-03 17:42:05 +00:00
parent af63258e40
commit 48e8f40150
No known key found for this signature in database
2 changed files with 63 additions and 5 deletions

View file

@ -298,7 +298,10 @@ def _deserialize_json_list(data: Any) -> Optional[List[Dict[str, Any]]]:
"""Deserialize a JSON array stored in the DB (``env_vars`` and friends).
Returns ``None`` for empty / null / unparseable input. Accepts strings
(raw JSON) or already-materialized lists.
(raw JSON), already-materialized lists of dicts, and lists of Pydantic
models (Prisma may hydrate a JSON column such as ``env_vars`` into
``MCPEnvVar`` objects); model entries are normalized to plain dicts so
downstream consumers expecting ``List[Dict[str, Any]]`` validate.
"""
if data is None or data == "" or data == []:
return None
@ -307,10 +310,13 @@ def _deserialize_json_list(data: Any) -> Optional[List[Dict[str, Any]]]:
parsed = json.loads(data)
except (json.JSONDecodeError, TypeError):
return None
return parsed if isinstance(parsed, list) else None
if isinstance(data, list):
return data
return None
data = parsed
if not isinstance(data, list):
return None
return [
item.model_dump(mode="json") if hasattr(item, "model_dump") else item
for item in data
]
class MCPServerManager:

View file

@ -28,10 +28,13 @@ from mcp.types import Tool as MCPTool
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
_deserialize_json_dict,
_deserialize_json_list,
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
MCPApprovalStatus,
MCPEnvVar,
MCPEnvVarScope,
MCPTransport,
)
from litellm.types.mcp import MCPAuth
@ -3049,6 +3052,55 @@ class TestMCPServerTimestamps:
assert rebuilt_table.created_at == created
assert rebuilt_table.updated_at == updated
def test_deserialize_json_list_normalizes_pydantic_models(self):
"""Prisma hydrates the ``env_vars`` JSON column into ``MCPEnvVar`` models;
``_deserialize_json_list`` must hand back plain dicts so ``MCPServer``
(typed ``List[Dict[str, Any]]``) validates."""
env_vars = [
MCPEnvVar(name="GITHUB_TOKEN", scope=MCPEnvVarScope.user, description="PAT"),
MCPEnvVar(name="REGION", value="us-east-1", scope=MCPEnvVarScope.global_),
]
result = _deserialize_json_list(env_vars)
assert result is not None
assert all(isinstance(item, dict) for item in result)
assert result[0]["name"] == "GITHUB_TOKEN"
assert result[0]["scope"] == "user"
assert result[1]["value"] == "us-east-1"
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_with_model_env_vars(self):
"""Regression: a DB row whose ``env_vars`` is a list of ``MCPEnvVar``
models (as Prisma returns) must build into an ``MCPServer`` instead of
raising a Pydantic ``dict_type`` validation error that silently drops
the server from the registry."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="env-var-server-1",
server_name="github_peruser",
url="https://api.githubcopilot.com/mcp/",
transport=MCPTransport.http,
static_headers={"Authorization": "Bearer ${GITHUB_TOKEN}"},
env_vars=[
MCPEnvVar(
name="GITHUB_TOKEN",
scope=MCPEnvVarScope.user,
description="Your personal GitHub PAT",
)
],
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
assert mcp_server.env_vars == [
{
"name": "GITHUB_TOKEN",
"value": "",
"scope": "user",
"description": "Your personal GitHub PAT",
}
]
@pytest.mark.asyncio
async def test_round_trip_source_url_preserved(self):
"""source_url survives the full round-trip: LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable.