|
| 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