• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mongodb-js / mongodb-mcp-server / 17671857423

12 Sep 2025 10:28AM UTC coverage: 81.466% (-0.06%) from 81.528%
17671857423

Pull #550

github

web-flow
Merge 68f35722a into 02fe6a246
Pull Request #550: fix: add untrusted data wrapper to the export resource MCP-197

967 of 1284 branches covered (75.31%)

Branch coverage included in aggregate %.

44 of 46 new or added lines in 2 files covered. (95.65%)

31 existing lines in 3 files now uncovered.

4866 of 5876 relevant lines covered (82.81%)

44.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.92
/src/common/exportsManager.ts
1
import z from "zod";
2✔
2
import path from "path";
2✔
3
import fs from "fs/promises";
2✔
4
import EventEmitter from "events";
2✔
5
import { createWriteStream } from "fs";
2✔
6
import type { AggregationCursor, FindCursor } from "mongodb";
7
import type { EJSONOptions } from "bson";
8
import { EJSON, ObjectId } from "bson";
2✔
9
import { Transform } from "stream";
2✔
10
import { pipeline } from "stream/promises";
2✔
11
import type { MongoLogId } from "mongodb-log-writer";
12

13
import type { UserConfig } from "./config.js";
14
import type { LoggerBase } from "./logger.js";
15
import { LogId } from "./logger.js";
2✔
16

17
export const jsonExportFormat = z.enum(["relaxed", "canonical"]);
2✔
18
export type JSONExportFormat = z.infer<typeof jsonExportFormat>;
19

20
interface CommonExportData {
21
    exportName: string;
22
    exportTitle: string;
23
    exportURI: string;
24
    exportPath: string;
25
}
26

27
interface ReadyExport extends CommonExportData {
28
    exportStatus: "ready";
29
    exportCreatedAt: number;
30
    docsTransformed: number;
31
}
32

33
interface InProgressExport extends CommonExportData {
34
    exportStatus: "in-progress";
35
}
36

37
type StoredExport = ReadyExport | InProgressExport;
38

39
/**
40
 * Ideally just exportName and exportURI should be made publicly available but
41
 * we also make exportPath available because the export tool, also returns the
42
 * exportPath in its response when the MCP server is running connected to stdio
43
 * transport. The reasoning behind this is that a few clients, Cursor in
44
 * particular, as of the date of this writing (7 August 2025) cannot refer to
45
 * resource URIs which means they have no means to access the exported resource.
46
 * As of this writing, majority of the usage of our MCP server is behind STDIO
47
 * transport so we can assume that for most of the usages, if not all, the MCP
48
 * server will be running on the same machine as of the MCP client and thus we
49
 * can provide the local path to export so that these clients which do not still
50
 * support parsing resource URIs, can still work with the exported data. We
51
 * expect for clients to catch up and implement referencing resource URIs at
52
 * which point it would be safe to remove the `exportPath` from the publicly
53
 * exposed properties of an export.
54
 *
55
 * The editors that we would like to watch out for are Cursor and Windsurf as
56
 * they don't yet support working with Resource URIs.
57
 *
58
 * Ref Cursor: https://forum.cursor.com/t/cursor-mcp-resource-feature-support/50987
59
 * JIRA: https://jira.mongodb.org/browse/MCP-104 */
60
export type AvailableExport = Pick<StoredExport, "exportName" | "exportTitle" | "exportURI" | "exportPath">;
61

62
export type ExportsManagerConfig = Pick<UserConfig, "exportsPath" | "exportTimeoutMs" | "exportCleanupIntervalMs">;
63

64
type ExportsManagerEvents = {
65
    closed: [];
66
    "export-expired": [string];
67
    "export-available": [string];
68
};
69

70
export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
2✔
71
    private storedExports: Record<StoredExport["exportName"], StoredExport> = {};
2✔
72
    private exportsCleanupInProgress: boolean = false;
2✔
73
    private exportsCleanupInterval?: NodeJS.Timeout;
74
    private readonly shutdownController: AbortController = new AbortController();
2✔
75

76
    private constructor(
2✔
77
        private readonly exportsDirectoryPath: string,
90✔
78
        private readonly config: ExportsManagerConfig,
90✔
79
        private readonly logger: LoggerBase
90✔
80
    ) {
90✔
81
        super();
90✔
82
    }
90✔
83

