Skip to content

Commit 2762891

Browse files
chore: limit the exposed property of an export
1 parent 4992fa6 commit 2762891

File tree

5 files changed

+80
-62
lines changed

5 files changed

+80
-62
lines changed

src/common/sessionExportsManager.ts

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,36 @@ import { LoggerBase, LogId } from "./logger.js";
1414
export const jsonExportFormat = z.enum(["relaxed", "canonical"]);
1515
export type JSONExportFormat = z.infer<typeof jsonExportFormat>;
1616

17-
export type Export = {
18-
name: string;
19-
uri: string;
20-
path: string;
21-
createdAt: number;
17+
type StoredExport = {
18+
exportName: string;
19+
exportURI: string;
20+
exportPath: string;
21+
exportCreatedAt: number;
2222
};
2323

24+
/**
25+
* Ideally just exportName and exportURI should be made publicly available but
26+
* we also make exportPath available because the export tool, also returns the
27+
* exportPath in its response when the MCP server is running connected to stdio
28+
* transport. The reasoning behind this is that a few clients, Cursor in
29+
* particular, as of the date of this writing (7 August 2025) cannot refer to
30+
* resource URIs which means they have no means to access the exported resource.
31+
* As of this writing, majority of the usage of our MCP server is behind STDIO
32+
* transport so we can assume that for most of the usages, if not all, the MCP
33+
* server will be running on the same machine as of the MCP client and thus we
34+
* can provide the local path to export so that these clients which do not still
35+
* support parsing resource URIs, can still work with the exported data. We
36+
* expect for clients to catch up and implement referencing resource URIs at
37+
* which point it would be safe to remove the `exportPath` from the publicly
38+
* exposed properties of an export.
39+
*
40+
* The editors that we would like to watch out for are Cursor and Windsurf as
41+
* they don't yet support working with Resource URIs.
42+
*
43+
* Ref Cursor: https://forum.cursor.com/t/cursor-mcp-resource-feature-support/50987
44+
* JIRA: https://jira.mongodb.org/browse/MCP-104 */
45+
type AvailableExport = Pick<StoredExport, "exportName" | "exportURI" | "exportPath">;
46+
2447
export type SessionExportsManagerConfig = Pick<
2548
UserConfig,
2649
"exportsPath" | "exportTimeoutMs" | "exportCleanupIntervalMs"
@@ -32,7 +55,7 @@ type SessionExportsManagerEvents = {
3255
};
3356

3457
export class SessionExportsManager extends EventEmitter<SessionExportsManagerEvents> {
35-
private sessionExports: Record<Export["name"], Export> = {};
58+
private sessionExports: Record<StoredExport["exportName"], StoredExport> = {};
3659
private exportsCleanupInProgress: boolean = false;
3760
private exportsCleanupInterval: NodeJS.Timeout;
3861
private exportsDirectoryPath: string;
@@ -50,10 +73,14 @@ export class SessionExportsManager extends EventEmitter<SessionExportsManagerEve
5073
);
5174
}
5275

53-
public get availableExports(): Export[] {
54-
return Object.values(this.sessionExports).filter(
55-
({ createdAt }) => !isExportExpired(createdAt, this.config.exportTimeoutMs)
56-
);
76+
public get availableExports(): AvailableExport[] {
77+
return Object.values(this.sessionExports)
78+
.filter(({ exportCreatedAt: createdAt }) => !isExportExpired(createdAt, this.config.exportTimeoutMs))
79+
.map(({ exportName, exportURI, exportPath }) => ({
80+
exportName,
81+
exportURI,
82+
exportPath,
83+
}));
5784
}
5885

5986
public async close(): Promise<void> {
@@ -69,27 +96,21 @@ export class SessionExportsManager extends EventEmitter<SessionExportsManagerEve
6996
}
7097
}
7198

