diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 5aa910deab7..e27f0c44c20 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -469,6 +469,9 @@ async def delete_mcp_server( """ Delete the mcp server from the db by server_id + Also removes any per-user env var rows for the server, which have no FK + cascade, so deleting a server never leaves orphaned credential rows behind. + Returns the deleted mcp server record if it exists, otherwise None """ deleted_server = await prisma_client.db.litellm_mcpservertable.delete( @@ -476,6 +479,10 @@ async def delete_mcp_server( "server_id": server_id, }, ) + if deleted_server is not None: + await prisma_client.db.litellm_mcpuserenvvars.delete_many( + where={"server_id": server_id} + ) return deleted_server diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index dc9046f7c6e..66587c4bdbc 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -2273,7 +2273,12 @@ if MCP_AVAILABLE: @router.post( "/server/{server_id}/user-env-vars", - description="Store the calling user's per-user MCP env var values for this server.", + description=( + "Store the calling user's per-user MCP env var values for this " + "server. This fully replaces any previously stored values: a " + "variable omitted from the request (or sent empty) is cleared, " + "not preserved. Send the complete set you want retained." + ), dependencies=[Depends(user_api_key_auth)], response_model=MCPUserEnvVarsStatus, ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py index c7865841a08..92a6c3a683e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py @@ -673,6 +673,40 @@ async def test_delete_user_env_vars_is_idempotent_delete_many(): assert call.kwargs["where"] == {"user_id": "alice", "server_id": "srv-1"} +@pytest.mark.asyncio +async def test_delete_mcp_server_removes_orphaned_user_env_vars(): + """Deleting a server must also drop every user's per-user env var rows for + it; there is no FK cascade, so skipping this leaves orphaned credentials.""" + from unittest.mock import AsyncMock + + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = _mock_env_vars_prisma() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=object()) + + await delete_mcp_server(prisma, "srv-1") + + prisma.db.litellm_mcpuserenvvars.delete_many.assert_awaited_once() + call = prisma.db.litellm_mcpuserenvvars.delete_many.call_args + assert call.kwargs["where"] == {"server_id": "srv-1"} + + +@pytest.mark.asyncio +async def test_delete_mcp_server_skips_env_var_cleanup_when_server_missing(): + """A no-op delete (server not found) must not touch the env var table.""" + from unittest.mock import AsyncMock + + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = _mock_env_vars_prisma() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=None) + + result = await delete_mcp_server(prisma, "srv-1") + + assert result is None + prisma.db.litellm_mcpuserenvvars.delete_many.assert_not_awaited() + + # ── DB helpers: global env vars encrypted at rest ─────────────────────────