Skip to content

Commit 8a2db27

Browse files
feat(atlas-local): Added Atlas Local List Deployments tool (#520)
1 parent a27f2d4 commit 8a2db27

File tree

12 files changed

+346
-6
lines changed

12 files changed

+346
-6
lines changed

package-lock.json

Lines changed: 98 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
122122
},
123123
"optionalDependencies": {
124+
"@mongodb-js-preview/atlas-local": "^0.0.0-preview.1",
124125
"kerberos": "^2.2.2"
125126
}
126127
}

src/common/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
1616
import { ErrorCodes, MongoDBError } from "./errors.js";
1717
import type { ExportsManager } from "./exportsManager.js";
18+
import type { Client } from "@mongodb-js-preview/atlas-local";
1819
import type { Keychain } from "./keychain.js";
1920

2021
export interface SessionOptions {
@@ -46,6 +47,7 @@ export class Session extends EventEmitter<SessionEvents> {
4647
version?: string;
4748
title?: string;
4849
};
50+
atlasLocalClient?: Client;
4951

5052
public logger: CompositeLogger;
5153

@@ -99,6 +101,10 @@ export class Session extends EventEmitter<SessionEvents> {
99101
this.connectionManager.setClientName(this.mcpClient.name || "unknown");
100102
}
101103

104+
setAtlasLocalClient(atlasLocalClient: Client): void {
105+
this.atlasLocalClient = atlasLocalClient;
106+
}
107+
102108
async disconnect(): Promise<void> {
103109
const atlasCluster = this.connectedAtlasCluster;
104110

src/server.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
UnsubscribeRequestSchema,
2020
} from "@modelcontextprotocol/sdk/types.js";
2121
import assert from "assert";
22-
import type { ToolBase } from "./tools/tool.js";
22+
import type { ToolBase, ToolConstructor } from "./tools/tool.js";
2323
import { validateConnectionString } from "./helpers/connectionOptions.js";
2424
import { packageInfo } from "./common/packageInfo.js";
2525
import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js";
@@ -69,6 +69,9 @@ export class Server {
6969
// TODO: Eventually we might want to make tools reactive too instead of relying on custom logic.
7070
this.registerTools();
7171

72+
// Atlas Local tools are optional and require async initialization
73+
void this.registerAtlasLocalTools();
74+
7275
// This is a workaround for an issue we've seen with some models, where they'll see that everything in the `arguments`
7376
// object is optional, and then not pass it at all. However, the MCP server expects the `arguments` object to be if
7477
// the tool accepts any arguments, even if they're all optional.
@@ -197,8 +200,41 @@ export class Server {
197200
this.telemetry.emitEvents([event]).catch(() => {});
198201
}
199202

203+
private async registerAtlasLocalTools(): Promise<void> {
204+
// If Atlas Local tools are disabled, don't attempt to connect to the client
205+
if (this.userConfig.disabledTools.includes("atlas-local")) {
206+
return;
207+
}
208+
209+
try {
210+
// Import Atlas Local client asyncronously
211+
// This will fail on unsupported platforms
212+
const { Client: AtlasLocalClient } = await import("@mongodb-js-preview/atlas-local");
213+
214+
// Connect to Atlas Local client
215+
// This will fail if docker is not running
216+
const client = AtlasLocalClient.connect();
217+
218+
// Set Atlas Local client
219+
this.session.setAtlasLocalClient(client);
220+
221+
// Register Atlas Local tools
222+
this.registerToolInstances(AtlasLocalTools);
223+
} catch (error) {
224+
console.warn(
225+
"Failed to initialize Atlas Local client, atlas-local tools will be disabled (error: ",
226+
error,
227+
")"
228+
);
229+
}
230+
}
231+
200232
private registerTools(): void {
201-
for (const toolConstructor of [...AtlasTools, ...AtlasLocalTools, ...MongoDbTools]) {
233+
this.registerToolInstances([...AtlasTools, ...MongoDbTools]);
234+
}
235+
236+
private registerToolInstances(tools: Array<ToolConstructor>): void {
237+
for (const toolConstructor of tools) {
202238
const tool = new toolConstructor(this.session, this.userConfig, this.telemetry);
203239
if (tool.register(this)) {
204240
this.tools.push(tool);

src/tools/atlasLocal/atlasLocalTool.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,45 @@
11
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2-
import type { ToolArgs, ToolCategory } from "../tool.js";
2+
import type { TelemetryToolMetadata, ToolArgs, ToolCategory } from "../tool.js";
33
import { ToolBase } from "../tool.js";
4+
import type { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
5+
import type { Client } from "@mongodb-js-preview/atlas-local";
46

57
export abstract class AtlasLocalToolBase extends ToolBase {
68
public category: ToolCategory = "atlas-local";
79

10+
protected verifyAllowed(): boolean {
11+
return this.session.atlasLocalClient !== undefined && super.verifyAllowed();
12+
}
13+
14+
protected async execute(): Promise<CallToolResult> {
15+
// Get the client
16+
const client = this.session.atlasLocalClient;
17+
18+
// If the client is not found, throw an error
19+
// This should never happen:
20+
// - atlas-local tools are only added after the client is set
21+
// this means that if we were unable to get the client, the tool will not be registered
22+
// - in case the tool was registered by accident
23+
// verifyAllowed in the base class would still return false preventing the tool from being registered,
24+
// preventing the tool from being executed
25+
if (!client) {
26+
return {
27+
content: [
28+
{
29+
type: "text",
30+
text: `Something went wrong on our end, this tool should have been disabled but it was not.
31+
please log a ticket here: https://github.com/mongodb-js/mongodb-mcp-server/issues/new?template=bug_report.yml`,
32+
},
33+
],
34+
isError: true,
35+
};
36+
}
37+
38+
return this.executeWithAtlasLocalClient(client);
39+
}
40+
41+
protected abstract executeWithAtlasLocalClient(client: Client): Promise<CallToolResult>;
42+
843
protected handleError(
944
error: unknown,
1045
args: ToolArgs<typeof this.argsShape>
@@ -14,4 +49,12 @@ export abstract class AtlasLocalToolBase extends ToolBase {
1449
// For other types of errors, use the default error handling from the base class
1550
return super.handleError(error, args);
1651
}
52+
53+
protected resolveTelemetryMetadata(
54+
...args: Parameters<ToolCallback<typeof this.argsShape>>
55+
): TelemetryToolMetadata {
56+
// TODO: include deployment id in the metadata where possible
57+
void args; // this shuts up the eslint rule until we implement the TODO above
58+
return {};
59+
}
1760
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2+
import { AtlasLocalToolBase } from "../atlasLocalTool.js";
3+
import type { OperationType } from "../../tool.js";
4+
import { formatUntrustedData } from "../../tool.js";
5+
import type { Deployment } from "@mongodb-js-preview/atlas-local";
6+
import type { Client } from "@mongodb-js-preview/atlas-local";
7+
8+
export class ListDeploymentsTool extends AtlasLocalToolBase {
9+
public name = "atlas-local-list-deployments";
10+
protected description = "List MongoDB Atlas local deployments";
11+
public operationType: OperationType = "read";
12+
protected argsShape = {};
13+
14+
protected async executeWithAtlasLocalClient(client: Client): Promise<CallToolResult> {
15+
// List the deployments
16+
const deployments = await client.listDeployments();
17+
18+
// Format the deployments
19+
return this.formatDeploymentsTable(deployments);
20+
}
21+
22+
private formatDeploymentsTable(deployments: Deployment[]): CallToolResult {
23+
// Check if deployments are absent
24+
if (!deployments?.length) {
25+
return {
26+
content: [{ type: "text", text: "No deployments found." }],
27+
};
28+
}
29+
30+
// Turn the deployments into a markdown table
31+
const rows = deployments
32+
.map((deployment) => {
33+
return `${deployment.name || "Unknown"} | ${deployment.state} | ${deployment.mongodbVersion}`;
34+
})
35+
.join("\n");
36+
37+
return {
38+
content: formatUntrustedData(
39+
`Found ${deployments.length} deployments:`,
40+
`Deployment Name | State | MongoDB Version
41+
----------------|----------------|----------------
42+
${rows}`
43+
),
44+
};
45+
}
46+
}

src/tools/atlasLocal/tools.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export const AtlasLocalTools = [];
1+
import { ListDeploymentsTool } from "./read/listDeployments.js";
2+
3+
export const AtlasLocalTools = [ListDeploymentsTool];

src/tools/tool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type TelemetryToolMetadata = {
1818
orgId?: string;
1919
};
2020

21+
export type ToolConstructor = new (session: Session, config: UserConfig, telemetry: Telemetry) => ToolBase;
22+
2123
export abstract class ToolBase {
2224
public abstract name: string;
2325

tests/integration/helpers.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MCPConnectionManager } from "../../src/common/connectionManager.js";
1515
import { DeviceId } from "../../src/helpers/deviceId.js";
1616
import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js";
1717
import { Keychain } from "../../src/common/keychain.js";
18+
import type { Client as AtlasLocalClient } from "@mongodb-js-preview/atlas-local";
1819

1920
interface ParameterInfo {
2021
name: string;
@@ -345,6 +346,37 @@ export function waitUntil<T extends ConnectionState>(
345346
});
346347
}
347348

349+
export function waitUntilMcpClientIsSet(
350+
mcpServer: Server,
351+
signal: AbortSignal,
352+
timeout: number = 5000
353+
): Promise<AtlasLocalClient> {
354+
let ts: NodeJS.Timeout | undefined;
355+
356+
const timeoutSignal = AbortSignal.timeout(timeout);
357+
const combinedSignal = AbortSignal.any([signal, timeoutSignal]);
358+
359+
return new Promise<AtlasLocalClient>((resolve, reject) => {
360+
ts = setInterval(() => {
361+
if (combinedSignal.aborted) {
362+
return reject(new Error(`Aborted: ${combinedSignal.reason}`));
363+
}
364+
365+
// wait until session.client != undefined
366+
// do not wait more than 1 second, should take a few milliseconds at most
367+
// try every 50ms to see if the client is set, if it's not set after 1 second, throw an error
368+
const client = mcpServer.session.atlasLocalClient;
369+
if (client) {
370+
return resolve(client);
371+
}
372+
}, 100);
373+
}).finally(() => {
374+
if (ts !== undefined) {
375+
clearInterval(ts);
376+
}
377+
});
378+
}
379+
348380
export function getDataFromUntrustedContent(content: string): string {
349381
const regex = /^[ \t]*<untrusted-user-data-[0-9a-f\\-]*>(?<data>.*)^[ \t]*<\/untrusted-user-data-[0-9a-f\\-]*>/gms;
350382
const match = regex.exec(content);

tests/integration/server.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ describe("Server integration test", () => {
1111
expectDefined(tools);
1212
expect(tools.tools.length).toBeGreaterThan(0);
1313

14-
const atlasTools = tools.tools.filter((tool) => tool.name.startsWith("atlas-"));
14+
const atlasTools = tools.tools.filter(
15+
(tool) => tool.name.startsWith("atlas-") && !tool.name.startsWith("atlas-local-")
16+
);
1517
expect(atlasTools.length).toBeLessThanOrEqual(0);
1618
});
1719
},

0 commit comments

Comments
 (0)