84
    public get availableExports(): AvailableExport[] {
2✔
85
        this.assertIsNotShuttingDown();
13✔
86
        return Object.values(this.storedExports)
13✔
87
            .filter((storedExport) => {
13✔
88
                return (
12✔
89
                    storedExport.exportStatus === "ready" &&
12✔
90
                    !isExportExpired(storedExport.exportCreatedAt, this.config.exportTimeoutMs)
10✔
91
                );
92
            })
13✔
93
            .map(({ exportName, exportTitle, exportURI, exportPath }) => ({
13✔
94
                exportName,
10✔
95
                exportTitle,
10✔
96
                exportURI,
10✔
97
                exportPath,
10✔
98
            }));
13✔
99
    }
13✔
100

101
    protected init(): void {
2✔
102
        if (!this.exportsCleanupInterval) {
90✔
103
            this.exportsCleanupInterval = setInterval(
90✔
104
                () => void this.cleanupExpiredExports(),
90✔
105
                this.config.exportCleanupIntervalMs
90✔
106
            );
90✔
107
        }
90✔
108
    }
90✔
109

110
    public async close(): Promise<void> {
2✔
111
        if (this.shutdownController.signal.aborted) {
83!
112
            return;
4✔
113
        }
4✔
114
        try {
79✔
115
            clearInterval(this.exportsCleanupInterval);
79✔
116
            this.shutdownController.abort();
79✔
117
            await fs.rm(this.exportsDirectoryPath, { force: true, recursive: true });
79✔
118
            this.emit("closed");
79✔
119
        } catch (error) {
83!
120
            this.logger.error({
×
121
                id: LogId.exportCloseError,
×
122
                context: "Error while closing ExportsManager",
×
123
                message: error instanceof Error ? error.message : String(error),
×
124
            });
×
125
        }
×
126
    }
83✔
127

128
    public async readExport(exportName: string): Promise<{ content: string; docsTransformed: number }> {
2✔
129
        try {
15✔
130
            this.assertIsNotShuttingDown();
15✔
131
            exportName = decodeAndNormalize(exportName);
15✔
132
            const exportHandle = this.storedExports[exportName];
15✔
133
            if (!exportHandle) {
15✔
134
                throw new Error("Requested export has either expired or does not exist.");
4✔
135
            }
4✔
136

137
            if (exportHandle.exportStatus === "in-progress") {
15✔
138
                throw new Error("Requested export is still being generated. Try again later.");
1✔
139
            }
1✔
140

141
            const { exportPath, docsTransformed } = exportHandle;
9✔
142

143
            return {
9✔
144
                content: await fs.readFile(exportPath, { encoding: "utf8", signal: this.shutdownController.signal }),
9✔
145
                docsTransformed,
9✔
146
            };
9✔
147
        } catch (error) {
13✔
148
            this.logger.error({
6✔
149
                id: LogId.exportReadError,
6✔
150
                context: `Error when reading export - ${exportName}`,
6✔
151
                message: error instanceof Error ? error.message : String(error),
6!
152
            });
6✔
153
            throw error;
6✔
154
        }
6✔
155
    }
15✔
156

157
    public async createJSONExport({
2✔
158
        input,
39✔
159
        exportName,
39✔
160
        exportTitle,
39✔
161
        jsonExportFormat,
39✔
162
    }: {
39✔
163
        input: FindCursor | AggregationCursor;
164
        exportName: string;
165
        exportTitle: string;
166
        jsonExportFormat: JSONExportFormat;
167
    }): Promise<AvailableExport> {
39✔
168
        try {
39✔
169
            this.assertIsNotShuttingDown();
39✔
170
            const exportNameWithExtension = decodeAndNormalize(ensureExtension(exportName, "json"));
39✔
171
            if (this.storedExports[exportNameWithExtension]) {
39✔
172
                return Promise.reject(
1✔
173
                    new Error("Export with same name is either already available or being generated.")
1✔
174
                );
1✔
175
            }
1✔
176
            const exportURI = `exported-data://${encodeURIComponent(exportNameWithExtension)}`;
37✔
177
            const exportFilePath = path.join(this.exportsDirectoryPath, exportNameWithExtension);
37✔
178
            const inProgressExport: InProgressExport = (this.storedExports[exportNameWithExtension] = {
37✔
179
                exportName: exportNameWithExtension,
37✔
180
                exportTitle,
37✔
181
                exportPath: exportFilePath,
37✔
182
                exportURI: exportURI,
37✔
183
                exportStatus: "in-progress",
37✔
184
            });
37✔
185

186
            void this.startExport({ input, jsonExportFormat, inProgressExport });
37✔
187
            return Promise.resolve(inProgressExport);
37✔
188
        } catch (error) {
39✔
189
            this.logger.error({
1✔
190
                id: LogId.exportCreationError,
1✔
191
                context: "Error when registering JSON export request",
1✔
192
                message: error instanceof Error ? error.message : String(error),
1!
193
            });
1✔
194
            throw error;
1✔
195
        }
1✔
196
    }
