Skip to content

Commit 1b82cd7

Browse files
chore: tests for exported-data resource
1 parent 8d4523f commit 1b82cd7

File tree

9 files changed

+292
-127
lines changed

9 files changed

+292
-127
lines changed

src/common/logger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export const LogId = {
5252
exportCleanupError: mongoLogId(1_007_001),
5353
exportCreationError: mongoLogId(1_007_002),
5454
exportReadError: mongoLogId(1_007_003),
55+
exportCloseError: mongoLogId(1_007_004),
56+
exportedDataListError: mongoLogId(1_007_005),
57+
exportedDataAutoCompleteError: mongoLogId(1_007_006),
5558
} as const;
5659

5760
export abstract class LoggerBase {

src/common/sessionExportsManager.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,18 @@ export class SessionExportsManager {
4141
);
4242
}
4343

44-
public close() {
45-
clearInterval(this.exportsCleanupInterval);
44+
public async close() {
45+
try {
46+
clearInterval(this.exportsCleanupInterval);
47+
const exportsDirectory = this.exportsDirectoryPath();
48+
await fs.rm(exportsDirectory, { force: true, recursive: true });
49+
} catch (error) {
50+
logger.error(
51+
LogId.exportCloseError,
52+
"Error while closing SessionExportManager",
53+
error instanceof Error ? error.message : String(error)
54+
);
55+
}
4656
}
4757

4858
public exportNameToResourceURI(nameWithExtension: string): string {
@@ -209,8 +219,15 @@ export class SessionExportsManager {
209219
/**
210220
* Small utility to centrally determine if an export is expired or not */
211221
private async isExportFileExpired(exportFilePath: string): Promise<boolean> {
212-
const stats = await fs.stat(exportFilePath);
213-
return this.isExportExpired(stats.birthtimeMs);
222+
try {
223+
const stats = await fs.stat(exportFilePath);
224+
return this.isExportExpired(stats.birthtimeMs);
225+
} catch (error) {
226+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
227+
throw new Error("Requested export does not exist!");
228+
}
229+
throw error;
230+
}
214231
}
215232

216233
private isExportExpired(createdAt: number) {

src/resources/common/exported-data.ts

Lines changed: 0 additions & 114 deletions
This file was deleted.

src/resources/common/exportedData.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
CompleteResourceTemplateCallback,
3+
ListResourcesCallback,
4+
ReadResourceTemplateCallback,
5+
ResourceTemplate,
6+
} from "@modelcontextprotocol/sdk/server/mcp.js";
7+
import { Server } from "../../server.js";
8+
import logger, { LogId } from "../../common/logger.js";
9+
10+
export class ExportedData {
11+
private readonly name = "exported-data";
12+
private readonly description = "Data files exported in the current session.";
13+
private readonly uri = "exported-data://{exportName}";
14+
15+
constructor(private readonly server: Server) {
16+
this.server.session.on("export-available", (uri) => {
17+
this.server.mcpServer.sendResourceListChanged();
18+
void this.server.mcpServer.server.sendResourceUpdated({
19+
uri,
20+
});
21+
this.server.mcpServer.sendResourceListChanged();
22+
});
23+
this.server.session.on("export-expired", () => {
24+
this.server.mcpServer.sendResourceListChanged();
25+
});
26+
}
27+
28+
public register(): void {
29+
this.server.mcpServer.registerResource(
30+
this.name,
31+
new ResourceTemplate(this.uri, {
32+
/**
33+
* A few clients have the capability of listing templated
34+
* resources as well and this callback provides support for that
35+
* */
36+
list: this.listResourcesCallback,
37+
/**
38+
* This is to provide auto completion when user starts typing in
39+
* value for template variable, in our case, exportName */
40+
complete: {
41+
exportName: this.autoCompleteExportName,
42+
},
43+
}),
44+
{ description: this.description },
45+
this.readResourceCallback
46+
);
47+
}
48+
49+
private listResourcesCallback: ListResourcesCallback = () => {
50+
try {
51+
const sessionId = this.server.session.sessionId;
52+
if (!sessionId) {
53+
// Note that we don't throw error here because this is a
54+
// non-critical path and safe to return the most harmless value.
55+
56+
// TODO: log warn here
57+
return { resources: [] };
58+
}
59+
60+
const sessionExports = this.server.exportsManager.listAvailableExports();
61+
return {
62+
resources: sessionExports.map(({ name, uri }) => ({
63+
name: name,
64+
description: this.exportNameToDescription(name),
65+
uri: uri,
66+
mimeType: "application/json",
67+
})),
68+
};
69+
} catch (error) {
70+
logger.error(
71+
LogId.exportedDataListError,
72+
"Error when listing exported data resources",
73+
error instanceof Error ? error.message : String(error)
74+
);
75+
return {
76+
resources: [],
77+
};
78+
}
79+
};
80+
81+
private autoCompleteExportName: CompleteResourceTemplateCallback = (value) => {
82+
try {
83+
const sessionId = this.server.session.sessionId;
84+
if (!sessionId) {
85+
// Note that we don't throw error here because this is a
86+
// non-critical path and safe to return the most harmless value.
87+
88+
// TODO: log warn here
89+
return [];
90+
}
91+
92+
const sessionExports = this.server.exportsManager.listAvailableExports();
93+
return sessionExports.filter(({ name }) => name.startsWith(value)).map(({ name }) => name);
94+
} catch (error) {
95+
logger.error(
96+
LogId.exportedDataAutoCompleteError,
97+
"Error when autocompleting exported data",
98+
error instanceof Error ? error.message : String(error)
99+
);
100+
return [];
101+
}
102+
};
103+
104+
private readResourceCallback: ReadResourceTemplateCallback = async (uri, { exportName }) => {
105+
try {
106+
const sessionId = this.server.session.sessionId;
107+
if (!sessionId) {
108+
throw new Error("Cannot retrieve exported data, session is not valid.");
109+
}
110+
111+
if (typeof exportName !== "string") {
112+
throw new Error("Cannot retrieve exported data, exportName not provided.");
113+
}
114+
115+
return {
116+
contents: [
117+
{
118+
uri: this.server.exportsManager.exportNameToResourceURI(exportName),
119+
text: await this.server.exportsManager.readExport(exportName),
120+
mimeType: "application/json",
121+
},
122+
],
123+
};
124+
} catch (error) {
125+
return {
126+
contents: [
127+
{
128+
uri:
129+
typeof exportName === "string"
130+
? this.server.exportsManager.exportNameToResourceURI(exportName)
131+
: this.uri,
132+
text: `Error reading from ${this.uri}: ${error instanceof Error ? error.message : String(error)}`,
133+
},
134+
],
135+
isError: true,
136+
};
137+
}
138+
};
139+
140+
private exportNameToDescription(exportName: string) {
141+
const match = exportName.match(/^(.+)\.(\d+)\.json$/);
142+
if (!match) return "Exported data for an unknown namespace.";
143+
144+
const [, namespace, timestamp] = match;
145+
if (!namespace) {
146+
return "Exported data for an unknown namespace.";
147+
}
148+
149+
if (!timestamp) {
150+
return `Export from ${namespace}.`;
151+
}
152+
153+
return `Export from ${namespace} done on ${new Date(parseInt(timestamp)).toLocaleString()}`;
154+
}
155+
}

src/resources/resources.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ConfigResource } from "./common/config.js";
22
import { DebugResource } from "./common/debug.js";
3-
import { ExportedData } from "./common/exported-data.js";
3+
import { ExportedData } from "./common/exportedData.js";
44

55
export const Resources = [ConfigResource, DebugResource, ExportedData] as const;

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class Server {
111111
}
112112

113113
async close(): Promise<void> {
114-
this.exportsManager.close();
114+
await this.exportsManager.close();
115115
await this.telemetry.close();
116116
await this.session.close();
117117
await this.mcpServer.close();

tests/integration/helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,7 @@ function validateToolAnnotations(tool: ToolInfo, name: string, description: stri
267267
expect(tool.annotations.destructiveHint).toBe(false);
268268
}
269269
}
270+
271+
export function timeout(ms: number) {
272+
return new Promise((resolve) => setTimeout(resolve, ms));
273+
}

0 commit comments

Comments
 (0)