Skip to content

Commit 44ae9f7

Browse files
committed
skunkworks
1 parent 4de743d commit 44ae9f7

File tree

12 files changed

+721
-44
lines changed

12 files changed

+721
-44
lines changed

package-lock.json

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

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@jest/globals": "^29.7.0",
3838
"@modelcontextprotocol/inspector": "^0.10.2",
3939
"@redocly/cli": "^1.34.2",
40+
"@types/dockerode": "^3.3.38",
4041
"@types/jest": "^29.5.14",
4142
"@types/node": "^22.14.0",
4243
"@types/simple-oauth2": "^5.0.7",
@@ -65,6 +66,7 @@
6566
"@mongodb-js/devtools-connect": "^3.7.2",
6667
"@mongosh/service-provider-node-driver": "^3.6.0",
6768
"bson": "^6.10.3",
69+
"dockerode": "^4.0.6",
6870
"lru-cache": "^11.1.0",
6971
"mongodb": "^6.15.0",
7072
"mongodb-connection-string-url": "^3.0.2",

src/common/local/dockerUtils.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { promisify } from "util";
2+
import { exec } from "child_process";
3+
import { z } from "zod";
4+
import { DockerPsSummary } from "../../tools/local/localTool.js";
5+
6+
export interface ClusterDetails {
7+
name: string;
8+
mongodbVersion: string;
9+
status: string;
10+
health: string;
11+
port: string;
12+
dbUser: string;
13+
isAuth: boolean; // Indicates if authentication is required
14+
}
15+
16+
export async function getValidatedClusterDetails(clusterName: string): Promise<ClusterDetails> {
17+
const execAsync = promisify(exec);
18+
19+
try {
20+
const { stdout } = await execAsync(`docker inspect ${clusterName}`);
21+
const containerDetails = JSON.parse(stdout) as DockerPsSummary[];
22+
23+
if (!Array.isArray(containerDetails) || containerDetails.length === 0) {
24+
throw new Error(`No details found for cluster "${clusterName}".`);
25+
}
26+
27+
const DockerInspectSchema = z.object({
28+
Config: z.object({
29+
Env: z.array(z.string()).optional(),
30+
Image: z.string(),
31+
}),
32+
NetworkSettings: z.object({
33+
Ports: z.record(
34+
z.string(),
35+
z
36+
.array(
37+
z
38+
.object({
39+
HostPort: z.string(),
40+
})
41+
.optional()
42+
)
43+
.optional()
44+
),
45+
}),
46+
State: z.object({
47+
Health: z
48+
.object({
49+
Status: z.string(),
50+
})
51+
.optional(),
52+
Status: z.string(),
53+
}),
54+
Name: z.string(),
55+
});
56+
57+
const validatedDetails = DockerInspectSchema.parse(containerDetails[0]);
58+
59+
const port = validatedDetails.NetworkSettings.Ports["27017/tcp"]?.[0]?.HostPort || "Unknown";
60+
61+
const envVars = validatedDetails.Config.Env || [];
62+
const username = envVars.find((env) => env.startsWith("MONGODB_INITDB_ROOT_USERNAME="))?.split("=")[1];
63+
64+
const isAuth = !!username; // Determine if authentication is required
65+
66+
const mongodbVersionMatch = validatedDetails.Config.Image.match(/mongodb\/mongodb-atlas-local:(.+)/);
67+
const mongodbVersion = mongodbVersionMatch ? mongodbVersionMatch[1] : "Unknown";
68+
69+
const status = validatedDetails.State.Status || "Unknown";
70+
const health = validatedDetails.State.Health?.Status || "Unknown";
71+
72+
return {
73+
name: validatedDetails.Name.replace("/", ""),
74+
mongodbVersion,
75+
status,
76+
health,
77+
port,
78+
dbUser: username || "No user found",
79+
isAuth,
80+
};
81+
} catch (error) {
82+
if (error instanceof Error) {
83+
throw new Error(`Failed to inspect cluster "${clusterName}": ${error.message}`);
84+
} else {
85+
throw new Error(`An unexpected error occurred while inspecting cluster "${clusterName}".`);
86+
}
87+
}
88+
}