39✔
197

198
    private async startExport({
2✔
199
        input,
37✔
200
        jsonExportFormat,
37✔
201
        inProgressExport,
37✔
202
    }: {
37✔
203
        input: FindCursor | AggregationCursor;
204
        jsonExportFormat: JSONExportFormat;
205
        inProgressExport: InProgressExport;
206
    }): Promise<void> {
37✔
207
        try {
37✔
208
            let pipeSuccessful = false;
37✔
209
            let docsTransformed = 0;
37✔
210
            try {
37✔
211
                await fs.mkdir(this.exportsDirectoryPath, { recursive: true });
37✔
212
                const outputStream = createWriteStream(inProgressExport.exportPath);
37✔
213
                const ejsonTransform = this.docToEJSONStream(this.getEJSONOptionsForFormat(jsonExportFormat));
37✔
214
                await pipeline([input.stream(), ejsonTransform, outputStream], {
37✔
215
                    signal: this.shutdownController.signal,
37✔
216
                });
37✔
217
                docsTransformed = ejsonTransform.docsTransformed;
30✔
218
                pipeSuccessful = true;
30✔
219
            } catch (error) {
37✔
220
                // If the pipeline errors out then we might end up with
221
                // partial and incorrect export so we remove it entirely.
222
                delete this.storedExports[inProgressExport.exportName];
6✔
223
                // do not block the user, just delete the file in the background
224
                void this.silentlyRemoveExport(
6✔
225
                    inProgressExport.exportPath,
6✔
226
                    LogId.exportCreationCleanupError,
6✔
227
                    `Error when removing incomplete export ${inProgressExport.exportName}`
6✔
228
                );
6✔
229
                throw error;
6✔
230
            } finally {
37✔
231
                if (pipeSuccessful) {
36✔
232
                    this.storedExports[inProgressExport.exportName] = {
30✔
233
                        ...inProgressExport,
30✔
234
                        exportCreatedAt: Date.now(),
30✔
235
                        exportStatus: "ready",
30✔
236
                        docsTransformed,
30✔
237
                    };
30✔
238
                    this.emit("export-available", inProgressExport.exportURI);
30✔
239
                }
30✔
240
                void input.close();
36✔
241
            }
36✔
242
        } catch (error) {
37✔
243
            this.logger.error({
6✔
244
                id: LogId.exportCreationError,
6✔
245
                context: `Error when generating JSON export for ${inProgressExport.exportName}`,
6✔
246
                message: error instanceof Error ? error.message : String(error),
6!
247
            });
6✔
248
        }
6✔
249
    }
37✔
250

251
    private getEJSONOptionsForFormat(format: JSONExportFormat): EJSONOptions | undefined {
2✔
252
        switch (format) {
37✔
253
            case "relaxed":
37✔
254
                return { relaxed: true };
33✔
255
            case "canonical":
37✔
256
                return { relaxed: false };
4✔
257
            default:
37!
258
                return undefined;
×
259
        }
37✔
260
    }
37✔
261

262
    private docToEJSONStream(ejsonOptions: EJSONOptions | undefined): Transform & { docsTransformed: number } {
2✔
263
        let docsTransformed = 0;
36✔
264
        const result = Object.assign(
36✔
265
            new Transform({
36✔
266
                objectMode: true,
36✔
267
                transform(chunk: unknown, encoding, callback): void {
36✔
268
                    try {
34✔
269
                        const doc = EJSON.stringify(chunk, undefined, undefined, ejsonOptions);
34✔
270
                        if (docsTransformed === 0) {
34✔
271
                            this.push("[" + doc);
25✔
272
                        } else {
34✔
273
                            this.push(",\n" + doc);
9✔
274
                        }
9✔
275
                        docsTransformed++;
34✔
276
                        callback();
34✔
277
                    } catch (err) {
34!
NEW
278
                        callback(err as Error);
×
NEW
279
                    }
×
280
                },
34✔
281
                flush(callback): void {
36✔
282
                    if (docsTransformed === 0) {
30✔
283
                        this.push("[]");
6✔
284
                    } else {
30✔
285
                        this.push("]");
24✔
286
                    }
24✔
287
                    result.docsTransformed = docsTransformed;
30✔
288
                    callback();
30✔
289
                },
30✔
290
            }),
36✔
291
            { docsTransformed }
36✔
292
        );
36✔
293

294
        return result;
36✔
295
    }
