export
Extract MongoDB collection data or query results in EJSON format. Specify filters, projections, sorting, and export format to customize data retrieval for analysis or migration.
Instructions
Export a collection data or query results in the specified EJSON format.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| collection | Yes | Collection name | |
| database | Yes | Database name | |
| exportTitle | Yes | A short description to uniquely identify the export. | |
| filter | No | The query filter, matching the syntax of the query argument of db.collection.find() | |
| jsonExportFormat | No | The format to be used when exporting collection data as EJSON with default being relaxed. 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. 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. | relaxed |
| limit | No | The maximum number of documents to return | |
| projection | No | The projection, matching the syntax of the projection argument of db.collection.find() | |
| sort | No | 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). |
Input Schema (JSON Schema)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"collection": {
"description": "Collection name",
"type": "string"
},
"database": {
"description": "Database name",
"type": "string"
},
"exportTitle": {
"description": "A short description to uniquely identify the export.",
"type": "string"
},
"filter": {
"additionalProperties": true,
"description": "The query filter, matching the syntax of the query argument of db.collection.find()",
"properties": {},
"type": "object"
},
"jsonExportFormat": {
"default": "relaxed",
"description": "The format to be used when exporting collection data as EJSON with default being relaxed.\nrelaxed: 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.\ncanonical: 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.",
"enum": [
"relaxed",
"canonical"
],
"type": "string"
},
"limit": {
"description": "The maximum number of documents to return",
"type": "number"
},
"projection": {
"additionalProperties": true,
"description": "The projection, matching the syntax of the projection argument of db.collection.find()",
"properties": {},
"type": "object"
},
"sort": {
"additionalProperties": {},
"description": "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).",
"properties": {},
"type": "object"
}
},
"required": [
"exportTitle",
"database",
"collection"
],
"type": "object"
}
Implementation Reference
- src/tools/mongodb/read/export.ts:54-126 (handler)Handler function (execute method) for the 'export' tool. Creates a cursor based on provided find or aggregate arguments, exports the data as JSON using ExportsManager, and returns a resource link URI.protected async execute({ database, collection, jsonExportFormat, exportTitle, exportTarget: target, }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> { const provider = await this.ensureConnected(); const exportTarget = target[0]; if (!exportTarget) { throw new Error("Export target not provided. Expected one of the following: `aggregate`, `find`"); } let cursor: FindCursor | AggregationCursor; if (exportTarget.name === "find") { const { filter, projection, sort, limit } = exportTarget.arguments; cursor = provider.find(database, collection, filter ?? {}, { projection, sort, limit, promoteValues: false, bsonRegExp: true, }); } else { const { pipeline } = exportTarget.arguments; cursor = provider.aggregate(database, collection, pipeline, { promoteValues: false, bsonRegExp: true, allowDiskUse: true, }); } const exportName = `${new ObjectId().toString()}.json`; const { exportURI, exportPath } = await this.session.exportsManager.createJSONExport({ input: cursor, exportName, exportTitle: exportTitle || `Export for namespace ${database}.${collection} requested on ${new Date().toLocaleString()}`, jsonExportFormat, }); const toolCallContent: CallToolResult["content"] = [ // Not all the clients as of this commit understands how to // parse a resource_link so we provide a text result for them to // understand what to do with the result. { type: "text", text: `Data for namespace ${database}.${collection} is being exported and will be made available under resource URI - "${exportURI}".`, }, { type: "resource_link", name: exportName, uri: exportURI, description: "Resource URI for fetching exported data once it is ready.", mimeType: "application/json", }, ]; // This special case is to make it easier to work with exported data for // clients that still cannot reference resources (Cursor). // More information here: https://jira.mongodb.org/browse/MCP-104 if (this.isServerRunningLocally()) { toolCallContent.push({ type: "text", text: `Optionally, when the export is finished, the exported data can also be accessed under path - "${exportPath}"`, }); } return { content: toolCallContent, }; }
- Input schema (argsShape) for the 'export' tool using Zod, including database/collection, title, target (discriminated union of find or aggregate args), and jsonExportFormat.protected argsShape = { ...DbOperationArgs, exportTitle: z.string().describe("A short description to uniquely identify the export."), exportTarget: z .array( z.discriminatedUnion("name", [ z.object({ name: z .literal("find") .describe("The literal name 'find' to represent a find cursor as target."), arguments: z .object({ ...FindArgs, limit: FindArgs.limit.removeDefault(), }) .describe("The arguments for 'find' operation."), }), z.object({ name: z .literal("aggregate") .describe("The literal name 'aggregate' to represent an aggregation cursor as target."), arguments: z .object(getAggregateArgs(this.isFeatureEnabled("search"))) .describe("The arguments for 'aggregate' operation."), }), ]) ) .describe("The export target along with its arguments."), jsonExportFormat: jsonExportFormat .default("relaxed") .describe( [ "The format to be used when exporting collection data as EJSON with default being relaxed.", "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.", "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.", ].join("\n") ), };
- src/server.ts:251-265 (registration)Server.registerTools() method instantiates all tools from toolConstructors (defaults to AllTools which includes ExportTool) and calls tool.register(this) to register them with the MCP server.private registerTools(): void { for (const toolConstructor of this.toolConstructors) { const tool = new toolConstructor({ category: toolConstructor.category, operationType: toolConstructor.operationType, session: this.session, config: this.userConfig, telemetry: this.telemetry, elicitation: this.elicitation, }); if (tool.register(this)) { this.tools.push(tool); } } }
- src/tools/mongodb/tools.ts:21-21 (registration)Re-export of the ExportTool class from its implementation file, making it available for inclusion in the tools index.export { ExportTool } from "./read/export.js";
- src/tools/index.ts:7-11 (registration)AllTools array collects all tool classes including those from MongoDbTools (which includes ExportTool), used by server as default toolConstructors.export const AllTools: ToolClass[] = Object.values({ ...MongoDbTools, ...AtlasTools, ...AtlasLocalTools, });
- src/common/exportsManager.ts:17-392 (helper)ExportsManager helper: defines jsonExportFormat schema, creates and manages JSON exports from cursors (used by export tool), provides resource URIs.export const jsonExportFormat = z.enum(["relaxed", "canonical"]); export type JSONExportFormat = z.infer<typeof jsonExportFormat>; interface CommonExportData { exportName: string; exportTitle: string; exportURI: string; exportPath: string; } interface ReadyExport extends CommonExportData { exportStatus: "ready"; exportCreatedAt: number; docsTransformed: number; } interface InProgressExport extends CommonExportData { exportStatus: "in-progress"; } type StoredExport = ReadyExport | InProgressExport; /** * Ideally just exportName and exportURI should be made publicly available but * we also make exportPath available because the export tool, also returns the * exportPath in its response when the MCP server is running connected to stdio * transport. The reasoning behind this is that a few clients, Cursor in * particular, as of the date of this writing (7 August 2025) cannot refer to * resource URIs which means they have no means to access the exported resource. * As of this writing, majority of the usage of our MCP server is behind STDIO * transport so we can assume that for most of the usages, if not all, the MCP * server will be running on the same machine as of the MCP client and thus we * can provide the local path to export so that these clients which do not still * support parsing resource URIs, can still work with the exported data. We * expect for clients to catch up and implement referencing resource URIs at * which point it would be safe to remove the `exportPath` from the publicly * exposed properties of an export. * * The editors that we would like to watch out for are Cursor and Windsurf as * they don't yet support working with Resource URIs. * * Ref Cursor: https://forum.cursor.com/t/cursor-mcp-resource-feature-support/50987 * JIRA: https://jira.mongodb.org/browse/MCP-104 */ export type AvailableExport = Pick<StoredExport, "exportName" | "exportTitle" | "exportURI" | "exportPath">; export type ExportsManagerConfig = Pick<UserConfig, "exportsPath" | "exportTimeoutMs" | "exportCleanupIntervalMs">; type ExportsManagerEvents = { closed: []; "export-expired": [string]; "export-available": [string]; }; export class ExportsManager extends EventEmitter<ExportsManagerEvents> { private storedExports: Record<StoredExport["exportName"], StoredExport> = {}; private exportsCleanupInProgress: boolean = false; private exportsCleanupInterval?: NodeJS.Timeout; private readonly shutdownController: AbortController = new AbortController(); private constructor( private readonly exportsDirectoryPath: string, private readonly config: ExportsManagerConfig, private readonly logger: LoggerBase ) { super(); } public get availableExports(): AvailableExport[] { this.assertIsNotShuttingDown(); return Object.values(this.storedExports) .filter((storedExport) => { return ( storedExport.exportStatus === "ready" && !isExportExpired(storedExport.exportCreatedAt, this.config.exportTimeoutMs) ); }) .map(({ exportName, exportTitle, exportURI, exportPath }) => ({ exportName, exportTitle, exportURI, exportPath, })); } protected init(): void { if (!this.exportsCleanupInterval) { this.exportsCleanupInterval = setInterval( () => void this.cleanupExpiredExports(), this.config.exportCleanupIntervalMs ); } } public async close(): Promise<void> { if (this.shutdownController.signal.aborted) { return; } try { clearInterval(this.exportsCleanupInterval); this.shutdownController.abort(); await fs.rm(this.exportsDirectoryPath, { force: true, recursive: true }); this.emit("closed"); } catch (error) { this.logger.error({ id: LogId.exportCloseError, context: "Error while closing ExportsManager", message: error instanceof Error ? error.message : String(error), }); } } public async readExport(exportName: string): Promise<{ content: string; docsTransformed: number }> { try { this.assertIsNotShuttingDown(); exportName = decodeAndNormalize(exportName); const exportHandle = this.storedExports[exportName]; if (!exportHandle) { throw new Error("Requested export has either expired or does not exist."); } if (exportHandle.exportStatus === "in-progress") { throw new Error("Requested export is still being generated. Try again later."); } const { exportPath, docsTransformed } = exportHandle; return { content: await fs.readFile(exportPath, { encoding: "utf8", signal: this.shutdownController.signal }), docsTransformed, }; } catch (error) { this.logger.error({ id: LogId.exportReadError, context: `Error when reading export - ${exportName}`, message: error instanceof Error ? error.message : String(error), }); throw error; } } public async createJSONExport({ input, exportName, exportTitle, jsonExportFormat, }: { input: FindCursor | AggregationCursor; exportName: string; exportTitle: string; jsonExportFormat: JSONExportFormat; }): Promise<AvailableExport> { try { this.assertIsNotShuttingDown(); const exportNameWithExtension = decodeAndNormalize(ensureExtension(exportName, "json")); if (this.storedExports[exportNameWithExtension]) { return Promise.reject( new Error("Export with same name is either already available or being generated.") ); } const exportURI = `exported-data://${encodeURIComponent(exportNameWithExtension)}`; const exportFilePath = path.join(this.exportsDirectoryPath, exportNameWithExtension); const inProgressExport: InProgressExport = (this.storedExports[exportNameWithExtension] = { exportName: exportNameWithExtension, exportTitle, exportPath: exportFilePath, exportURI: exportURI, exportStatus: "in-progress", }); void this.startExport({ input, jsonExportFormat, inProgressExport }); return Promise.resolve(inProgressExport); } catch (error) { this.logger.error({ id: LogId.exportCreationError, context: "Error when registering JSON export request", message: error instanceof Error ? error.message : String(error), }); throw error; } } private async startExport({ input, jsonExportFormat, inProgressExport, }: { input: FindCursor | AggregationCursor; jsonExportFormat: JSONExportFormat; inProgressExport: InProgressExport; }): Promise<void> { try { let pipeSuccessful = false; let docsTransformed = 0; try { await fs.mkdir(this.exportsDirectoryPath, { recursive: true }); const outputStream = createWriteStream(inProgressExport.exportPath); const ejsonTransform = this.docToEJSONStream(this.getEJSONOptionsForFormat(jsonExportFormat)); await pipeline([input.stream(), ejsonTransform, outputStream], { signal: this.shutdownController.signal, }); docsTransformed = ejsonTransform.docsTransformed; pipeSuccessful = true; } catch (error) { // If the pipeline errors out then we might end up with // partial and incorrect export so we remove it entirely. delete this.storedExports[inProgressExport.exportName]; // do not block the user, just delete the file in the background void this.silentlyRemoveExport( inProgressExport.exportPath, LogId.exportCreationCleanupError, `Error when removing incomplete export ${inProgressExport.exportName}` ); throw error; } finally { if (pipeSuccessful) { this.storedExports[inProgressExport.exportName] = { ...inProgressExport, exportCreatedAt: Date.now(), exportStatus: "ready", docsTransformed, }; this.emit("export-available", inProgressExport.exportURI); } void input.close(); } } catch (error) { this.logger.error({ id: LogId.exportCreationError, context: `Error when generating JSON export for ${inProgressExport.exportName}`, message: error instanceof Error ? error.message : String(error), }); } } private getEJSONOptionsForFormat(format: JSONExportFormat): EJSONOptions | undefined { switch (format) { case "relaxed": return { relaxed: true }; case "canonical": return { relaxed: false }; default: return undefined; } } private docToEJSONStream(ejsonOptions: EJSONOptions | undefined): Transform & { docsTransformed: number } { let docsTransformed = 0; const result = Object.assign( new Transform({ objectMode: true, transform(chunk: unknown, encoding, callback): void { try { const doc = EJSON.stringify(chunk, undefined, undefined, ejsonOptions); if (docsTransformed === 0) { this.push("[" + doc); } else { this.push(",\n" + doc); } docsTransformed++; callback(); } catch (err) { callback(err as Error); } }, flush(callback): void { if (docsTransformed === 0) { this.push("[]"); } else { this.push("]"); } result.docsTransformed = docsTransformed; callback(); }, }), { docsTransformed } ); return result; } private async cleanupExpiredExports(): Promise<void> { if (this.exportsCleanupInProgress) { return; } this.exportsCleanupInProgress = true; try { // first, unregister all exports that are expired, so they are not considered anymore for reading const exportsForCleanup: ReadyExport[] = []; for (const expiredExport of Object.values(this.storedExports)) { if ( expiredExport.exportStatus === "ready" && isExportExpired(expiredExport.exportCreatedAt, this.config.exportTimeoutMs) ) { exportsForCleanup.push(expiredExport); delete this.storedExports[expiredExport.exportName]; } } // and then remove them (slow operation potentially) from disk. const allDeletionPromises: Promise<void>[] = []; for (const { exportPath, exportName } of exportsForCleanup) { allDeletionPromises.push( this.silentlyRemoveExport( exportPath, LogId.exportCleanupError, `Considerable error when removing export ${exportName}` ) ); } await Promise.allSettled(allDeletionPromises); } catch (error) { this.logger.error({ id: LogId.exportCleanupError, context: "Error when cleaning up exports", message: error instanceof Error ? error.message : String(error), }); } finally { this.exportsCleanupInProgress = false; } } private async silentlyRemoveExport(exportPath: string, logId: MongoLogId, logContext: string): Promise<void> { try { await fs.unlink(exportPath); } catch (error) { // If the file does not exist or the containing directory itself // does not exist then we can safely ignore that error anything else // we need to flag. if ((error as NodeJS.ErrnoException).code !== "ENOENT") { this.logger.error({ id: logId, context: logContext, message: error instanceof Error ? error.message : String(error), }); } } } private assertIsNotShuttingDown(): void { if (this.shutdownController.signal.aborted) { throw new Error("ExportsManager is shutting down."); } } static init( config: ExportsManagerConfig, logger: LoggerBase, sessionId = new ObjectId().toString() ): ExportsManager { const exportsDirectoryPath = path.join(config.exportsPath, sessionId); const exportsManager = new ExportsManager(exportsDirectoryPath, config, logger); exportsManager.init(); return exportsManager; } } export function decodeAndNormalize(text: string): string { return decodeURIComponent(text).normalize("NFKC"); } /** * Ensures the path ends with the provided extension */ export function ensureExtension(pathOrName: string, extension: string): string { const extWithDot = extension.startsWith(".") ? extension : `.${extension}`; if (pathOrName.endsWith(extWithDot)) { return pathOrName; } return `${pathOrName}${extWithDot}`; } export function isExportExpired(createdAt: number, exportTimeoutMs: number): boolean { return Date.now() - createdAt > exportTimeoutMs; }