src/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { Session } from "./session.js";
33
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
44
import { AtlasTools } from "./tools/atlas/tools.js";
5+
import { LocalTools } from "./tools/local/tools.js";
56
import { MongoDbTools } from "./tools/mongodb/tools.js";
67
import logger, { initializeLogger, LogId } from "./logger.js";
78
import { ObjectId } from "mongodb";
@@ -134,7 +135,7 @@ export class Server {
134135
}
135136

136137
private registerTools() {
137-
for (const tool of [...AtlasTools, ...MongoDbTools]) {
138+
for (const tool of [...AtlasTools, ...LocalTools, ...MongoDbTools]) {
138139
new tool(this.session, this.userConfig, this.telemetry).register(this.mcpServer);
139140
}
140141
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { exec } from "child_process";
2+
import { promisify } from "util";
3+
import * as net from "net";
4+
import { z } from "zod";
5+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6+
import { LocalToolBase } from "../localTool.js";
7+
import { ToolArgs, OperationType } from "../../tool.js";
8+
9+
export class CreateClusterTool extends LocalToolBase {
10+
protected name = "local-create-cluster";
11+
protected description = "Create a new local MongoDB cluster";
12+
protected operationType: OperationType = "create";
13+
protected argsShape = {
14+
name: z.string().describe("Name of the cluster"),
15+
port: z.number().describe("The port number on which the local MongoDB cluster will run.").optional(),
16+
};
17+
18+
protected async execute({ name, port }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
19+
const availablePort = await this.getAvailablePort(port);
20+
const result = await this.createCluster(name, availablePort);
21+
return this.formatCreateClusterResult(result);
22+
}
23+
24+
private async getAvailablePort(port?: number): Promise<number> {
25+
if (port) {
26+
const isAvailable = await this.isPortAvailable(port);
27+
if (!isAvailable) {
28+
throw new Error(`Port ${port} is already in use. Please specify a different port.`);
29+
}
30+
return port;
31+
}
32+
33+
// Find a random available port
34+
return new Promise((resolve, reject) => {
35+
const server = net.createServer();
36+
server.listen(0, () => {
37+
const address = server.address();
38+
if (typeof address === "object" && address?.port) {
39+
const randomPort = address.port;
40+
server.close(() => resolve(randomPort));
41+
} else {
42+
reject(new Error("Failed to find an available port."));
43+
}
44+
});
45+
server.on("error", reject);
46+
});
47+
}
48+
49+
private async isPortAvailable(port: number): Promise<boolean> {
50+
return new Promise((resolve) => {
51+
const server = net.createServer();
52+
server.once("error", () => resolve(false)); // Port is in use
53+
server.once("listening", () => {
54+
server.close(() => resolve(true)); // Port is available
55+
});
56+
server.listen(port);
57+
});
58+
}
59+
60+
private async createCluster(clusterName: string, port: number): Promise<{ success: boolean; message: string }> {
61+
const execAsync = promisify(exec);
62+
63+
try {
64+
// Run the Docker command to create a new MongoDB container
65+
await execAsync(`docker run -d --name ${clusterName} -p ${port}:27017 mongodb/mongodb-atlas-local:8.0`);
66+
67+
return {
68+
success: true,
69+
message: `Cluster "${clusterName}" created successfully on port ${port}.`,
70+
};
71+
} catch (error) {
72+
if (error instanceof Error) {
73+
return {
74+
success: false,
75+
message: `Failed to create cluster "${clusterName}": ${error.message}`,
76+
};
77+
} else {
78+
return {
79+
success: false,
80+
message: `An unexpected error occurred while creating cluster "${clusterName}".`,
81+
};
82+
}
83+
}
84+
}
85+
86+
private formatCreateClusterResult(result: { success: boolean; message: string }): CallToolResult {
87+
return {
88+
content: [
89+
{
90+
type: "text",
91+
text: result.message,
92+
},
93+
],
94+
};
95+
}
96+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { promisify } from "util";
2+
import { exec } from "child_process";
3+
import { z } from "zod";
4+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
5+
import { LocalToolBase } from "../localTool.js";
6+
import { OperationType } from "../../tool.js";
7+
8+
export class DeleteClusterTool extends LocalToolBase {
9+
protected name = "local-delete-cluster";
10+
protected description = "Delete a local MongoDB cluster";
11+
protected operationType: OperationType = "delete";
12+
protected argsShape = {
13+
clusterName: z.string().nonempty("Cluster name is required"),
14+
};
15+
16+
protected async execute({ clusterName }: { clusterName: string }): Promise<CallToolResult> {
17+
const result = await this.deleteCluster(clusterName);
18+
return this.formatDeleteClusterResult(result, clusterName);
19+
}
20+
21+
private async deleteCluster(clusterName: string): Promise<{ success: boolean; message: string }> {
22+
const execAsync = promisify(exec);
23+
24+
try {
25+
console.log(`Deleting MongoDB cluster with name: ${clusterName}`);
26+
// Stop and remove the Docker container
27+
await execAsync(`docker rm -f ${clusterName}`);
28+
29+
return {
30+
success: true,
31+
message: `Cluster "${clusterName}" deleted successfully.`,
32+
};
33+
} catch (error) {
34+
if (error instanceof Error) {
35+
console.error(`Failed to delete cluster "${clusterName}":`, error.message);
36+
return {
37+
success: false,
38+
message: `Failed to delete cluster "${clusterName}": ${error.message}`,
39+
};
40+
} else {
41+
console.error(`Unexpected error while deleting cluster "${clusterName}":`, error);
42+
return {
43+
success: false,
44+
message: `An unexpected error occurred while deleting cluster "${clusterName}".`,
45+
};
46+
}
47+
}
48+
}
49+
50+
private formatDeleteClusterResult(
51+
result: { success: boolean; message: string },
52+
clusterName: string
53+
): CallToolResult {
54+
return {
55+
content: [
56+
{
57+
type: "text",
58+
text: result.success
59+
? `Cluster "${clusterName}" has been deleted.`
60+
: `Failed to delete cluster "${clusterName}": ${result.message}`,
61+
},
62+
],
63+
};
64+
}
65+
}

src/tools/local/localTool.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { z } from "zod";
2+
import { ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import logger, { LogId } from "../../logger.js";
4+
import { ToolBase, ToolCategory, TelemetryToolMetadata } from "../tool.js";
5+
6+
export interface DockerPsSummary {
7+
ID: string;
8+
Image: string;
9+
Command: string;
10+
CreatedAt: string;
11+
RunningFor: string;
12+
Ports: string;
13+
Status: string;
14+
Names: string;
15+
}
16+
17+
export abstract class LocalToolBase extends ToolBase {
18+
protected category: ToolCategory = "local";
19+
20+
protected resolveTelemetryMetadata(
21+
...args: Parameters<ToolCallback<typeof this.argsShape>>
22+
): TelemetryToolMetadata {
23+
const toolMetadata: TelemetryToolMetadata = {};
24+
if (!args.length) {
25+
return toolMetadata;
26+
}
27+
28+
// Create a typed parser for the exact shape we expect
29+
const argsShape = z.object(this.argsShape);
30+
const parsedResult = argsShape.safeParse(args[0]);
31+
32+
if (!parsedResult.success) {
33+
logger.debug(
34+
LogId.telemetryMetadataError,
35+
"tool",
36+
`Error parsing tool arguments: ${parsedResult.error.message}`
37+
);
38+
return toolMetadata;
39+
}
40+
41+
const data = parsedResult.data;
42+
43+
// Extract projectId using type guard
44+
if ("projectId" in data && typeof data.projectId === "string" && data.projectId.trim() !== "") {
45+
toolMetadata.projectId = data.projectId;
46+
}
47+
48+
// Extract orgId using type guard
49+
if ("orgId" in data && typeof data.orgId === "string" && data.orgId.trim() !== "") {
50+
toolMetadata.orgId = data.orgId;
51+
}
52+
return toolMetadata;
53+
}
54+
}

0 commit comments

Comments
 (0)