This is a ncat limitation.
Let's use strace to look into what ncat is up to. Conveniently, strace has -e option to filter system calls, e.g. -e %net for logging network-related syscalls. Let's start the server first:
$ strace -e %net ncat -4 --exec /bin/cat -u --listen 2000
socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(2000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
Note the bind call. The socket is bound to 0.0.0.0, meaning that it will receive packets sent to 127.0.0.1:2000 as well as 10.0.2.15:2000 (my eth0 address), as well as any other address belonging to the machine.
Now let's start a client:
$ strace -e %net ncat -s 10.0.2.15 -u 127.0.0.1 2000
socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("10.0.2.15")}, 16) = 0
setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
connect(3, {sa_family=AF_INET, sin_port=htons(2000), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
getsockopt(3, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
At this point, the client is waiting for input.
Notice the connect call. UDP is connectionless, so the call simply remembers the remote's address. It is convenient as send will use the recorded address. Otherwise, it would be necessary to specify the remote address in every send call. There's another implication which we'll see shortly.
Now let's type some input. Client:
sendto(3, "Hello, world!\n", 14, 0, NULL, 0) = 14
Server:
recvfrom(3, "Hello, world!\n", 131072, MSG_PEEK, {sa_family=AF_INET, sin_port=htons(35197), sin_addr=inet_addr("10.0.2.15")}, [128->16]) = 14
connect(3, {sa_family=AF_INET, sin_port=htons(35197), sin_addr=inet_addr("10.0.2.15")}, 16) = 0
recvfrom(3, "Hello, world!\n", 8192, 0, NULL, NULL) = 14
sendto(3, "Hello, world!\n", 14, 0, NULL, 0) = 14
recvfrom(3, 0x7fff8fcc9d70, 8192, 0, NULL, NULL) = -1 ECONNREFUSED (Connection refused)
The server sees a message from 10.0.2.15. The first recvfrom call is done with MSG_PEEK flag, which allows to peek at the message without actually consuming it. Then connect (for convenience), another recvfrom to consume the message, and sendto to send a reply.
Now remember that the server socket is bound to 0.0.0.0. The client sent a message to 127.0.0.1:2000. The reply must include the server socket's address as the source. But the system can't use 0.0.0.0 as it doesn't generally make sense on the wire. Both 127.0.0.1 and 10.0.2.15 are possible, but ncat doesn't tell explicitly which one to use. So the system ends up using 10.0.2.15 as the source.
Now back to the client. It sent a message to 127.0.0.1:2000, but the response came from 10.0.2.15:2000. Another effect of connect on a UDP socket is that only datagrams originating from the specified address:port will be accepted. If the source doesn't match, the system responds with ICMP port unreachable. This is reported as ECONNREFUSED error in the server.
Possible fix
Specify the intended source address in the server using IP_PKTINFO/IPV6_PKTINFO ancillary message.