Written by Kishore Athrasseri
Published on Fri Nov 07 2025
This post is Part 2 of the journey in building ChikkaDB, a mongod-compatible server with an SQLite backend. The full code for this exploration can be found here. Read Part 1
What messages are sent between the MongoDB server and client when we perform database operations? In the previous exploration, we’ve understood the language they speak - the MongoDB Wire Protocol. It’s now time to find out what they say. Let’s do some eavesdropping on the wire!
This time we will program our own TCP connections in Typescript, since we need to intercept the messages sent between the server and the client. Then we can decode and understand them.
Basically we need to build a TCP proxy server that will listen on the port that the client expects to find the server, capture the messages and faithfully pass them on to the server. The same with the replies sent from the server, which need to be forwarded back to the client after studying them.
import { createServer, Socket } from 'node:net';
// We will start the mongod server on port 9000,
// not the traditional port 27017.
// Instead we will bind our eavesdropping proxy
// to 27017 and forward messages to 9000.
const MONGOD_HOST = '127.0.0.1';
const MONGOD_PORT = 9000;
const LISTEN_HOST = '127.0.0.1';
const LISTEN_PORT = 27017;
async function handleNewConnection(clientSock: Socket) {
// logic to accept and forward messages
}
const server = createServer(handleNewConnection);
server.listen(LISTEN_PORT, LISTEN_PORT, () => {
console.log(
'Mongo TCP proxy listening on',
`${LISTEN_HOST}:${LISTEN_PORT}`,
'->',
`${MONGOD_HOST}:${MONGOD_PORT}`,
);
});
Even at this preliminary stage without any message-forwarding logic, if we run this server, we can see mongosh trying to connect to it and get timed out after two seconds when it doesn’t get any reply.
user@host$ mongod --dbpath /tmp/mongo-data --port 9000
user@host$ node dist/index.js
Mongo TCP proxy listening on 127.0.0.1:27017 -> 127.0.0.1:9000
user@host$ mongosh
Current Mongosh Log ID: 6908c6be279485abe1ce5f46
Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.5.8
MongoServerSelectionError: Server selection timed out after 2000 ms
This is different from what happens when mongosh tries connecting to a port that doesn’t have an application listening.
user@host$ mongosh
Current Mongosh Log ID: 6908c688bbecfcb7cbce5f46
Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.5.8
MongoNetworkError: connect ECONNREFUSED 127.0.0.1:27017
Let’s see what happens when we write the logic to pass on the messages to port 9000 and back.
async function handleNewConnection(clientSock: Socket) {
console.log('client connected from port:', clientSock.remotePort);
// open a corresponding connection
// to mongod server on port 9000
const serverSock = createConnection(
{ host: MONGOD_HOST, port: MONGOD_PORT },
() => console.log('created proxy connection to mongod server')
);
// register a callback to forward to server
// any data sent from client
clientSock.on('data', (data) => {
serverSock.write(data);
});
// register a callback to forward to client
// any data sent from server
serverSock.on('data', (data) => {
clientSock.write(data);
});
}
user@host$ mongosh
Current Mongosh Log ID: 6908c9c0160974e641ce5f46
Connecting to: mongodb://127.0.0.1:27017/?directConnection=true&serverSelectionTimeoutMS=2000&appName=mongosh+2.5.8
Using MongoDB: 7.0.25
Using Mongosh: 2.5.8
mongosh 2.5.9 is available for download: https://www.mongodb.com/try/download/shell
For mongosh info see: https://www.mongodb.com/docs/mongodb-shell/
Hello, Kishore!
test>
Voila! The mongosh client is talking to mongod through our proxy server. The connections can be seen in the logs written by the proxy to the console.
Mongo TCP proxy listening on 127.0.0.1:27017 -> 127.0.0.1:9000
client connected from port: 47172
created proxy connection to mongod server
client connected from port: 47186
client connected from port: 47198
created proxy connection to mongod server
created proxy connection to mongod server
client connected from port: 47202
created proxy connection to mongod server
...
We have full access to the raw data being sent between server and client over the wire. We can now decode and analyze them to understand the conversation that’s going on.
The data event handler is the function which receives the data on the sockets. Currently all that it does it to immediately forward whatever data it receives. We need to add logic to also store and decode this data.
The data chunks need not entirely coincide with the wire messages. Remember this is a TCP connection, and the message could have been broken into packets and transmitted in a way we cannot control. So, we need to collect the data in a buffer as it comes in, and check every time new data arrives, whether a message has been completed.
When a client creates a connection to the proxy server, the proxy server in turn creates a connection to the mongod server. So we need to maintain two buffers - one for each connection.
// in function handleNewConnection
...
const c2sBufHolder = { buf: Buffer.alloc(0) };
const s2cBufHolder = { buf: Buffer.alloc(0) };
clientSock.on('data', (data) => {
serverSock.write(data);
c2sBufHolder.buf = Buffer.concat([c2sBufHolder.buf, data]);
const messages = processBuffer(c2sBufHolder); // to be defined
});
serverSock.on('data', (data) => {
clientSock.write(data);
s2cBufHolder.buf = Buffer.concat([s2cBufHolder.buf, data]);
const messages = processBuffer(s2cBufHolder);
});
...
You might wonder why we need to create a ‘buffer holder’ object instead of just creating a buffer variable. Observe that the ‘data’ event handler closes over the variable declared outside, so it needs to be a stable reference that does not change. Some interesting bugs arise if you try using a buffer without a static holder.
It’s time to recall the structure of the wire messages. They start with a header comprising four 32-bit integer fields. The first of those is messageLength - the number of bytes in the entire message including the header.
struct MsgHeader {
int32 messageLength;
int32 requestID;
int32 responseTo;
int32 opCode;
}
When the first chunk of data is received, the first four bytes will tell us how long the first message is. If we have already received so many bytes, we can read and decode them. If we haven’t, we can wait until the remaining bytes arrive.
function processBuffer(buf: Buffer) {
const buf = bufHolder.buf;
let offset = 0;
let messages: WireMessage[] = [];
// If the buffer has less than 4 bytes to read, we
// haven't received the messageLength field yet
while(buf.length - offset >= 4) {
const messageLength = buf.readInt32LE(offset);
// If the buffer has less than messageLenth bytes to read,
// wait till more bytes arrive
if (buf.length - offset < messageLength) break;
const messageBuf = buf.subarray(offset, offset + messageLength);
const message = decodeMessage(messageBuf);
messages.push(message);
offset += messageLength;
// Remove the processed bytes from the buffer
bufHolder.buf = buf.subarray(offset);
}
return messages;
}
To begin with, let us decode only the header of each message and print it to the console. We can find out what opcodes are used in the messages.
type WireMessage = {
header: {
messageLength: number;
requestID: number;
responseTo: number;
opCode: number;
};
// todo: payload types
};
function decodeMessage(buf: Buffer): WireMessage {
// decode header
const messageLength = buf.readInt32LE(0);
const requestID = buf.readInt32LE(4);
const responseTo = buf.readInt32LE(8);
const opCode = buf.readInt32LE(12);
// todo: decode payload
return {
header: { messageLength, requestID, responseTo, opCode },
};
}
// in function handleNewConnection
...
clientSock.on('data', (data) => {
serverSock.write(data);
c2sBufHolder.buf = Buffer.concat([c2sBufHolder.buf, data]);
const messages = processBuffer(c2sBufHolder); // to be defined
messages.forEach(message => {
console.log(`C -> S message`, message);
});
});
// same in serverSock data event handler
...
mongosh creates a pool of connections by default, as can be seen in the console logs the first time we ran the proxy. It results in a lot of chatter on the wire. If the intent is to understand the conversation between one client connection and the server, it’s best if we minimize the number of connections created by mongosh. We can do this using the query param maxPoolSize=1 in the database URI.
user@host$ node dist/index.js
Mongo TCP proxy listening on 127.0.0.1:27017 -> 127.0.0.1:9000
client connected from port: 57486
created proxy connection to mongod server
C -> S message {
header: { messageLength: 355, requestID: 1, responseTo: 0, opCode: 2004 }
}
S -> C message {
header: { messageLength: 329, requestID: 204, responseTo: 1, opCode: 1 }
}
client connected from port: 57498
created proxy connection to mongod server
C -> S message {
header: { messageLength: 355, requestID: 2, responseTo: 0, opCode: 2004 }
}
S -> C message {
header: { messageLength: 329, requestID: 205, responseTo: 2, opCode: 1 }
}
C -> S message {
header: { messageLength: 92, requestID: 3, responseTo: 0, opCode: 2013 }
}
S -> C message {
header: { messageLength: 2060, requestID: 206, responseTo: 3, opCode: 2013 }
}
There are still two connections - not just one - but not more than that. Probably mongosh maintains a separate background connection to check connectivity.
The outline of the conversation between the client and the server is starting to become visible.
The first message is a surprise. It has the opcode 2004, which is the legacy OP_QUERY message. The reply to it from the server too is a legacy message, this time an OP_REPLY (opcode 1). On checking the documentation again, I learnt that OP_QUERY and OP_REPLY are still supported, while the other legacy opcodes have been completely removed. Further down, we can see the opcode 2013, which is the now standard OP_MSG.
At the end of this exploration, we have a functioning proxy server that can become a man-in-the-middle between mongosh and mongod, and transparently intercept the messages they send each other.
Now that we have access to the wire messages, the next step is to decode them. We will do that in the next exploration.
This exploration is Part 2 of the journey in building ChikkaDB, a mongod-compatible server with an SQLite backend. The full code for this exploration can be found here. Read Part 3