See the question and my original answer on StackOverflow

Remember that IDispatch / late-binding was created for special clients (eg: VB/VBA/VBScript/JScript), and it's always a pain to use from pure C/C++ clients.

With the original definition, here is how the IMyRootClass is defined (you can read that using the OleView tool from the Windows SDK on the .tlb file generated by RegAsm):

interface IMyRootClass : IDispatch {
    [id(0x60020000)]
    HRESULT GetEntities([out, retval] SAFEARRAY(IEmailEntity*)* pRetVal);
};

Which will end up after #import as this, at C/C++ header level:

IMyRootClass : IDispatch
{
    //
    // Wrapper methods for error-handling
    //

    SAFEARRAY * GetEntities ( );

    //
    // Raw methods provided by interface
    //

      virtual HRESULT __stdcall raw_GetEntities (
        /*[out,retval]*/ SAFEARRAY * * pRetVal ) = 0;
};

where GetEntities is in fact just a small wrapper code around the IUnknown / early-binding interface:

inline SAFEARRAY * IMyRootClass::GetEntities ( ) {
    SAFEARRAY * _result = 0;
    HRESULT _hr = raw_GetEntities(&_result);
    if (FAILED(_hr)) _com_issue_errorex(_hr, this, __uuidof(this));
    return _result;
}

This is why "dual" interface are nice (not sure why you only want IDispatch), because they allow both worlds an easy access.

Now, if you change the definition as in your question, there's no more an IMyRootClass COM interface defined. Instead you'll only get a dispinterface at IDL level:

dispinterface IMyRootClass {
    properties:
    methods:
        [id(0x60020000)]
        SAFEARRAY(IEmailEntity*) GetEntities();
};

Which will end up after #import as this, at C/C++ header level:

IMyRootClass : IDispatch
{
    //
    // Wrapper methods for error-handling
    //

    // Methods:
    SAFEARRAY * GetEntities ( );
};

where GetEntities is in fact a quite different wrapper code:

inline SAFEARRAY * IMyRootClass::GetEntities ( ) {
    SAFEARRAY * _result = 0;
    _com_dispatch_method(this, 0x60020000, DISPATCH_METHOD, VT_ARRAY|VT_DISPATCH, (void*)&_result, NULL);
    return _result;
}

As you see here, since we're using IDispatch, everything is more or less close to the VARIANT type (which was invented at the same time, again for these clients), used as is or with wrappers.

This is why you see the expected return type is VT_ARRAY|VT_DISPATCH. One can also use VT_ARRAY|VT_UNKNOWN, or VT_ARRAY|VT_VARIANT, or simpy VT_VARIANT (the ultimate wrapper type), but there's no way to say VT_ARRAY of IEmailEntity*.

So, there are at least two solutions to this problem:

1 - you can do define your interface as this:

[ComVisible(true), InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
public interface IMyRootClass
{
    object[] GetEntities();
}

[ComVisible(true), ClassInterface(ClassInterfaceType.None)]
public class MyRootClass : IMyRootClass
{
    public object[] GetEntities()
    {
        ...
        // the Cast forces the creation of an array of object => VT_ARRAY | VT_DISPATCH
        return list.Cast<object>().ToArray();
    }
}   

2 - or you can use the IDispatch interface "manually" (don't use the wrappers) like this:

IMyRootClassPtr ptr(__uuidof(MyRootClass));
CComVariant result;
DISPPARAMS p = {};
ptr->Invoke(0x60020000, IID_NULL, 0, DISPATCH_METHOD, &p, &result, nullptr, nullptr);

Where in this case, result will be of VT_ARRAY | VT_UNKNOWN (as in the first case), which is why the wrapper throws an exception. comdef.h's wrappers are more limited, in their automation types support, than clients such as VB.