72-
public async readExport(exportName: string): Promise<{
73-
content: string;
74-
exportURI: string;
75-
}> {
99+
public async readExport(exportName: string): Promise<string> {
76100
try {
77101
const exportNameWithExtension = validateExportName(exportName);
78102
const exportHandle = this.sessionExports[exportNameWithExtension];
79103
if (!exportHandle) {
80104
throw new Error("Requested export has either expired or does not exist!");
81105
}
82106

83-
const { path: exportPath, uri, createdAt } = exportHandle;
107+
const { exportPath, exportCreatedAt } = exportHandle;
84108

85-
if (isExportExpired(createdAt, this.config.exportTimeoutMs)) {
109+
if (isExportExpired(exportCreatedAt, this.config.exportTimeoutMs)) {
86110
throw new Error("Requested export has expired!");
87111
}
88112

89-
return {
90-
exportURI: uri,
91-
content: await fs.readFile(exportPath, "utf8"),
92-
};
113+
return await fs.readFile(exportPath, "utf8");
93114
} catch (error) {
94115
this.logger.error({
95116
id: LogId.exportReadError,
@@ -111,10 +132,7 @@ export class SessionExportsManager extends EventEmitter<SessionExportsManagerEve
111132
input: FindCursor;
112133
exportName: string;
113134
jsonExportFormat: JSONExportFormat;
114-
}): Promise<{
115-
exportURI: string;
116-
exportPath: string;
117-
}> {
135+
}): Promise<AvailableExport> {
118136
try {
119137
const exportNameWithExtension = validateExportName(ensureExtension(exportName, "json"));
120138
const exportURI = `exported-data://${encodeURIComponent(exportNameWithExtension)}`;
@@ -130,6 +148,7 @@ export class SessionExportsManager extends EventEmitter<SessionExportsManagerEve
130148
await pipeline([inputStream, ejsonDocStream, outputStream]);
131149
pipeSuccessful = true;
132150
return {
151+
exportName,
133152
exportURI,
134153
exportPath: exportFilePath,
135154
};
@@ -150,10 +169,10 @@ export class SessionExportsManager extends EventEmitter<SessionExportsManagerEve
150169
void input.close();
151170
if (pipeSuccessful) {
152171
this.sessionExports[exportNameWithExtension] = {
153-
name: exportNameWithExtension,
154-
createdAt: Date.now(),
155-
path: exportFilePath,
156-
uri: exportURI,
172+
exportName: exportNameWithExtension,
173+
exportCreatedAt: Date.now(),
174+
exportPath: exportFilePath,
175+
exportURI: exportURI,
157176
};
158177
this.emit("export-available", exportURI);
159178
}
@@ -211,11 +230,11 @@ export class SessionExportsManager extends EventEmitter<SessionExportsManagerEve
211230
this.exportsCleanupInProgress = true;
212231
const exportsForCleanup = { ...this.sessionExports };
213232
try {
214-
for (const { path: exportPath, createdAt, uri, name } of Object.values(exportsForCleanup)) {
215-
if (isExportExpired(createdAt, this.config.exportTimeoutMs)) {
216-
delete this.sessionExports[name];
233+
for (const { exportPath, exportCreatedAt, exportURI, exportName } of Object.values(exportsForCleanup)) {
234+
if (isExportExpired(exportCreatedAt, this.config.exportTimeoutMs)) {
235+
delete this.sessionExports[exportName];
217236
await this.silentlyRemoveExport(exportPath);
218-
this.emit("export-expired", uri);
237+
this.emit("export-expired", exportURI);
219238
}
220239
}
221240
} catch (error) {

src/resources/common/exportedData.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ export class ExportedData {
4848
private listResourcesCallback: ListResourcesCallback = () => {
4949
try {
5050
return {
51-
resources: this.server.session.exportsManager.availableExports.map(({ name, uri }) => ({
52-
name: name,
53-
description: this.exportNameToDescription(name),
54-
uri: uri,
51+
resources: this.server.session.exportsManager.availableExports.map(({ exportName, exportURI }) => ({
52+
name: exportName,
53+
description: this.exportNameToDescription(exportName),
54+
uri: exportURI,
5555
mimeType: "application/json",
5656
})),
5757
};
@@ -70,8 +70,8 @@ export class ExportedData {
7070
private autoCompleteExportName: CompleteResourceTemplateCallback = (value) => {
7171
try {
7272
return this.server.session.exportsManager.availableExports
73-
.filter(({ name }) => name.startsWith(value))
74-
.map(({ name }) => name);
73+
.filter(({ exportName }) => exportName.startsWith(value))
74+
.map(({ exportName }) => exportName);
7575
} catch (error) {
7676
this.server.session.logger.error({
7777
id: LogId.exportedDataAutoCompleteError,
@@ -88,12 +88,12 @@ export class ExportedData {
8888
throw new Error("Cannot retrieve exported data, exportName not provided.");
8989
}
9090

91-
const { content, exportURI } = await this.server.session.exportsManager.readExport(exportName);
91+
const content = await this.server.session.exportsManager.readExport(exportName);
9292

9393
return {
9494
contents: [
9595
{
96-
uri: exportURI,
96+
uri: url.href,
9797
text: content,
9898
mimeType: "application/json",
9999
},
@@ -104,7 +104,7 @@ export class ExportedData {
104104
contents: [
105105
{
106106
uri: url.href,
107-
text: `Error reading from ${this.uri}: ${error instanceof Error ? error.message : String(error)}`,
107+
text: `Error reading ${url.href}: ${error instanceof Error ? error.message : String(error)}`,
108108
},
109109
],
110110
isError: true,

src/tools/mongodb/read/export.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ export class ExportTool extends MongoDBToolBase {
6666
];
6767

6868
// This special case is to make it easier to work with exported data for
69-
// stdio transport.
69+
// clients that still cannot reference resources (Cursor).
70+
// More information here: https://jira.mongodb.org/browse/MCP-104
7071
if (this.config.transport === "stdio") {
7172
toolCallContent.push({
7273
type: "text",

tests/integration/resources/exportedData.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ describeWithMongoDB(
3232

3333
describe("when requesting non-existent resource", () => {
3434
it("should return an error", async () => {
35+
const exportURI = "exported-data://db.coll.json";
3536
await integration.connectMcpClient();
3637
const response = await integration.mcpClient().readResource({
37-
uri: "exported-data://db.coll.json",
38+
uri: exportURI,
3839
});
3940
expect(response.isError).toEqual(true);
40-
expect(response.contents[0]?.uri).toEqual("exported-data://db.coll.json");
41+
expect(response.contents[0]?.uri).toEqual(exportURI);
4142
expect(response.contents[0]?.text).toEqual(
42-
"Error reading from exported-data://{exportName}: Requested export has either expired or does not exist!"
43+
`Error reading ${exportURI}: Requested export has either expired or does not exist!`
4344
);
4445
});
4546
});
@@ -65,7 +66,7 @@ describeWithMongoDB(
6566
expect(response.isError).toEqual(true);
6667
expect(response.contents[0]?.uri).toEqual(exportedResourceURI);
6768
expect(response.contents[0]?.text).toEqual(
68-
"Error reading from exported-data://{exportName}: Requested export has expired!"
69+
`Error reading ${exportedResourceURI}: Requested export has expired!`
6970
);
7071
});
7172
});

tests/unit/common/sessionExportsManager.test.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,14 @@ describe("SessionExportsManager unit test", () => {
8787
});
8888

8989
it("should return the resource content", async () => {
90-
const { exportName, exportURI } = getExportNameAndPath(session.sessionId, Date.now());
90+
const { exportName } = getExportNameAndPath(session.sessionId, Date.now());
9191
const inputCursor = createDummyFindCursor([]);
9292
await manager.createJSONExport({
9393
input: inputCursor,
9494
exportName,
9595
jsonExportFormat: "relaxed",
9696
});
97-
expect(await manager.readExport(exportName)).toEqual({
98-
content: "[]",
99-
exportURI,
100-
});
97+
expect(await manager.readExport(exportName)).toEqual("[]");
10198
});
10299
});
103100

@@ -137,16 +134,16 @@ describe("SessionExportsManager unit test", () => {
137134
expect(availableExports).toHaveLength(1);
138135
expect(availableExports).toContainEqual(
139136
expect.objectContaining({
140-
name: exportName,
141-
uri: exportURI,
137+
exportName,
138+
exportURI,
142139
})
143140
);
144141

145142
// Emit event
146143
expect(emitSpy).toHaveBeenCalledWith("export-available", exportURI);
147144

148145
// Exports relaxed json
149-
const jsonData = JSON.parse((await manager.readExport(exportName)).content) as unknown[];
146+
const jsonData = JSON.parse(await manager.readExport(exportName)) as unknown[];
150147
expect(jsonData).toEqual([]);
151148
});
152149
});
@@ -169,16 +166,16 @@ describe("SessionExportsManager unit test", () => {
169166
expect(availableExports).toHaveLength(1);
170167
expect(availableExports).toContainEqual(
171168
expect.objectContaining({
172-
name: expectedExportName,
173-
uri: `exported-data://${expectedExportName}`,
169+
exportName: expectedExportName,
170+
exportURI: `exported-data://${expectedExportName}`,
174171
})
175172
);
176173

177174
// Emit event
178175
expect(emitSpy).toHaveBeenCalledWith("export-available", `exported-data://${expectedExportName}`);
179176

180177
// Exports relaxed json
181-
const jsonData = JSON.parse((await manager.readExport(expectedExportName)).content) as unknown[];
178+
const jsonData = JSON.parse(await manager.readExport(expectedExportName)) as unknown[];
182179
expect(jsonData).toContainEqual(expect.objectContaining({ name: "foo", longNumber: 12 }));
183180
expect(jsonData).toContainEqual(expect.objectContaining({ name: "bar", longNumber: 123456 }));
184181
});
@@ -202,16 +199,16 @@ describe("SessionExportsManager unit test", () => {
202199
expect(availableExports).toHaveLength(1);
203200
expect(availableExports).toContainEqual(
204201
expect.objectContaining({
205-
name: expectedExportName,
206-
uri: `exported-data://${expectedExportName}`,
202+
exportName: expectedExportName,
203+
exportURI: `exported-data://${expectedExportName}`,
207204
})
208205
);
209206

210207
// Emit event
211208
expect(emitSpy).toHaveBeenCalledWith("export-available", `exported-data://${expectedExportName}`);
212209

213210
// Exports relaxed json
214-
const jsonData = JSON.parse((await manager.readExport(expectedExportName)).content) as unknown[];
211+
const jsonData = JSON.parse(await manager.readExport(expectedExportName)) as unknown[];
215212
expect(jsonData).toContainEqual(
216213
expect.objectContaining({ name: "foo", longNumber: { $numberLong: "12" } })
217214
);
@@ -300,8 +297,8 @@ describe("SessionExportsManager unit test", () => {
300297

301298
expect(manager.availableExports).toContainEqual(
302299
expect.objectContaining({
303-
name: exportName,
304-
uri: exportURI,
300+
exportName,
301+
exportURI,
305302
})
306303
);
307304
expect(await fileExists(exportPath)).toEqual(true);

0 commit comments

Comments
 (0)