mongodb-mcp-server 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.dockerignore +11 -0
  2. package/.github/workflows/check-pr-title.yml +29 -0
  3. package/.github/workflows/docker.yaml +57 -0
  4. package/.github/workflows/stale.yml +32 -0
  5. package/.smithery/Dockerfile +30 -0
  6. package/.smithery/smithery.yaml +63 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/Dockerfile +10 -0
  9. package/README.md +135 -14
  10. package/dist/common/atlas/apiClient.js +10 -1
  11. package/dist/common/atlas/apiClient.js.map +1 -1
  12. package/dist/common/atlas/cluster.js +1 -1
  13. package/dist/common/atlas/cluster.js.map +1 -1
  14. package/dist/index.js +17 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/logger.js +5 -0
  17. package/dist/logger.js.map +1 -1
  18. package/dist/server.js +1 -1
  19. package/dist/server.js.map +1 -1
  20. package/dist/telemetry/telemetry.js +115 -78
  21. package/dist/telemetry/telemetry.js.map +1 -1
  22. package/dist/tools/atlas/create/createProject.js +5 -1
  23. package/dist/tools/atlas/create/createProject.js.map +1 -1
  24. package/dist/tools/atlas/read/listAlerts.js +41 -0
  25. package/dist/tools/atlas/read/listAlerts.js.map +1 -0
  26. package/dist/tools/atlas/read/listProjects.js +3 -1
  27. package/dist/tools/atlas/read/listProjects.js.map +1 -1
  28. package/dist/tools/atlas/tools.js +2 -0
  29. package/dist/tools/atlas/tools.js.map +1 -1
  30. package/dist/tools/mongodb/metadata/listDatabases.js.map +1 -1
  31. package/dist/tools/mongodb/read/count.js +2 -2
  32. package/dist/tools/mongodb/read/count.js.map +1 -1
  33. package/dist/tools/tool.js +38 -6
  34. package/dist/tools/tool.js.map +1 -1
  35. package/package.json +8 -8
  36. package/scripts/apply.ts +4 -4
  37. package/scripts/filter.ts +1 -0
  38. package/src/common/atlas/apiClient.ts +11 -1
  39. package/src/common/atlas/cluster.ts +1 -2
  40. package/src/common/atlas/openapi.d.ts +1242 -28
  41. package/src/index.ts +20 -0
  42. package/src/logger.ts +6 -0
  43. package/src/server.ts +1 -1
  44. package/src/telemetry/telemetry.ts +150 -98
  45. package/src/telemetry/types.ts +1 -0
  46. package/src/tools/atlas/create/createProject.ts +7 -1
  47. package/src/tools/atlas/read/listAlerts.ts +45 -0
  48. package/src/tools/atlas/read/listProjects.ts +4 -2
  49. package/src/tools/atlas/tools.ts +2 -0
  50. package/src/tools/mongodb/metadata/listDatabases.ts +0 -1
  51. package/src/tools/mongodb/read/count.ts +3 -2
  52. package/src/tools/tool.ts +45 -8
  53. package/tests/integration/helpers.ts +23 -0
  54. package/tests/integration/tools/atlas/accessLists.test.ts +2 -2
  55. package/tests/integration/tools/atlas/alerts.test.ts +42 -0
  56. package/tests/integration/tools/atlas/atlasHelpers.ts +5 -3
  57. package/tests/integration/tools/atlas/clusters.test.ts +4 -4
  58. package/tests/integration/tools/atlas/dbUsers.test.ts +7 -7
  59. package/tests/integration/tools/atlas/orgs.test.ts +2 -2
  60. package/tests/integration/tools/atlas/projects.test.ts +3 -3
  61. package/tests/integration/tools/mongodb/create/createCollection.test.ts +2 -2
  62. package/tests/integration/tools/mongodb/create/createIndex.test.ts +2 -2
  63. package/tests/integration/tools/mongodb/create/insertMany.test.ts +1 -1
  64. package/tests/integration/tools/mongodb/delete/dropCollection.test.ts +1 -1
  65. package/tests/integration/tools/mongodb/metadata/collectionSchema.test.ts +2 -2
  66. package/tests/integration/tools/mongodb/metadata/dbStats.test.ts +4 -4
  67. package/tests/integration/tools/mongodb/metadata/explain.test.ts +10 -10
  68. package/tests/integration/tools/mongodb/metadata/listCollections.test.ts +1 -1
  69. package/tests/integration/tools/mongodb/metadata/listDatabases.test.ts +9 -5
  70. package/tests/integration/tools/mongodb/metadata/logs.test.ts +4 -4
  71. package/tests/integration/tools/mongodb/read/aggregate.test.ts +22 -7
  72. package/tests/integration/tools/mongodb/read/collectionIndexes.test.ts +5 -5
  73. package/tests/integration/tools/mongodb/read/count.test.ts +15 -10
  74. package/tests/integration/tools/mongodb/read/find.test.ts +6 -6
  75. package/tests/integration/tools/mongodb/update/renameCollection.test.ts +4 -4
  76. package/tests/unit/EJsonTransport.test.ts +1 -1
  77. package/tests/unit/session.test.ts +1 -1
  78. package/tests/unit/telemetry.test.ts +106 -58
  79. package/tsconfig.build.json +1 -0
  80. package/tests/integration/telemetry.test.ts +0 -28
