Skip to content

Commit 8d4523f

Browse files
chore: tests for export tool
1 parent e359184 commit 8d4523f

File tree

3 files changed

+307
-1
lines changed

3 files changed

+307
-1
lines changed

src/tools/mongodb/read/export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class ExportTool extends MongoDBToolBase {
4141
promoteValues: false,
4242
bsonRegExp: true,
4343
});
44-
const exportName = `${database}.${collection}.json`;
44+
const exportName = `${database}.${collection}.${Date.now()}.json`;
4545
if (!this.exportsManager) {
4646
throw new Error("Incorrect server configuration, export not possible!");
4747
}

tests/integration/helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Session } from "../../src/common/session.js";
88
import { Telemetry } from "../../src/telemetry/telemetry.js";
99
import { config } from "../../src/common/config.js";
1010
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
11+
import { SessionExportsManager } from "../../src/common/sessionExportsManager.js";
1112

1213
interface ParameterInfo {
1314
name: string;
@@ -69,8 +70,11 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati
6970

7071
const telemetry = Telemetry.create(session, userConfig);
7172

73+
const exportsManager = new SessionExportsManager(session, userConfig);
74+
7275
mcpServer = new Server({
7376
session,
77+
exportsManager,
7478
userConfig,
7579
telemetry,
7680
mcpServer: new McpServer({
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import fs from "fs/promises";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import {
4+
databaseCollectionParameters,
5+
validateThrowsForInvalidArguments,
6+
validateToolMetadata,
7+
} from "../../../helpers.js";
8+
import { describeWithMongoDB } from "../mongodbHelpers.js";
9+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
10+
import { Long } from "bson";
11+
12+
function contentWithTextResourceURI(content: CallToolResult["content"], namespace: string) {
13+
return content.find((part) => {
14+
return (
15+
part.type === "text" &&
16+
part.text.startsWith(`Exported data for namespace ${namespace} is available under resource URI -`)
17+
);
18+
});
19+
}
20+
21+
function contentWithResourceURILink(content: CallToolResult["content"], namespace: string) {
22+
return content.find((part) => {
23+
return part.type === "resource_link" && part.uri.startsWith(`exported-data://${namespace}`);
24+
});
25+
}
26+
27+
function contentWithExportPath(content: CallToolResult["content"]) {
28+
return content.find((part) => {
29+
return (
30+
part.type === "text" &&
31+
part.text.startsWith(`Optionally, the exported data can also be accessed under path -`)
32+
);
33+
});
34+
}
35+
36+
describeWithMongoDB("export tool", (integration) => {
37+
validateToolMetadata(
38+
integration,
39+
"export",
40+
"Export a collection data or query results in the specified json format.",
41+
[
42+
...databaseCollectionParameters,
43+
44+
{
45+
name: "filter",
46+
description: "The query filter, matching the syntax of the query argument of db.collection.find()",
47+
type: "object",
48+
required: false,
49+
},
50+
{
51+
name: "jsonExportFormat",
52+
description: [
53+
"The format to be used when exporting collection data as JSON with default being relaxed.",
54+
"relaxed: A string format that emphasizes readability and interoperability at the expense of type preservation. That is, conversion from relaxed format to BSON can lose type information.",
55+
"canonical: A string format that emphasizes type preservation at the expense of readability and interoperability. That is, conversion from canonical to BSON will generally preserve type information except in certain specific cases.",
56+
].join("\n"),
57+
type: "string",
58+
required: false,
59+
},
60+
{
61+
name: "limit",
62+
description: "The maximum number of documents to return",
63+
type: "number",
64+
required: false,
65+
},
66+
{
67+
name: "projection",
68+
description: "The projection, matching the syntax of the projection argument of db.collection.find()",
69+
type: "object",
70+
required: false,
71+
},
72+
{
73+
name: "sort",
74+
description:
75+
"A document, describing the sort order, matching the syntax of the sort argument of cursor.sort(). The keys of the object are the fields to sort on, while the values are the sort directions (1 for ascending, -1 for descending).",
76+
type: "object",
77+
required: false,
78+
},
79+
]
80+
);
81+
82+
validateThrowsForInvalidArguments(integration, "export", [
83+
{},
84+
{ database: 123, collection: "bar" },
85+
{ database: "test", collection: [] },
86+
{ database: "test", collection: "bar", filter: "{ $gt: { foo: 5 } }" },
87+
{ database: "test", collection: "bar", projection: "name" },
88+
{ database: "test", collection: "bar", limit: "10" },
89+
{ database: "test", collection: "bar", sort: [], limit: 10 },
90+
]);
91+
92+
it("when provided with incorrect namespace, export should have empty data", async function () {
93+
await integration.connectMcpClient();
94+
const response = await integration.mcpClient().callTool({
95+
name: "export",
96+
arguments: { database: "non-existent", collection: "foos" },
97+
});
98+
99+
const content = response.content as CallToolResult["content"];
100+
const namespace = "non-existent.foos";
101+
expect(content).toHaveLength(3);
102+
expect(contentWithTextResourceURI(content, namespace)).toBeDefined();
103+
expect(contentWithResourceURILink(content, namespace)).toBeDefined();
104+
105+
const localPathPart = contentWithExportPath(content);
106+
expect(localPathPart).toBeDefined();
107+
108+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
109+
expect(localPath).toBeDefined();
110+
111+
expect(await fs.readFile(localPath as string, "utf8")).toEqual("[]");
112+
});
113+
114+
describe("with correct namespace", function () {
115+
beforeEach(async () => {
116+
const mongoClient = integration.mongoClient();
117+
await mongoClient
118+
.db(integration.randomDbName())
119+
.collection("foo")
120+
.insertMany([
121+
{ name: "foo", longNumber: new Long(1234) },
122+
{ name: "bar", bigInt: new Long(123412341234) },
123+
]);
124+
});
125+
126+
it("should export entire namespace when filter are empty", async function () {
127+
await integration.connectMcpClient();
128+
const response = await integration.mcpClient().callTool({
129+
name: "export",
130+
arguments: { database: integration.randomDbName(), collection: "foo" },
131+
});
132+
133+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
134+
expect(localPathPart).toBeDefined();
135+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
136+
expect(localPath).toBeDefined();
137+
138+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
139+
string,
140+
unknown
141+
>[];
142+
expect(exportedContent).toHaveLength(2);
143+
expect(exportedContent[0]?.name).toEqual("foo");
144+
expect(exportedContent[1]?.name).toEqual("bar");
145+
});
146+
147+
it("should export filter results namespace when there are filters", async function () {
148+
await integration.connectMcpClient();
149+
const response = await integration.mcpClient().callTool({
150+
name: "export",
151+
arguments: { database: integration.randomDbName(), collection: "foo", filter: { name: "foo" } },
152+
});
153+
154+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
155+
expect(localPathPart).toBeDefined();
156+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
157+
expect(localPath).toBeDefined();
158+
159+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
160+
string,
161+
unknown
162+
>[];
163+
expect(exportedContent).toHaveLength(1);
164+
expect(exportedContent[0]?.name).toEqual("foo");
165+
});
166+
167+
it("should export results limited to the provided limit", async function () {
168+
await integration.connectMcpClient();
169+
const response = await integration.mcpClient().callTool({
170+
name: "export",
171+
arguments: { database: integration.randomDbName(), collection: "foo", limit: 1 },
172+
});
173+
174+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
175+
expect(localPathPart).toBeDefined();
176+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
177+
expect(localPath).toBeDefined();
178+
179+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
180+
string,
181+
unknown
182+
>[];
183+
expect(exportedContent).toHaveLength(1);
184+
expect(exportedContent[0]?.name).toEqual("foo");
185+
});
186+
187+
it("should export results with sorted by the provided sort", async function () {
188+
await integration.connectMcpClient();
189+
const response = await integration.mcpClient().callTool({
190+
name: "export",
191+
arguments: {
192+
database: integration.randomDbName(),
193+
collection: "foo",
194+
limit: 1,
195+
sort: { longNumber: 1 },
196+
},
197+
});
198+
199+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
200+
expect(localPathPart).toBeDefined();
201+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
202+
expect(localPath).toBeDefined();
203+
204+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
205+
string,
206+
unknown
207+
>[];
208+
expect(exportedContent).toHaveLength(1);
209+
expect(exportedContent[0]?.name).toEqual("bar");
210+
});
211+
212+
it("should export results containing only projected fields", async function () {
213+
await integration.connectMcpClient();
214+
const response = await integration.mcpClient().callTool({
215+
name: "export",
216+
arguments: {
217+
database: integration.randomDbName(),
218+
collection: "foo",
219+
limit: 1,
220+
projection: { _id: 0, name: 1 },
221+
},
222+
});
223+
224+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
225+
expect(localPathPart).toBeDefined();
226+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
227+
expect(localPath).toBeDefined();
228+
229+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
230+
string,
231+
unknown
232+
>[];
233+
expect(exportedContent).toEqual([
234+
{
235+
name: "foo",
236+
},
237+
]);
238+
});
239+
240+
it("should export relaxed json when provided jsonExportFormat is relaxed", async function () {
241+
await integration.connectMcpClient();
242+
const response = await integration.mcpClient().callTool({
243+
name: "export",
244+
arguments: {
245+
database: integration.randomDbName(),
246+
collection: "foo",
247+
limit: 1,
248+
projection: { _id: 0 },
249+
jsonExportFormat: "relaxed",
250+
},
251+
});
252+
253+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
254+
expect(localPathPart).toBeDefined();
255+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
256+
expect(localPath).toBeDefined();
257+
258+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
259+
string,
260+
unknown
261+
>[];
262+
expect(exportedContent).toEqual([
263+
{
264+
name: "foo",
265+
longNumber: 1234,
266+
},
267+
]);
268+
});
269+
270+
it("should export canonical json when provided jsonExportFormat is canonical", async function () {
271+
await integration.connectMcpClient();
272+
const response = await integration.mcpClient().callTool({
273+
name: "export",
274+
arguments: {
275+
database: integration.randomDbName(),
276+
collection: "foo",
277+
limit: 1,
278+
projection: { _id: 0 },
279+
jsonExportFormat: "canonical",
280+
},
281+
});
282+
283+
const localPathPart = contentWithExportPath(response.content as CallToolResult["content"]);
284+
expect(localPathPart).toBeDefined();
285+
const [, localPath] = /"(.*)"/.exec(String(localPathPart?.text)) ?? [];
286+
expect(localPath).toBeDefined();
287+
288+
const exportedContent = JSON.parse(await fs.readFile(localPath as string, "utf8")) as Record<
289+
string,
290+
unknown
291+
>[];
292+
expect(exportedContent).toEqual([
293+
{
294+
name: "foo",
295+
longNumber: {
296+
$numberLong: "1234",
297+
},
298+
},
299+
]);
300+
});
301+
});
302+
});

0 commit comments

Comments
 (0)