Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.

17 changes: 14 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,35 @@
"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:download": "curl -Lo ./scripts/spec.json https://github.com/mongodb/openapi/raw/refs/heads/main/openapi/v2/openapi-2025-03-12.json",
"generate:filter": "tsx ./scripts/filter.ts > ./scripts/filteredSpec.json < ./scripts/spec.json",
"generate:bundle": "redocly bundle --ext json --remove-unused-components ./scripts/filteredSpec.json --output ./scripts/bundledSpec.json",
"generate:openapi": "openapi-typescript ./scripts/bundledSpec.json --root-types-no-schema-prefix --root-types --output ./src/common/atlas/openapi.d.ts",
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 create an issue if it requires more changes - can we have per-path or per operationID files?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't think the tool supports it

"generate:clear": "rm -rf ./scripts/bundledSpec.json ./scripts/filteredSpec.json ./scripts/spec.json",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Super nit, but these seem to be very dependent on the order of execution, so not sure if it's that useful to have them as separate scripts - it doesn't seem like they'd be really useful on their own.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

the only reason they are separated is that it was challenging to implement in one go, we could move it to bash but it also felt a bit weird to do so

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

replaced with a bash

"generate": "npm run generate:download && npm run generate:filter && npm run generate:bundle && npm run generate:openapi && npm run generate:clear"
},
"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);
});
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