package/src/index.ts CHANGED
@@ -31,6 +31,26 @@ try {
31
31
 
32
32
  const transport = createEJsonTransport();
33
33
 
34
+ const shutdown = () => {
35
+ logger.info(LogId.serverCloseRequested, "server", `Server close requested`);
36
+
37
+ server
38
+ .close()
39
+ .then(() => {
40
+ logger.info(LogId.serverClosed, "server", `Server closed successfully`);
41
+ process.exit(0);
42
+ })
43
+ .catch((err: unknown) => {
44
+ const error = err instanceof Error ? err : new Error(String(err));
45
+ logger.error(LogId.serverCloseFailure, "server", `Error closing server: ${error.message}`);
46
+ process.exit(1);
47
+ });
48
+ };
49
+
50
+ process.once("SIGINT", shutdown);
51
+ process.once("SIGTERM", shutdown);
52
+ process.once("SIGQUIT", shutdown);
53
+
34
54
  await server.connect(transport);
35
55
  } catch (error: unknown) {
36
56
  logger.emergency(LogId.serverStartFailure, "server", `Fatal error running server: ${error as string}`);
package/src/logger.ts CHANGED
@@ -9,6 +9,9 @@ export type LogLevel = LoggingMessageNotification["params"]["level"];
9
9
  export const LogId = {
10
10
  serverStartFailure: mongoLogId(1_000_001),
11
11
  serverInitialized: mongoLogId(1_000_002),
12
+ serverCloseRequested: mongoLogId(1_000_003),
13
+ serverClosed: mongoLogId(1_000_004),
14
+ serverCloseFailure: mongoLogId(1_000_005),
12
15
 
13
16
  atlasCheckCredentials: mongoLogId(1_001_001),
14
17
  atlasDeleteDatabaseUserFailure: mongoLogId(1_001_002),
@@ -22,6 +25,7 @@ export const LogId = {
22
25
  telemetryMetadataError: mongoLogId(1_002_005),
23
26
  telemetryDeviceIdFailure: mongoLogId(1_002_006),
24
27
  telemetryDeviceIdTimeout: mongoLogId(1_002_007),
28
+ telemetryContainerEnvFailure: mongoLogId(1_002_008),
25
29
 
26
30
  toolExecute: mongoLogId(1_003_001),
27
31
  toolExecuteFailure: mongoLogId(1_003_002),
@@ -29,6 +33,8 @@ export const LogId = {
29
33
 
30
34
  mongodbConnectFailure: mongoLogId(1_004_001),
31
35
  mongodbDisconnectFailure: mongoLogId(1_004_002),
36
+
37
+ toolUpdateFailure: mongoLogId(1_005_001),
32
38
  } as const;
33
39
 
34
40
  abstract class LoggerBase {
package/src/server.ts CHANGED
@@ -130,7 +130,7 @@ export class Server {
130
130
  }
131
131
  }
132
132
 
133
- this.telemetry.emitEvents([event]).catch(() => {});
133
+ this.telemetry.emitEvents([event]);
134
134
  }
135
135
 
136
136
  private registerTools() {
@@ -7,114 +7,152 @@ import { MACHINE_METADATA } from "./constants.js";
7
7
  import { EventCache } from "./eventCache.js";
8
8
  import nodeMachineId from "node-machine-id";
9
9
  import { getDeviceId } from "@mongodb-js/device-id";
10
+ import fs from "fs/promises";
11
+
12
+ async function fileExists(filePath: string): Promise<boolean> {
13
+ try {
14
+ await fs.access(filePath, fs.constants.F_OK);
15
+ return true; // File exists
16
+ } catch (e: unknown) {
17
+ if (
18
+ e instanceof Error &&
19
+ (
20
+ e as Error & {
21
+ code: string;
22
+ }
23
+ ).code === "ENOENT"
24
+ ) {
25
+ return false; // File does not exist
26
+ }
27
+ throw e; // Re-throw unexpected errors
28
+ }
29
+ }
10
30
 
11
- type EventResult = {
12
- success: boolean;
13
- error?: Error;
14
- };
31
+ async function isContainerized(): Promise<boolean> {
32
+ if (process.env.container) {
33
+ return true;
34
+ }
35
+
36
+ const exists = await Promise.all(["/.dockerenv", "/run/.containerenv", "/var/run/.containerenv"].map(fileExists));
15
37
 
16
- export const DEVICE_ID_TIMEOUT = 3000;
38
+ return exists.includes(true);
39
+ }
17
40
 
18
41
  export class Telemetry {
19
- private isBufferingEvents: boolean = true;
20
- /** Resolves when the device ID is retrieved or timeout occurs */
21
- public deviceIdPromise: Promise<string> | undefined;
22
42
  private deviceIdAbortController = new AbortController();
23
43
  private eventCache: EventCache;
24
44
  private getRawMachineId: () => Promise<string>;
45
+ private getContainerEnv: () => Promise<boolean>;
46
+ private cachedCommonProperties?: CommonProperties;
47
+ private flushing: boolean = false;
25
48
 
26
49
  private constructor(
27
50
  private readonly session: Session,
28
51
  private readonly userConfig: UserConfig,
29
- private readonly commonProperties: CommonProperties,
30
- { eventCache, getRawMachineId }: { eventCache: EventCache; getRawMachineId: () => Promise<string> }
52
+ {
53
+ eventCache,
54
+ getRawMachineId,
55
+ getContainerEnv,
56
+ }: {
57
+ eventCache: EventCache;
58
+ getRawMachineId: () => Promise<string>;
59
+ getContainerEnv: () => Promise<boolean>;
60
+ }
31
61
  ) {
32
62
  this.eventCache = eventCache;
33
63
  this.getRawMachineId = getRawMachineId;
64
+ this.getContainerEnv = getContainerEnv;
34
65
  }
35
66
 
36
67
  static create(
37
68
  session: Session,
38
69
  userConfig: UserConfig,
39
70
  {
40
- commonProperties = { ...MACHINE_METADATA },
41
71
  eventCache = EventCache.getInstance(),
42
72
  getRawMachineId = () => nodeMachineId.machineId(true),
73
+ getContainerEnv = isContainerized,
43
74
  }: {
44
75
  eventCache?: EventCache;
45
76
  getRawMachineId?: () => Promise<string>;
46
- commonProperties?: CommonProperties;
77
+ getContainerEnv?: () => Promise<boolean>;
47
78
  } = {}
48
79
  ): Telemetry {
49
- const instance = new Telemetry(session, userConfig, commonProperties, { eventCache, getRawMachineId });
50
-
51
- void instance.start();
52
- return instance;
53
- }
54
-
55
- private async start(): Promise<void> {
56
- if (!this.isTelemetryEnabled()) {
57
- return;
58
- }
59
- this.deviceIdPromise = getDeviceId({
60
- getMachineId: () => this.getRawMachineId(),
61
- onError: (reason, error) => {
62
- switch (reason) {
63
- case "resolutionError":
64
- logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error));
65
- break;
66
- case "timeout":
67
- logger.debug(LogId.telemetryDeviceIdTimeout, "telemetry", "Device ID retrieval timed out");
68
- break;
69
- case "abort":
70
- // No need to log in the case of aborts
71
- break;
72
- }
73
- },
74
- abortSignal: this.deviceIdAbortController.signal,
80
+ const instance = new Telemetry(session, userConfig, {
81
+ eventCache,
82
+ getRawMachineId,
83
+ getContainerEnv,
75
84
  });
76
85
 
77
- this.commonProperties.device_id = await this.deviceIdPromise;
78
-
79
- this.isBufferingEvents = false;
86
+ return instance;
80
87
  }
81
88
 
82
89
  public async close(): Promise<void> {
83
90
  this.deviceIdAbortController.abort();
84
- this.isBufferingEvents = false;
85
- await this.emitEvents(this.eventCache.getEvents());
91
+ await this.flush();
86
92
  }
87
93
 
88
94
  /**
89
95
  * Emits events through the telemetry pipeline
90
96
  * @param events - The events to emit
91
97
  */
92
- public async emitEvents(events: BaseEvent[]): Promise<void> {
93
- try {
94
- if (!this.isTelemetryEnabled()) {
95
- logger.info(LogId.telemetryEmitFailure, "telemetry", `Telemetry is disabled.`);
96
- return;
97
- }
98
-
99
- await this.emit(events);
100
- } catch {
101
- logger.debug(LogId.telemetryEmitFailure, "telemetry", `Error emitting telemetry events.`);
102
- }
98
+ public emitEvents(events: BaseEvent[]): void {
99
+ void this.flush(events);
103
100
  }
104
101
 
105
102
  /**
106
103
  * Gets the common properties for events
107
104
  * @returns Object containing common properties for all events
108
105
  */
109
- public getCommonProperties(): CommonProperties {
110
- return {
111
- ...this.commonProperties,
112
- mcp_client_version: this.session.agentRunner?.version,
113
- mcp_client_name: this.session.agentRunner?.name,
114
- session_id: this.session.sessionId,
115
- config_atlas_auth: this.session.apiClient.hasCredentials() ? "true" : "false",
116
- config_connection_string: this.userConfig.connectionString ? "true" : "false",
117
- };
106
+ private async getCommonProperties(): Promise<CommonProperties> {
107
+ if (!this.cachedCommonProperties) {
108
+ let deviceId: string | undefined;
109
+ let containerEnv: boolean | undefined;
110
+ try {
111
+ await Promise.all([
112
+ getDeviceId({
113
+ getMachineId: () => this.getRawMachineId(),
114
+ onError: (reason, error) => {
115
+ switch (reason) {
116
+ case "resolutionError":
117
+ logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error));
118
+ break;
119
+ case "timeout":
120
+ logger.debug(
121
+ LogId.telemetryDeviceIdTimeout,
122
+ "telemetry",
123
+ "Device ID retrieval timed out"
124
+ );
125
+ break;
126
+ case "abort":
127
+ // No need to log in the case of aborts
128
+ break;
129
+ }
130
+ },
131
+ abortSignal: this.deviceIdAbortController.signal,
132
+ }).then((id) => {
133
+ deviceId = id;
134
+ }),
135
+ this.getContainerEnv().then((env) => {
136
+ containerEnv = env;
137
+ }),
138
+ ]);
139
+ } catch (error: unknown) {
140
+ const err = error instanceof Error ? error : new Error(String(error));
141
+ logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", err.message);
142
+ }
143
+ this.cachedCommonProperties = {
144
+ ...MACHINE_METADATA,
145
+ mcp_client_version: this.session.agentRunner?.version,
146
+ mcp_client_name: this.session.agentRunner?.name,
147
+ session_id: this.session.sessionId,
148
+ config_atlas_auth: this.session.apiClient.hasCredentials() ? "true" : "false",
149
+ config_connection_string: this.userConfig.connectionString ? "true" : "false",
150
+ is_container_env: containerEnv ? "true" : "false",
151
+ device_id: deviceId,
152
+ };
153
+ }
154
+
155
+ return this.cachedCommonProperties;
118
156
  }
119
157
 
120
158
  /**
@@ -135,60 +173,74 @@ export class Telemetry {
135
173
  }
136
174
 
137
175
  /**
138
- * Attempts to emit events through authenticated and unauthenticated clients
176
+ * Attempts to flush events through authenticated and unauthenticated clients
139
177
  * Falls back to caching if both attempts fail
140
178
  */
141
- private async emit(events: BaseEvent[]): Promise<void> {
142
- if (this.isBufferingEvents) {
143
- this.eventCache.appendEvents(events);
179
+ public async flush(events?: BaseEvent[]): Promise<void> {
180
+ if (!this.isTelemetryEnabled()) {
181
+ logger.info(LogId.telemetryEmitFailure, "telemetry", `Telemetry is disabled.`);
144
182
  return;
145
183
  }
146
184
 
147
- const cachedEvents = this.eventCache.getEvents();
148
- const allEvents = [...cachedEvents, ...events];
185
+ if (this.flushing) {
186
+ this.eventCache.appendEvents(events ?? []);
187
+ process.nextTick(async () => {
188
+ // try again if in the middle of a flush
189
+ await this.flush();
190
+ });
191
+ return;
192
+ }
149
193
 
150
- logger.debug(
151
- LogId.telemetryEmitStart,
152
- "telemetry",
153
- `Attempting to send ${allEvents.length} events (${cachedEvents.length} cached)`
154
- );
194
+ this.flushing = true;
155
195
 
156
- const result = await this.sendEvents(this.session.apiClient, allEvents);
157
- if (result.success) {
196
+ try {
197
+ const cachedEvents = this.eventCache.getEvents();
198
+ const allEvents = [...cachedEvents, ...(events ?? [])];
199
+ if (allEvents.length <= 0) {
200
+ this.flushing = false;
201
+ return;
202
+ }
203
+
204
+ logger.debug(
205
+ LogId.telemetryEmitStart,
206
+ "telemetry",
207
+ `Attempting to send ${allEvents.length} events (${cachedEvents.length} cached)`
208
+ );
209
+
210
+ await this.sendEvents(this.session.apiClient, allEvents);
158
211
  this.eventCache.clearEvents();
159
212
  logger.debug(
160
213
  LogId.telemetryEmitSuccess,
161
214
  "telemetry",
162
215
  `Sent ${allEvents.length} events successfully: ${JSON.stringify(allEvents, null, 2)}`
163
216
  );
164
- return;
217
+ } catch (error: unknown) {
218
+ logger.debug(
219
+ LogId.telemetryEmitFailure,
220
+ "telemetry",
221
+ `Error sending event to client: ${error instanceof Error ? error.message : String(error)}`
222
+ );
223
+ this.eventCache.appendEvents(events ?? []);
224
+ process.nextTick(async () => {
225
+ // try again
226
+ await this.flush();
227
+ });
165
228
  }
166
229
 
167
- logger.debug(
168
- LogId.telemetryEmitFailure,
169
- "telemetry",
170
- `Error sending event to client: ${result.error instanceof Error ? result.error.message : String(result.error)}`
171
- );
172
- this.eventCache.appendEvents(events);
230
+ this.flushing = false;
173
231
  }
174
232
 
175
233
  /**
176
234
  * Attempts to send events through the provided API client
177
235
  */
178
- private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise<EventResult> {
179
- try {
180
- await client.sendEvents(
181
- events.map((event) => ({
182
- ...event,
183
- properties: { ...this.getCommonProperties(), ...event.properties },
184
- }))
185
- );
186
- return { success: true };
187
- } catch (error) {
188
- return {
189
- success: false,
190
- error: error instanceof Error ? error : new Error(String(error)),
191
- };
192
- }
236
+ private async sendEvents(client: ApiClient, events: BaseEvent[]): Promise<void> {
237
+ const commonProperties = await this.getCommonProperties();
238
+
239
+ await client.sendEvents(
240
+ events.map((event) => ({
241
+ ...event,
242
+ properties: { ...commonProperties, ...event.properties },
243
+ }))
244
+ );
193
245
  }
194
246
  }
@@ -71,4 +71,5 @@ export type CommonProperties = {
71
71
  config_atlas_auth?: TelemetryBoolSet;
72
72
  config_connection_string?: TelemetryBoolSet;
73
73
  session_id?: string;
74
+ is_container_env?: TelemetryBoolSet;
74
75
  } & CommonStaticProperties;
@@ -28,7 +28,13 @@ export class CreateProjectTool extends AtlasToolBase {
28
28
  "No organizations were found in your MongoDB Atlas account. Please create an organization first."
29
29
  );
30
30
  }
31
- organizationId = organizations.results[0].id;
31
+ const firstOrg = organizations.results[0];
32
+ if (!firstOrg?.id) {
33
+ throw new Error(
34
+ "The first organization found does not have an ID. Please check your Atlas account."
35
+ );
36
+ }
37
+ organizationId = firstOrg.id;
32
38
  assumedOrg = true;
33
39
  } catch {
34
40
  throw new Error(
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { AtlasToolBase } from "../atlasTool.js";
4
+ import { ToolArgs, OperationType } from "../../tool.js";
5
+
6
+ export class ListAlertsTool extends AtlasToolBase {
7
+ protected name = "atlas-list-alerts";
8
+ protected description = "List MongoDB Atlas alerts";
9
+ protected operationType: OperationType = "read";
10
+ protected argsShape = {
11
+ projectId: z.string().describe("Atlas project ID to list alerts for"),
12
+ };
13
+
14
+ protected async execute({ projectId }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
15
+ const data = await this.session.apiClient.listAlerts({
16
+ params: {
17
+ path: {
18
+ groupId: projectId,
19
+ },
20
+ },
21
+ });
22
+
23
+ if (!data?.results?.length) {
24
+ return { content: [{ type: "text", text: "No alerts found in your MongoDB Atlas project." }] };
25
+ }
26
+
27
+ // Format alerts as a table
28
+ const output =
29
+ `Alert ID | Status | Created | Updated | Type | Comment
30
+ ----------|---------|----------|----------|------|--------
31
+ ` +
32
+ data.results
33
+ .map((alert) => {
34
+ const created = alert.created ? new Date(alert.created).toLocaleString() : "N/A";
35
+ const updated = alert.updated ? new Date(alert.updated).toLocaleString() : "N/A";
36
+ const comment = alert.acknowledgementComment ?? "N/A";
37
+ return `${alert.id} | ${alert.status} | ${created} | ${updated} | ${alert.eventTypeName} | ${comment}`;
38
+ })
39
+ .join("\n");
40
+
41
+ return {
42
+ content: [{ type: "text", text: output }],
43
+ };
44
+ }
45
+ }
@@ -21,7 +21,8 @@ export class ListProjectsTool extends AtlasToolBase {
21
21
 
22
22
  const orgs: Record<string, string> = orgData.results
23
23
  .map((org) => [org.id || "", org.name])
24
- .reduce((acc, [id, name]) => ({ ...acc, [id]: name }), {});
24
+ .filter(([id]) => id)
25
+ .reduce((acc, [id, name]) => ({ ...acc, [id as string]: name }), {});
25
26
 
26
27
  const data = orgId
27
28
  ? await this.session.apiClient.listOrganizationProjects({
@@ -41,7 +42,8 @@ export class ListProjectsTool extends AtlasToolBase {
41
42
  const rows = data.results
42
43
  .map((project) => {
43
44
  const createdAt = project.created ? new Date(project.created).toLocaleString() : "N/A";
44
- return `${project.name} | ${project.id} | ${orgs[project.orgId]} | ${project.orgId} | ${createdAt}`;
45
+ const orgName = orgs[project.orgId] ?? "N/A";
46
+ return `${project.name} | ${project.id} | ${orgName} | ${project.orgId} | ${createdAt}`;
45
47
  })
46
48
  .join("\n");
47
49
  const formattedProjects = `Project Name | Project ID | Organization Name | Organization ID | Created At
@@ -9,6 +9,7 @@ import { CreateDBUserTool } from "./create/createDBUser.js";
9
9
  import { CreateProjectTool } from "./create/createProject.js";
10
10
  import { ListOrganizationsTool } from "./read/listOrgs.js";
11
11
  import { ConnectClusterTool } from "./metadata/connectCluster.js";
12
+ import { ListAlertsTool } from "./read/listAlerts.js";
12
13
 
13
14
  export const AtlasTools = [
14
15
  ListClustersTool,
@@ -22,4 +23,5 @@ export const AtlasTools = [
22
23
  CreateProjectTool,
23
24
  ListOrganizationsTool,
24
25
  ConnectClusterTool,
26
+ ListAlertsTool,
25
27
  ];
@@ -7,7 +7,6 @@ export class ListDatabasesTool extends MongoDBToolBase {
7
7
  protected name = "list-databases";
8
8
  protected description = "List all databases for a MongoDB connection";
9
9
  protected argsShape = {};
10
-
11
10
  protected operationType: OperationType = "metadata";
12
11
 
13
12
  protected async execute(): Promise<CallToolResult> {
@@ -8,13 +8,14 @@ export const CountArgs = {
8
8
  .record(z.string(), z.unknown())
9
9
  .optional()
10
10
  .describe(
11
- "The query filter to count documents. Matches the syntax of the filter argument of db.collection.count()"
11
+ "A filter/query parameter. Allows users to filter the documents to count. Matches the syntax of the filter argument of db.collection.count()."
12
12
  ),
13
13
  };
14
14
 
15
15
  export class CountTool extends MongoDBToolBase {
16
16
  protected name = "count";
17
- protected description = "Gets the number of documents in a MongoDB collection";
17
+ protected description =
18
+ "Gets the number of documents in a MongoDB collection using db.collection.count() and query as an optional filter parameter";
18
19
  protected argsShape = {
19
20
  ...DbOperationArgs,
20
21
  ...CountArgs,
package/src/tools/tool.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z, type ZodRawShape, type ZodNever, AnyZodObject } from "zod";
2
2
  import type { McpServer, RegisteredTool, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import type { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { Session } from "../session.js";
5
5
  import logger, { LogId } from "../logger.js";
6
6
  import { Telemetry } from "../telemetry/telemetry.js";
@@ -27,6 +27,34 @@ export abstract class ToolBase {
27
27
 
28
28
  protected abstract argsShape: ZodRawShape;
29
29
 
30
+ protected get annotations(): ToolAnnotations {
31
+ const annotations: ToolAnnotations = {
32
+ title: this.name,
33
+ description: this.description,
34
+ };
35
+
36
+ switch (this.operationType) {
37
+ case "read":
38
+ case "metadata":
39
+ annotations.readOnlyHint = true;
40
+ annotations.destructiveHint = false;
41
+ break;
42
+ case "delete":
43
+ annotations.readOnlyHint = false;
44
+ annotations.destructiveHint = true;
45
+ break;
46
+ case "create":
47
+ case "update":
48
+ annotations.destructiveHint = false;
49
+ annotations.readOnlyHint = false;
50
+ break;
51
+ default:
52
+ break;
53
+ }
54
+
55
+ return annotations;
56
+ }
57
+
30
58
  protected abstract execute(...args: Parameters<ToolCallback<typeof this.argsShape>>): Promise<CallToolResult>;
31
59
 
32
60
  constructor(
@@ -43,20 +71,20 @@ export abstract class ToolBase {
43
71
  const callback: ToolCallback<typeof this.argsShape> = async (...args) => {
44
72
  const startTime = Date.now();
45
73
  try {
46
- logger.debug(LogId.toolExecute, "tool", `Executing ${this.name} with args: ${JSON.stringify(args)}`);
74
+ logger.debug(LogId.toolExecute, "tool", `Executing tool ${this.name}`);
47
75
 
48
76
  const result = await this.execute(...args);
49
- await this.emitToolEvent(startTime, result, ...args).catch(() => {});
77
+ this.emitToolEvent(startTime, result, ...args);
50
78
  return result;
51
79
  } catch (error: unknown) {
52
80
  logger.error(LogId.toolExecuteFailure, "tool", `Error executing ${this.name}: ${error as string}`);
53
81
  const toolResult = await this.handleError(error, args[0] as ToolArgs<typeof this.argsShape>);
54
- await this.emitToolEvent(startTime, toolResult, ...args).catch(() => {});
82
+ this.emitToolEvent(startTime, toolResult, ...args);
55
83
  return toolResult;
56
84
  }
57
85
  };
58
86
 
59
- server.tool(this.name, this.description, this.argsShape, callback);
87
+ server.tool(this.name, this.description, this.argsShape, this.annotations, callback);
60
88
 
61
89
  // This is very similar to RegisteredTool.update, but without the bugs around the name.
62
90
  // In the upstream update method, the name is captured in the closure and not updated when
@@ -66,13 +94,22 @@ export abstract class ToolBase {
66
94
  const tools = server["_registeredTools"] as { [toolName: string]: RegisteredTool };
67
95
  const existingTool = tools[this.name];
68
96
 
97
+ if (!existingTool) {
98
+ logger.warning(LogId.toolUpdateFailure, "tool", `Tool ${this.name} not found in update`);
99
+ return;
100
+ }
101
+
102
+ existingTool.annotations = this.annotations;
103
+
69
104
  if (updates.name && updates.name !== this.name) {
105
+ existingTool.annotations.title = updates.name;
70
106
  delete tools[this.name];
71
107
  this.name = updates.name;
72
108
  tools[this.name] = existingTool;
73
109
  }
74
110
 
75
111
  if (updates.description) {
112
+ existingTool.annotations.description = updates.description;
76
113
  existingTool.description = updates.description;
77
114
  this.description = updates.description;
78
115
  }
@@ -142,11 +179,11 @@ export abstract class ToolBase {
142
179
  * @param result - Whether the command succeeded or failed
143
180
  * @param args - The arguments passed to the tool
144
181
  */
145
- private async emitToolEvent(
182
+ private emitToolEvent(
146
183
  startTime: number,
147
184
  result: CallToolResult,
148
185
  ...args: Parameters<ToolCallback<typeof this.argsShape>>
149
- ): Promise<void> {
186
+ ): void {
150
187
  if (!this.telemetry.isTelemetryEnabled()) {
151
188
  return;
152
189
  }
@@ -172,6 +209,6 @@ export abstract class ToolBase {
172
209
  event.properties.project_id = metadata.projectId;
173
210
  }
174
211
 
175
- await this.telemetry.emitEvents([event]);
212
+ this.telemetry.emitEvents([event]);
176
213
  }
177
214
  }
@@ -206,6 +206,7 @@ export function validateToolMetadata(
206
206
  expectDefined(tool);
207
207
  expect(tool.description).toBe(description);
208
208
 
209
+ validateToolAnnotations(tool, name, description);
209
210
  const toolParameters = getParameters(tool);
210
211
  expect(toolParameters).toHaveLength(parameters.length);
211
212
  expect(toolParameters).toIncludeAllMembers(parameters);
@@ -240,3 +241,25 @@ export function expectDefined<T>(arg: T): asserts arg is Exclude<T, undefined |
240
241
  expect(arg).toBeDefined();
241
242
  expect(arg).not.toBeNull();
242
243
  }
244
+
245
+ function validateToolAnnotations(tool: ToolInfo, name: string, description: string): void {
246
+ expectDefined(tool.annotations);
247
+ expect(tool.annotations.title).toBe(name);
248
+ expect(tool.annotations.description).toBe(description);
249
+
250
+ switch (tool.operationType) {
251
+ case "read":
252
+ case "metadata":
253
+ expect(tool.annotations.readOnlyHint).toBe(true);
254
+ expect(tool.annotations.destructiveHint).toBe(false);
255
+ break;
256
+ case "delete":
257
+ expect(tool.annotations.readOnlyHint).toBe(false);
258
+ expect(tool.annotations.destructiveHint).toBe(true);
259
+ break;
260
+ case "create":
261
+ case "update":
262
+ expect(tool.annotations.readOnlyHint).toBe(false);
263
+ expect(tool.annotations.destructiveHint).toBe(false);
264
+ }
265
+ }