Skip to content

[SKUNKWORKS - DO NOT MERGE]: Add Local Atlas tools #251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
233 changes: 191 additions & 42 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@jest/globals": "^29.7.0",
"@modelcontextprotocol/inspector": "^0.10.2",
"@redocly/cli": "^1.34.2",
"@types/dockerode": "^3.3.38",
"@types/jest": "^29.5.14",
"@types/node": "^22.14.0",
"@types/simple-oauth2": "^5.0.7",
Expand Down Expand Up @@ -65,6 +66,7 @@
"@mongodb-js/devtools-connect": "^3.7.2",
"@mongosh/service-provider-node-driver": "^3.6.0",
"bson": "^6.10.3",
"dockerode": "^4.0.6",
"lru-cache": "^11.1.0",
"mongodb": "^6.15.0",
"mongodb-connection-string-url": "^3.0.2",
Expand Down
88 changes: 88 additions & 0 deletions src/common/local/dockerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { promisify } from "util";
import { exec } from "child_process";
import { z } from "zod";
import { DockerPsSummary } from "../../tools/local/localTool.js";

export interface ClusterDetails {
name: string;
mongodbVersion: string;
status: string;
health: string;
port: string;
dbUser: string;
isAuth: boolean; // Indicates if authentication is required
}

export async function getValidatedClusterDetails(clusterName: string): Promise<ClusterDetails> {
const execAsync = promisify(exec);

try {
const { stdout } = await execAsync(`docker inspect ${clusterName}`);
const containerDetails = JSON.parse(stdout) as DockerPsSummary[];

if (!Array.isArray(containerDetails) || containerDetails.length === 0) {
throw new Error(`No details found for cluster "${clusterName}".`);
}

const DockerInspectSchema = z.object({
Config: z.object({
Env: z.array(z.string()).optional(),
Image: z.string(),
}),
NetworkSettings: z.object({
Ports: z.record(
z.string(),
z
.array(
z
.object({
HostPort: z.string(),
})
.optional()
)
.optional()
),
}),
State: z.object({
Health: z
.object({
Status: z.string(),
})
.optional(),
Status: z.string(),
}),
Name: z.string(),
});

const validatedDetails = DockerInspectSchema.parse(containerDetails[0]);

const port = validatedDetails.NetworkSettings.Ports["27017/tcp"]?.[0]?.HostPort || "Unknown";

const envVars = validatedDetails.Config.Env || [];
const username = envVars.find((env) => env.startsWith("MONGODB_INITDB_ROOT_USERNAME="))?.split("=")[1];

const isAuth = !!username; // Determine if authentication is required

const mongodbVersionMatch = validatedDetails.Config.Image.match(/mongodb\/mongodb-atlas-local:(.+)/);
const mongodbVersion = mongodbVersionMatch ? mongodbVersionMatch[1] : "Unknown";

const status = validatedDetails.State.Status || "Unknown";
const health = validatedDetails.State.Health?.Status || "Unknown";

return {
name: validatedDetails.Name.replace("/", ""),
mongodbVersion,
status,
health,
port,
dbUser: username || "No user found",
isAuth,
};
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to inspect cluster "${clusterName}": ${error.message}`);
} else {
throw new Error(`An unexpected error occurred while inspecting cluster "${clusterName}".`);
}
}
}
3 changes: 2 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Session } from "./session.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { AtlasTools } from "./tools/atlas/tools.js";
import { LocalTools } from "./tools/local/tools.js";
import { MongoDbTools } from "./tools/mongodb/tools.js";
import logger, { initializeLogger, LogId } from "./logger.js";
import { ObjectId } from "mongodb";
Expand Down Expand Up @@ -134,7 +135,7 @@ export class Server {
}

private registerTools() {
for (const tool of [...AtlasTools, ...MongoDbTools]) {
for (const tool of [...AtlasTools, ...LocalTools, ...MongoDbTools]) {
new tool(this.session, this.userConfig, this.telemetry).register(this.mcpServer);
}
}
Expand Down
96 changes: 96 additions & 0 deletions src/tools/local/create/createCluster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { exec } from "child_process";
import { promisify } from "util";
import * as net from "net";
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { LocalToolBase } from "../localTool.js";
import { ToolArgs, OperationType } from "../../tool.js";

export class CreateClusterTool extends LocalToolBase {
protected name = "local-create-cluster";
protected description = "Create a new local MongoDB cluster";
protected operationType: OperationType = "create";
protected argsShape = {
name: z.string().describe("Name of the cluster"),
port: z.number().describe("The port number on which the local MongoDB cluster will run.").optional(),
};

protected async execute({ name, port }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const availablePort = await this.getAvailablePort(port);
const result = await this.createCluster(name, availablePort);
return this.formatCreateClusterResult(result);
}

private async getAvailablePort(port?: number): Promise<number> {
if (port) {
const isAvailable = await this.isPortAvailable(port);
if (!isAvailable) {
throw new Error(`Port ${port} is already in use. Please specify a different port.`);
}
return port;
}

// Find a random available port
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const address = server.address();
if (typeof address === "object" && address?.port) {
const randomPort = address.port;
server.close(() => resolve(randomPort));
} else {
reject(new Error("Failed to find an available port."));
}
});
server.on("error", reject);
});
}

private async isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false)); // Port is in use
server.once("listening", () => {
server.close(() => resolve(true)); // Port is available
});
server.listen(port);
});
}

private async createCluster(clusterName: string, port: number): Promise<{ success: boolean; message: string }> {
const execAsync = promisify(exec);

try {
// Run the Docker command to create a new MongoDB container
await execAsync(`docker run -d --name ${clusterName} -p ${port}:27017 mongodb/mongodb-atlas-local:8.0`);

return {
success: true,
message: `Cluster "${clusterName}" created successfully on port ${port}.`,
};
} catch (error) {
if (error instanceof Error) {
return {
success: false,
message: `Failed to create cluster "${clusterName}": ${error.message}`,
};
} else {
return {
success: false,
message: `An unexpected error occurred while creating cluster "${clusterName}".`,
};
}
}
}

private formatCreateClusterResult(result: { success: boolean; message: string }): CallToolResult {
return {
content: [
{
type: "text",
text: result.message,
},
],
};
}
}
65 changes: 65 additions & 0 deletions src/tools/local/delete/deleteCluster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { promisify } from "util";
import { exec } from "child_process";
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { LocalToolBase } from "../localTool.js";
import { OperationType } from "../../tool.js";

export class DeleteClusterTool extends LocalToolBase {
protected name = "local-delete-cluster";
protected description = "Delete a local MongoDB cluster";
protected operationType: OperationType = "delete";
protected argsShape = {
clusterName: z.string().nonempty("Cluster name is required"),
};

protected async execute({ clusterName }: { clusterName: string }): Promise<CallToolResult> {
const result = await this.deleteCluster(clusterName);
return this.formatDeleteClusterResult(result, clusterName);
}

private async deleteCluster(clusterName: string): Promise<{ success: boolean; message: string }> {
const execAsync = promisify(exec);

try {
console.log(`Deleting MongoDB cluster with name: ${clusterName}`);
// Stop and remove the Docker container
await execAsync(`docker rm -f ${clusterName}`);

return {
success: true,
message: `Cluster "${clusterName}" deleted successfully.`,
};
} catch (error) {
if (error instanceof Error) {
console.error(`Failed to delete cluster "${clusterName}":`, error.message);
return {
success: false,
message: `Failed to delete cluster "${clusterName}": ${error.message}`,
};
} else {
console.error(`Unexpected error while deleting cluster "${clusterName}":`, error);
return {
success: false,
message: `An unexpected error occurred while deleting cluster "${clusterName}".`,
};
}
}
}

private formatDeleteClusterResult(
result: { success: boolean; message: string },
clusterName: string
): CallToolResult {
return {
content: [
{
type: "text",
text: result.success
? `Cluster "${clusterName}" has been deleted.`
: `Failed to delete cluster "${clusterName}": ${result.message}`,
},
],
};
}
}
54 changes: 54 additions & 0 deletions src/tools/local/localTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { z } from "zod";
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
import logger, { LogId } from "../../logger.js";
import { ToolBase, ToolCategory, TelemetryToolMetadata } from "../tool.js";

export interface DockerPsSummary {
ID: string;
Image: string;
Command: string;
CreatedAt: string;
RunningFor: string;
Ports: string;
Status: string;
Names: string;
}

export abstract class LocalToolBase extends ToolBase {
protected category: ToolCategory = "local";

protected resolveTelemetryMetadata(
...args: Parameters<ToolCallback<typeof this.argsShape>>
): TelemetryToolMetadata {
const toolMetadata: TelemetryToolMetadata = {};
if (!args.length) {
return toolMetadata;
}

// Create a typed parser for the exact shape we expect
const argsShape = z.object(this.argsShape);
const parsedResult = argsShape.safeParse(args[0]);

if (!parsedResult.success) {
logger.debug(
LogId.telemetryMetadataError,
"tool",
`Error parsing tool arguments: ${parsedResult.error.message}`
);
return toolMetadata;
}

const data = parsedResult.data;

// Extract projectId using type guard
if ("projectId" in data && typeof data.projectId === "string" && data.projectId.trim() !== "") {
toolMetadata.projectId = data.projectId;
}

// Extract orgId using type guard
if ("orgId" in data && typeof data.orgId === "string" && data.orgId.trim() !== "") {
toolMetadata.orgId = data.orgId;
}
return toolMetadata;
}
}
Loading
Loading