36✔
296

297
    private async cleanupExpiredExports(): Promise<void> {
2✔
298
        if (this.exportsCleanupInProgress) {
17!
299
            return;
×
300
        }
×
301

302
        this.exportsCleanupInProgress = true;
17✔
303
        try {
17✔
304
            // first, unregister all exports that are expired, so they are not considered anymore for reading
305
            const exportsForCleanup: ReadyExport[] = [];
17✔
306
            for (const expiredExport of Object.values(this.storedExports)) {
17✔
307
                if (
15✔
308
                    expiredExport.exportStatus === "ready" &&
15✔
309
                    isExportExpired(expiredExport.exportCreatedAt, this.config.exportTimeoutMs)
6✔
310
                ) {
15✔
311
                    exportsForCleanup.push(expiredExport);
2✔
312
                    delete this.storedExports[expiredExport.exportName];
2✔
313
                }
2✔
314
            }
15✔
315

316
            // and then remove them (slow operation potentially) from disk.
317
            const allDeletionPromises: Promise<void>[] = [];
17✔
318
            for (const { exportPath, exportName } of exportsForCleanup) {
17✔
319
                allDeletionPromises.push(
2✔
320
                    this.silentlyRemoveExport(
2✔
321
                        exportPath,
2✔
322
                        LogId.exportCleanupError,
2✔
323
                        `Considerable error when removing export ${exportName}`
2✔
324
                    )
2✔
325
                );
2✔
326
            }
2✔
327

328
            await Promise.allSettled(allDeletionPromises);
17✔
329
        } catch (error) {
17!
330
            this.logger.error({
×
331
                id: LogId.exportCleanupError,
×
332
                context: "Error when cleaning up exports",
×
333
                message: error instanceof Error ? error.message : String(error),
×
334
            });
×
335
        } finally {
17✔
336
            this.exportsCleanupInProgress = false;
17✔
337
        }
17✔
338
    }
17✔
339

340
    private async silentlyRemoveExport(exportPath: string, logId: MongoLogId, logContext: string): Promise<void> {
2✔
341
        try {
8✔
342
            await fs.unlink(exportPath);
8✔
343
        } catch (error) {
8!
344
            // If the file does not exist or the containing directory itself
345
            // does not exist then we can safely ignore that error anything else
346
            // we need to flag.
347
            if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
×
348
                this.logger.error({
×
349
                    id: logId,
×
350
                    context: logContext,
×
351
                    message: error instanceof Error ? error.message : String(error),
×
352
                });
×
353
            }
×
354
        }
×
355
    }
8✔
356

357
    private assertIsNotShuttingDown(): void {
2✔
358
        if (this.shutdownController.signal.aborted) {
67✔
359
            throw new Error("ExportsManager is shutting down.");
3✔
360
        }
3✔
361
    }
67✔
362

363
    static init(
2✔
364
        config: ExportsManagerConfig,
90✔
365
        logger: LoggerBase,
90✔
366
        sessionId = new ObjectId().toString()
90✔
367
    ): ExportsManager {
90✔
368
        const exportsDirectoryPath = path.join(config.exportsPath, sessionId);
90✔
369
        const exportsManager = new ExportsManager(exportsDirectoryPath, config, logger);
90✔
370
        exportsManager.init();
90✔
371
        return exportsManager;
90✔
372
    }
90✔
373
}
2✔
374

375
export function decodeAndNormalize(text: string): string {
2✔
376
    return decodeURIComponent(text).normalize("NFKC");
52✔
377
}
52✔
378

379
/**
380
 * Ensures the path ends with the provided extension */
381
export function ensureExtension(pathOrName: string, extension: string): string {
2✔
382
    const extWithDot = extension.startsWith(".") ? extension : `.${extension}`;
44!
383
    if (pathOrName.endsWith(extWithDot)) {
44✔
384
        return pathOrName;
39✔
385
    }
39✔
386
    return `${pathOrName}${extWithDot}`;
5✔
387
}
5✔
388

389
export function isExportExpired(createdAt: number, exportTimeoutMs: number): boolean {
2✔
390
    return Date.now() - createdAt > exportTimeoutMs;
18✔
391
}
18✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc