Skip to content

feat: add atlas-create-free-cluster atlas-inspect-cluster tools #29

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

Merged
merged 7 commits into from
Apr 9, 2025
Merged
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
3,100 changes: 3,024 additions & 76 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,30 @@
"build": "npm run build:clean && npm run build:compile && npm run build:addshebang && npm run build:chmod",
"inspect": "npm run build && mcp-inspector -- dist/index.js",
"prettier": "prettier",
"check": "npm run check:lint && npm run check:format",
"check": "npm run build && npm run check:lint && npm run check:format",
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@nirinchev I'm adding build here, I've seen some weird bug where the source was not compiling but the styles were passing 🤷‍♂️

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, sounds good. I was thinking of adding a build step on CI anyway, but that's also fine. Our project builds fairly quickly still, so I'm not really worried about the extra couple of seconds it adds.

Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: can be a follow-up PR- we should update our readme with the right commands to run

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

right now npm run check should check everything

"check:lint": "eslint .",
"check:format": "prettier -c .",
"reformat": "prettier --write ."
"reformat": "prettier --write .",
"generate": "./scripts/generate.sh"
},
"license": "Apache-2.0",
"devDependencies": {
"@eslint/js": "^9.24.0",
"@modelcontextprotocol/inspector": "^0.8.2",
"@modelcontextprotocol/sdk": "^1.8.0",
"@redocly/cli": "^1.34.2",
"@types/node": "^22.14.0",
"@types/simple-oauth2": "^5.0.7",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1",
"globals": "^16.0.0",
"openapi-types": "^12.1.3",
"openapi-typescript": "^7.6.1",
"prettier": "^3.5.3",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"typescript-eslint": "^8.29.1"
"typescript-eslint": "^8.29.1",
"yaml": "^2.7.1"
},
"dependencies": {
"@mongodb-js/devtools-connect": "^3.7.2",
Expand Down
56 changes: 56 additions & 0 deletions scripts/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { OpenAPIV3_1 } from "openapi-types";

async function readStdin() {
return new Promise<string>((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("error", (err) => {
reject(err);
});
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
resolve(data);
});
});
}

function filterOpenapi(openapi: OpenAPIV3_1.Document): OpenAPIV3_1.Document {
const allowedOperations = [
"listProjects",
"getProject",
"createProject",
"listClusters",
"createCluster",
"listClustersForAllProjects",
];

const filteredPaths = {};

for (const path in openapi.paths) {
const filteredMethods = {} as OpenAPIV3_1.PathItemObject;
for (const method in openapi.paths[path]) {
if (allowedOperations.includes(openapi.paths[path][method].operationId)) {
filteredMethods[method] = openapi.paths[path][method];
}
}
if (Object.keys(filteredMethods).length > 0) {
filteredPaths[path] = filteredMethods;
}
}

return { ...openapi, paths: filteredPaths };
}

async function main() {
const openapiText = await readStdin();
const openapi = JSON.parse(openapiText) as OpenAPIV3_1.Document;
const filteredOpenapi = filterOpenapi(openapi);
console.log(JSON.stringify(filteredOpenapi));
}

main().catch((error) => {
console.error("Error:", error);
process.exit(1);
});
10 changes: 10 additions & 0 deletions scripts/generate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash

set -Eeou pipefail

curl -Lo ./scripts/spec.json https://github.com/mongodb/openapi/raw/refs/heads/main/openapi/v2/openapi-2025-03-12.json
tsx ./scripts/filter.ts > ./scripts/filteredSpec.json < ./scripts/spec.json
redocly bundle --ext json --remove-unused-components ./scripts/filteredSpec.json --output ./scripts/bundledSpec.json
openapi-typescript ./scripts/bundledSpec.json --root-types-no-schema-prefix --root-types --output ./src/common/atlas/openapi.d.ts
prettier --write ./src/common/atlas/openapi.d.ts
rm -rf ./scripts/bundledSpec.json ./scripts/filteredSpec.json ./scripts/spec.json
32 changes: 32 additions & 0 deletions src/common/atlas/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiClient } from "./client";
import { State } from "../../state";

export async function ensureAuthenticated(state: State, apiClient: ApiClient): Promise<void> {
if (!(await isAuthenticated(state, apiClient))) {
throw new Error("Not authenticated");
}
}

export async function isAuthenticated(state: State, apiClient: ApiClient): Promise<boolean> {
switch (state.auth.status) {
case "not_auth":
return false;
case "requested":
try {
if (!state.auth.code) {
return false;
}
await apiClient.retrieveToken(state.auth.code.device_code);
return !!state.auth.token;
} catch {
return false;
}
case "issued":
if (!state.auth.token) {
return false;
}
return await apiClient.validateToken();
default:
throw new Error("Unknown authentication status");
}
}
81 changes: 33 additions & 48 deletions src/client.ts → src/common/atlas/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import config from "./config.js";
import config from "../../config.js";

import {
Group,
PaginatedOrgGroupView,
PaginatedAtlasGroupView,
ClusterDescription20240805,
PaginatedClusterDescription20240805,
} from "./openapi.js";

export interface OAuthToken {
access_token: string;
Expand All @@ -10,27 +18,6 @@ export interface OAuthToken {
expiry: Date;
}

export interface AtlasProject {
id: string;
name: string;
created?: {
$date: string;
};
}

export interface AtlasCluster {
id?: string;
name: string;
stateName: string;
mongoDBVersion?: string;
providerSettings?: {
regionName?: string;
};
connectionStrings?: {
standard?: string;
};
}

export interface OauthDeviceCode {
user_code: string;
verification_uri: string;
Expand All @@ -39,11 +26,6 @@ export interface OauthDeviceCode {
interval: string;
}

export interface AtlasResponse<T> {
results: T[];
totalCount?: number;
}

export type saveTokenFunction = (token: OAuthToken) => void | Promise<void>;

export class ApiClientError extends Error {
Expand Down Expand Up @@ -120,10 +102,11 @@ export class ApiClient {
...options?.headers,
},
};

const response = await fetch(url, opt);

if (!response.ok) {
throw new ApiClientError(`Error calling Atlas API: ${response.statusText}`, response);
throw new ApiClientError(`Error calling Atlas API: ${await response.text()}`, response);
}

return (await response.json()) as T;
Expand Down Expand Up @@ -282,31 +265,33 @@ export class ApiClient {
}
}

/**
* Get all projects for the authenticated user
*/
async listProjects(): Promise<AtlasResponse<AtlasProject>> {
return await this.do<AtlasResponse<AtlasProject>>("/groups");
async listProjects(): Promise<PaginatedAtlasGroupView> {
return await this.do<PaginatedAtlasGroupView>("/groups");
}

/**
* Get a specific project by ID
*/
async getProject(projectId: string): Promise<AtlasProject> {
return await this.do<AtlasProject>(`/groups/${projectId}`);
async getProject(groupId: string): Promise<Group> {
return await this.do<Group>(`/groups/${groupId}`);
}

/**
* Get clusters for a specific project
*/
async listProjectClusters(projectId: string): Promise<AtlasResponse<AtlasCluster>> {
return await this.do<AtlasResponse<AtlasCluster>>(`/groups/${projectId}/clusters`);
async listClusters(groupId: string): Promise<PaginatedClusterDescription20240805> {
return await this.do<PaginatedClusterDescription20240805>(`/groups/${groupId}/clusters`);
}

/**
* Get clusters for a specific project
*/
async listAllClusters(): Promise<AtlasResponse<AtlasCluster>> {
return await this.do<AtlasResponse<AtlasCluster>>(`/clusters`);
async listClustersForAllProjects(): Promise<PaginatedOrgGroupView> {
return await this.do<PaginatedOrgGroupView>(`/clusters`);
}

async getCluster(groupId: string, clusterName: string): Promise<ClusterDescription20240805> {
return await this.do<ClusterDescription20240805>(`/groups/${groupId}/clusters/${clusterName}`);
}

async createCluster(groupId: string, cluster: ClusterDescription20240805): Promise<ClusterDescription20240805> {
if (!cluster.groupId) {
throw new Error("Cluster groupId is required");
}
return await this.do<ClusterDescription20240805>(`/groups/${groupId}/clusters`, {
method: "POST",
body: JSON.stringify(cluster),
});
}
}
Loading