Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
},
"optionalDependencies": {
"@mongodb-js-preview/atlas-local": "^0.0.0-preview.1",
"kerberos": "^2.2.2"
}
}
6 changes: 6 additions & 0 deletions src/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { ErrorCodes, MongoDBError } from "./errors.js";
import type { ExportsManager } from "./exportsManager.js";
import type { Client } from "@mongodb-js-preview/atlas-local";
import type { Keychain } from "./keychain.js";

export interface SessionOptions {
Expand Down Expand Up @@ -46,6 +47,7 @@ export class Session extends EventEmitter<SessionEvents> {
version?: string;
title?: string;
};
atlasLocalClient?: Client;

public logger: CompositeLogger;

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

setAtlasLocalClient(atlasLocalClient: Client): void {
this.atlasLocalClient = atlasLocalClient;
}

async disconnect(): Promise<void> {
const atlasCluster = this.connectedAtlasCluster;

Expand Down
40 changes: 38 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
UnsubscribeRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import assert from "assert";
import type { ToolBase } from "./tools/tool.js";
import type { ToolBase, ToolConstructor } from "./tools/tool.js";
import { validateConnectionString } from "./helpers/connectionOptions.js";
import { packageInfo } from "./common/packageInfo.js";
import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js";
Expand Down Expand Up @@ -69,6 +69,9 @@ export class Server {
// TODO: Eventually we might want to make tools reactive too instead of relying on custom logic.
this.registerTools();

// Atlas Local tools are optional and require async initialization
void this.registerAtlasLocalTools();

// This is a workaround for an issue we've seen with some models, where they'll see that everything in the `arguments`
// object is optional, and then not pass it at all. However, the MCP server expects the `arguments` object to be if
// the tool accepts any arguments, even if they're all optional.
Expand Down Expand Up @@ -197,8 +200,41 @@ export class Server {
this.telemetry.emitEvents([event]).catch(() => {});
}

private async registerAtlasLocalTools(): Promise<void> {
try {
// Import Atlas Local client asyncronously
// This will fail on unsupported platforms
const { Client: AtlasLocalClient } = await import("@mongodb-js-preview/atlas-local");

// Connect to Atlas Local client
// This will fail if docker is not running
const client = AtlasLocalClient.connect();

// Set Atlas Local client
this.session.setAtlasLocalClient(client);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for streamable http, we'll have multiple sessions that will connect to the server and manage the local atlas env, do we think this is the best place to add set client or should we set it somewhere else? if you think we need a single client, you could check the transport file base.ts and how we inject DeviceId (long-running operation that blocks other calls) or Telemetry

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is ok to have multiple instances of the client, it should not cause any issues


// Register Atlas Local tools
this.registerToolInstances(AtlasLocalTools);
} catch (error) {
// If Atlas Local tools are disabled, don't log an error
if (this.userConfig.disabledTools.includes("atlas-local")) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be done even before the import at line 207? Why do we want to attempt this if the tools are disabled? Or am I missing something?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I missed this. Updated

return;
}

console.warn(
"Failed to initialize Atlas Local client, atlas-local tools will be disabled (error: ",
error,
")"
);
}
}

private registerTools(): void {
for (const toolConstructor of [...AtlasTools, ...AtlasLocalTools, ...MongoDbTools]) {
this.registerToolInstances([...AtlasTools, ...MongoDbTools]);
}

private registerToolInstances(tools: Array<ToolConstructor>): void {
for (const toolConstructor of tools) {
const tool = new toolConstructor(this.session, this.userConfig, this.telemetry);
if (tool.register(this)) {
this.tools.push(tool);
Expand Down
32 changes: 31 additions & 1 deletion src/tools/atlasLocal/atlasLocalTool.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { ToolArgs, ToolCategory } from "../tool.js";
import type { TelemetryToolMetadata, ToolArgs, ToolCategory } from "../tool.js";
import { ToolBase } from "../tool.js";
import type { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Client } from "@mongodb-js-preview/atlas-local";

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

protected verifyAllowed(): boolean {
return this.session.atlasLocalClient !== undefined && super.verifyAllowed();
}

protected async execute(): Promise<CallToolResult> {
// Get the client
const client = this.session.atlasLocalClient;

// If the client is not found, throw an error
// This should never happen, because the tool should have been disabled.
// verifyAllowed in the base class returns false if the client is not found
if (!client) {
throw new Error("Atlas Local client not found, tool should have been disabled.");
}

return this.executeWithAtlasLocalClient(client);
}

protected abstract executeWithAtlasLocalClient(client: Client): Promise<CallToolResult>;

protected handleError(
error: unknown,
args: ToolArgs<typeof this.argsShape>
Expand All @@ -14,4 +36,12 @@ export abstract class AtlasLocalToolBase extends ToolBase {
// For other types of errors, use the default error handling from the base class
return super.handleError(error, args);
}

protected resolveTelemetryMetadata(
...args: Parameters<ToolCallback<typeof this.argsShape>>
): TelemetryToolMetadata {
// TODO: include deployment id in the metadata where possible
void args; // this shuts up the eslint rule until we implement the TODO above
return {};
}
}
46 changes: 46 additions & 0 deletions src/tools/atlasLocal/read/listDeployments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AtlasLocalToolBase } from "../atlasLocalTool.js";
import type { OperationType } from "../../tool.js";
import { formatUntrustedData } from "../../tool.js";
import type { Deployment } from "@mongodb-js-preview/atlas-local";
import type { Client } from "@mongodb-js-preview/atlas-local";

export class ListDeploymentsTool extends AtlasLocalToolBase {
public name = "atlas-local-list-deployments";
protected description = "List MongoDB Atlas local deployments";
public operationType: OperationType = "read";
protected argsShape = {};

protected async executeWithAtlasLocalClient(client: Client): Promise<CallToolResult> {
// List the deployments
const deployments = await client.listDeployments();

// Format the deployments
return this.formatDeploymentsTable(deployments);
}

private formatDeploymentsTable(deployments: Deployment[]): CallToolResult {
// Check if deployments are absent
if (!deployments?.length) {
return {
content: [{ type: "text", text: "No deployments found." }],
};
}

// Turn the deployments into a markdown table
const rows = deployments
.map((deployment) => {
return `${deployment.name || "Unknown"} | ${deployment.state} | ${deployment.mongodbVersion}`;
})
.join("\n");

return {
content: formatUntrustedData(
`Found ${deployments.length} deployments:`,
`Deployment Name | State | MongoDB Version
----------------|----------------|----------------
${rows}`
),
};
}
}
4 changes: 3 additions & 1 deletion src/tools/atlasLocal/tools.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const AtlasLocalTools = [];
import { ListDeploymentsTool } from "./read/listDeployments.js";

export const AtlasLocalTools = [ListDeploymentsTool];
2 changes: 2 additions & 0 deletions src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type TelemetryToolMetadata = {
orgId?: string;
};

export type ToolConstructor = new (session: Session, config: UserConfig, telemetry: Telemetry) => ToolBase;

export abstract class ToolBase {
public abstract name: string;

Expand Down
4 changes: 3 additions & 1 deletion tests/integration/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ describe("Server integration test", () => {
expectDefined(tools);
expect(tools.tools.length).toBeGreaterThan(0);

const atlasTools = tools.tools.filter((tool) => tool.name.startsWith("atlas-"));
const atlasTools = tools.tools.filter(
(tool) => tool.name.startsWith("atlas-") && !tool.name.startsWith("atlas-local-")
);
expect(atlasTools.length).toBeLessThanOrEqual(0);
});
},
Expand Down
63 changes: 63 additions & 0 deletions tests/integration/tools/atlas-local/listDeployments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
defaultDriverOptions,
defaultTestConfig,
expectDefined,
getResponseElements,
setupIntegrationTest,
} from "../../helpers.js";
import { describe, expect, it } from "vitest";

const isMacOSInGitHubActions = process.platform === "darwin" && process.env.GITHUB_ACTIONS === "true";

// Docker is not available on macOS in GitHub Actions
// That's why we skip the tests on macOS in GitHub Actions
describe("atlas-local-list-deployments", () => {
const integration = setupIntegrationTest(
() => defaultTestConfig,
() => defaultDriverOptions
);

it.skipIf(isMacOSInGitHubActions)("should have the atlas-local-list-deployments tool", async () => {
const { tools } = await integration.mcpClient().listTools();
const listDeployments = tools.find((tool) => tool.name === "atlas-local-list-deployments");
expectDefined(listDeployments);
});

it.skipIf(!isMacOSInGitHubActions)(
"[MacOS in GitHub Actions] should not have the atlas-local-list-deployments tool",
async () => {
const { tools } = await integration.mcpClient().listTools();
const listDeployments = tools.find((tool) => tool.name === "atlas-local-list-deployments");
expect(listDeployments).toBeUndefined();
}
);

it.skipIf(isMacOSInGitHubActions)("should have correct metadata", async () => {
const { tools } = await integration.mcpClient().listTools();
const listDeployments = tools.find((tool) => tool.name === "atlas-local-list-deployments");
expectDefined(listDeployments);
expect(listDeployments.inputSchema.type).toBe("object");
expectDefined(listDeployments.inputSchema.properties);
expect(listDeployments.inputSchema.properties).toEqual({});
});

it.skipIf(isMacOSInGitHubActions)("should not crash when calling the tool", async () => {
const response = await integration.mcpClient().callTool({
name: "atlas-local-list-deployments",
arguments: {},
});
const elements = getResponseElements(response.content);
expect(elements.length).toBeGreaterThanOrEqual(1);

if (elements.length === 1) {
expect(elements[0]?.text).toContain("No deployments found.");
}

if (elements.length > 1) {
expect(elements[0]?.text).toMatch(/Found \d+ deployments/);
expect(elements[1]?.text).toContain(
"Deployment Name | State | MongoDB Version\n----------------|----------------|----------------\n"
);
}
});
});
2 changes: 1 addition & 1 deletion tests/integration/transports/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describeWithMongoDB("StdioRunner", (integration) => {
beforeAll(async () => {
transport = new StdioClientTransport({
command: "node",
args: ["dist/index.js"],
args: ["dist/index.js", "--disabledTools", "atlas-local"],
env: {
MDB_MCP_TRANSPORT: "stdio",
MDB_MCP_CONNECTION_STRING: integration.connectionString(),
Expand Down
Loading