Skip to content

Commit cc73ebf

Browse files
authored
fix: use ejson parsing for stdio messages (#218)
1 parent d061543 commit cc73ebf

File tree

5 files changed

+150
-3
lines changed

5 files changed

+150
-3
lines changed

src/helpers/EJsonTransport.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
2+
import { EJSON } from "bson";
3+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4+
5+
// This is almost a copy of ReadBuffer from @modelcontextprotocol/sdk
6+
// but it uses EJSON.parse instead of JSON.parse to handle BSON types
7+
export class EJsonReadBuffer {
8+
private _buffer?: Buffer;
9+
10+
append(chunk: Buffer): void {
11+
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
12+
}
13+
14+
readMessage(): JSONRPCMessage | null {
15+
if (!this._buffer) {
16+
return null;
17+
}
18+
19+
const index = this._buffer.indexOf("\n");
20+
if (index === -1) {
21+
return null;
22+
}
23+
24+
const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, "");
25+
this._buffer = this._buffer.subarray(index + 1);
26+
27+
// This is using EJSON.parse instead of JSON.parse to handle BSON types
28+
return JSONRPCMessageSchema.parse(EJSON.parse(line));
29+
}
30+
31+
clear(): void {
32+
this._buffer = undefined;
33+
}
34+
}
35+
36+
// This is a hacky workaround for https://github.com/mongodb-js/mongodb-mcp-server/issues/211
37+
// The underlying issue is that StdioServerTransport uses JSON.parse to deserialize
38+
// messages, but that doesn't handle bson types, such as ObjectId when serialized as EJSON.
39+
//
40+
// This function creates a StdioServerTransport and replaces the internal readBuffer with EJsonReadBuffer
41+
// that uses EJson.parse instead.
42+
export function createEJsonTransport(): StdioServerTransport {
43+
const server = new StdioServerTransport();
44+
server["_readBuffer"] = new EJsonReadBuffer();
45+
46+
return server;
47+
}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
#!/usr/bin/env node
22

3-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
43
import logger, { LogId } from "./logger.js";
54
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
65
import { config } from "./config.js";
76
import { Session } from "./session.js";
87
import { Server } from "./server.js";
98
import { packageInfo } from "./helpers/packageInfo.js";
109
import { Telemetry } from "./telemetry/telemetry.js";
10+
import { createEJsonTransport } from "./helpers/EJsonTransport.js";
1111

1212
try {
1313
const session = new Session({
@@ -29,7 +29,7 @@ try {
2929
userConfig: config,
3030
});
3131

32-
const transport = new StdioServerTransport();
32+
const transport = createEJsonTransport();
3333

3434
await server.connect(transport);
3535
} catch (error: unknown) {

tests/integration/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export function validateThrowsForInvalidArguments(
227227
}
228228

229229
/** Expects the argument being defined and asserts it */
230-
export function expectDefined<T>(arg: T): asserts arg is Exclude<T, undefined> {
230+
export function expectDefined<T>(arg: T): asserts arg is Exclude<T, undefined | null> {
231231
expect(arg).toBeDefined();
232+
expect(arg).not.toBeNull();
232233
}

tests/integration/tools/mongodb/read/find.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
validateToolMetadata,
55
validateThrowsForInvalidArguments,
66
getResponseElements,
7+
expectDefined,
78
} from "../../../helpers.js";
89
import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js";
910

@@ -171,6 +172,33 @@ describeWithMongoDB("find tool", (integration) => {
171172
expect(JSON.parse(elements[i + 1].text).value).toEqual(i);
172173
}
173174
});
175+
176+
it("can find objects by $oid", async () => {
177+
await integration.connectMcpClient();
178+
179+
const fooObject = await integration
180+
.mongoClient()
181+
.db(integration.randomDbName())
182+
.collection("foo")
183+
.findOne();
184+
expectDefined(fooObject);
185+
186+
const response = await integration.mcpClient().callTool({
187+
name: "find",
188+
arguments: {
189+
database: integration.randomDbName(),
190+
collection: "foo",
191+
filter: { _id: fooObject._id },
192+
},
193+
});
194+
195+
const elements = getResponseElements(response.content);
196+
expect(elements).toHaveLength(2);
197+
expect(elements[0].text).toEqual('Found 1 documents in the collection "foo":');
198+
199+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
200+
expect(JSON.parse(elements[1].text).value).toEqual(fooObject.value);
201+
});
174202
});
175203

176204
validateAutoConnectBehavior(integration, "find", () => {

tests/unit/EJsonTransport.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Decimal128, MaxKey, MinKey, ObjectId, Timestamp, UUID } from "bson";
2+
import { createEJsonTransport, EJsonReadBuffer } from "../../src/helpers/EJsonTransport.js";
3+
import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
4+
import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
5+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6+
import { Readable } from "stream";
7+
import { ReadBuffer } from "@modelcontextprotocol/sdk/shared/stdio.js";
8+
9+
describe("EJsonTransport", () => {
10+
let transport: StdioServerTransport;
11+
beforeEach(async () => {
12+
transport = createEJsonTransport();
13+
await transport.start();
14+
});
15+
16+
afterEach(async () => {
17+
await transport.close();
18+
});
19+
20+
it("ejson deserializes messages", () => {
21+
const messages: { message: JSONRPCMessage; extra?: { authInfo?: AuthInfo } }[] = [];
22+
transport.onmessage = (
23+
message,
24+
extra?: {
25+
authInfo?: AuthInfo;
26+
}
27+
) => {
28+
messages.push({ message, extra });
29+
};
30+
31+
(transport["_stdin"] as Readable).emit(
32+
"data",
33+
Buffer.from(
34+
'{"jsonrpc":"2.0","id":1,"method":"testMethod","params":{"oid":{"$oid":"681b741f13aa74a0687b5110"},"uuid":{"$uuid":"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"},"date":{"$date":"2025-05-07T14:54:23.973Z"},"decimal":{"$numberDecimal":"1234567890987654321"},"int32":123,"maxKey":{"$maxKey":1},"minKey":{"$minKey":1},"timestamp":{"$timestamp":{"t":123,"i":456}}}}\n',
35+
"utf-8"
36+
)
37+
);
38+
39+
expect(messages.length).toBe(1);
40+
const message = messages[0].message;
41+
42+
expect(message).toEqual({
43+
jsonrpc: "2.0",
44+
id: 1,
45+
method: "testMethod",
46+
params: {
47+
oid: new ObjectId("681b741f13aa74a0687b5110"),
48+
uuid: new UUID("f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
49+
date: new Date(Date.parse("2025-05-07T14:54:23.973Z")),
50+
decimal: new Decimal128("1234567890987654321"),
51+
int32: 123,
52+
maxKey: new MaxKey(),
53+
minKey: new MinKey(),
54+
timestamp: new Timestamp({ t: 123, i: 456 }),
55+
},
56+
});
57+
});
58+
59+
it("has _readBuffer field of type EJsonReadBuffer", () => {
60+
expect(transport["_readBuffer"]).toBeDefined();
61+
expect(transport["_readBuffer"]).toBeInstanceOf(EJsonReadBuffer);
62+
});
63+
64+
describe("standard StdioServerTransport", () => {
65+
it("has a _readBuffer field", () => {
66+
const standardTransport = new StdioServerTransport();
67+
expect(standardTransport["_readBuffer"]).toBeDefined();
68+
expect(standardTransport["_readBuffer"]).toBeInstanceOf(ReadBuffer);
69+
});
70+
});
71+
});

0 commit comments

Comments
 (0)