Skip to content

Commit 0766b69

Browse files
authored
Merge branch 'main' into telemetry-update
2 parents 93402e3 + 9e76f95 commit 0766b69

File tree

6 files changed

+197
-4
lines changed

6 files changed

+197
-4
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) {

src/tools/atlas/atlasTool.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { ToolBase, ToolCategory, TelemetryToolMetadata } from "../tool.js";
1+
import { ToolBase, ToolCategory, TelemetryToolMetadata, ToolArgs } from "../tool.js";
22
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
34
import logger, { LogId } from "../../logger.js";
45
import { z } from "zod";
6+
import { ApiClientError } from "../../common/atlas/apiClientError.js";
57

68
export abstract class AtlasToolBase extends ToolBase {
79
protected category: ToolCategory = "atlas";
@@ -13,6 +15,50 @@ export abstract class AtlasToolBase extends ToolBase {
1315
return super.verifyAllowed();
1416
}
1517

18+
protected handleError(
19+
error: unknown,
20+
args: ToolArgs<typeof this.argsShape>
21+
): Promise<CallToolResult> | CallToolResult {
22+
if (error instanceof ApiClientError) {
23+
const statusCode = error.response.status;
24+
25+
if (statusCode === 401) {
26+
return {
27+
content: [
28+
{
29+
type: "text",
30+
text: `Unable to authenticate with MongoDB Atlas, API error: ${error.message}
31+
32+
Hint: Your API credentials may be invalid, expired or lack permissions.
33+
Please check your Atlas API credentials and ensure they have the appropriate permissions.
34+
For more information on setting up API keys, visit: https://www.mongodb.com/docs/atlas/configure-api-access/`,
35+
},
36+
],
37+
isError: true,
38+
};
39+
}
40+
41+
if (statusCode === 403) {
42+
return {
43+
content: [
44+
{
45+
type: "text",
46+
text: `Received a Forbidden API Error: ${error.message}
47+
48+
You don't have sufficient permissions to perform this action in MongoDB Atlas
49+
Please ensure your API key has the necessary roles assigned.
50+
For more information on Atlas API access roles, visit: https://www.mongodb.com/docs/atlas/api/service-accounts-overview/`,
51+
},
52+
],
53+
isError: true,
54+
};
55+
}
56+
}
57+
58+
// For other types of errors, use the default error handling from the base class
59+
return super.handleError(error, args);
60+
}
61+
1662
/**
1763
*
1864
* Resolves the tool metadata from the arguments passed to the tool

tests/integration/helpers.ts

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

237237
/** Expects the argument being defined and asserts it */
238-
export function expectDefined<T>(arg: T): asserts arg is Exclude<T, undefined> {
238+
export function expectDefined<T>(arg: T): asserts arg is Exclude<T, undefined | null> {
239239
expect(arg).toBeDefined();
240+
expect(arg).not.toBeNull();
240241
}

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)