• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mongodb-js / mongodb-mcp-server / 18192134230

02 Oct 2025 11:52AM UTC coverage: 82.501%. Remained the same
18192134230

Pull #607

github

web-flow
Merge 2564d5502 into 4cd078dc8
Pull Request #607: chore: stop waiting for cluster deletion

1097 of 1441 branches covered (76.13%)

Branch coverage included in aggregate %.

5296 of 6308 relevant lines covered (83.96%)

67.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.16
/src/common/atlas/apiClient.ts
1
import createClient from "openapi-fetch";
2✔
2
import type { ClientOptions, FetchOptions, Client, Middleware } from "openapi-fetch";
3
import { ApiClientError } from "./apiClientError.js";
2✔
4
import type { paths, operations } from "./openapi.js";
5
import type { CommonProperties, TelemetryEvent } from "../../telemetry/types.js";
6
import { packageInfo } from "../packageInfo.js";
2✔
7
import type { LoggerBase } from "../logger.js";
8
import { LogId } from "../logger.js";
2✔
9
import { createFetch } from "@mongodb-js/devtools-proxy-support";
2✔
10
import * as oauth from "oauth4webapi";
2✔
11
import { Request as NodeFetchRequest } from "node-fetch";
2✔
12

13
const ATLAS_API_VERSION = "2025-03-12";
2✔
14

15
export interface ApiClientCredentials {
16
    clientId: string;
17
    clientSecret: string;
18
}
19

20
export interface ApiClientOptions {
21
    credentials?: ApiClientCredentials;
22
    baseUrl: string;
23
    userAgent?: string;
24
}
25

26
export interface AccessToken {
27
    access_token: string;
28
    expires_at?: number;
29
}
30

31
export class ApiClient {
2✔
32
    private readonly options: {
33
        baseUrl: string;
34
        userAgent: string;
35
        credentials?: {
36
            clientId: string;
37
            clientSecret: string;
38
        };
39
    };
40

41
    // createFetch assumes that the first parameter of fetch is always a string
42
    // with the URL. However, fetch can also receive a Request object. While
43
    // the typechecking complains, createFetch does passthrough the parameters
44
    // so it works fine.
45
    private static customFetch: typeof fetch = createFetch({
49✔
46
        useEnvironmentVariableProxies: true,
49✔
47
    }) as unknown as typeof fetch;
49✔
48

49
    private client: Client<paths>;
50

51
    private oauth2Client?: oauth.Client;
52
    private oauth2Issuer?: oauth.AuthorizationServer;
53
    private accessToken?: AccessToken;
54

55
    public hasCredentials(): boolean {
49✔
56
        return !!this.oauth2Client && !!this.oauth2Issuer;
162✔
57
    }
162✔
58

59
    private isAccessTokenValid(): boolean {
49✔
60
        return !!(
125✔
61
            this.accessToken &&
125✔
62
            this.accessToken.expires_at !== undefined &&
101✔
63
            this.accessToken.expires_at > Date.now()
101✔
64
        );
65
    }
125✔
66

67
    private getAccessToken = async (): Promise<string | undefined> => {
49✔
68
        if (!this.hasCredentials()) {
125!
69
            return undefined;
×
70
        }
×
71

72
        if (!this.isAccessTokenValid()) {
125✔
73
            this.accessToken = await this.getNewAccessToken();
25✔
74
        }
25✔
75

76
        return this.accessToken?.access_token;
125✔
77
    };
125✔
78

79
    private authMiddleware: Middleware = {
49✔
80
        onRequest: async ({ request, schemaPath }) => {
49✔
81
            if (schemaPath.startsWith("/api/private/unauth") || schemaPath.startsWith("/api/oauth")) {
71!
82
                return undefined;
×
83
            }
×
84

85
            try {
71✔
86
                const accessToken = await this.getAccessToken();
71✔
87
                if (accessToken) {
71✔
88
                    request.headers.set("Authorization", `Bearer ${accessToken}`);
70✔
89
                }
70✔
90
                return request;
71✔
91
            } catch {
71!
92
                // ignore not availble tokens, API will return 401
93
                return undefined;
×
94
            }
×
95
        },
71✔
96
    };
49✔
97

98
    constructor(
49✔
99
        options: ApiClientOptions,
97✔
100
        public readonly logger: LoggerBase
97✔
101
    ) {
97✔
102
        this.options = {
97✔
103
            ...options,
97✔
104
            userAgent:
97✔
105
                options.userAgent ||
97✔
106
                `AtlasMCP/${packageInfo.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
85✔
107
        };
97✔
108

109
        this.client = createClient<paths>({
97✔
110
            baseUrl: this.options.baseUrl,
97✔
111
            headers: {
97✔
112
                "User-Agent": this.options.userAgent,
97✔
113
                Accept: `application/vnd.atlas.${ATLAS_API_VERSION}+json`,
97✔
114
            },
97✔
115
            fetch: ApiClient.customFetch,
97✔
116
            // NodeFetchRequest has more overloadings than the native Request
117
            // so it complains here. However, the interfaces are actually compatible
118
            // so it's not a real problem, just a type checking problem.
119
            Request: NodeFetchRequest as unknown as ClientOptions["Request"],
97✔
120
        });
97✔
121

122
        if (this.options.credentials?.clientId && this.options.credentials?.clientSecret) {
97✔
123
            this.oauth2Issuer = {
25✔
124
                issuer: this.options.baseUrl,
25✔
125
                token_endpoint: new URL("/api/oauth/token", this.options.baseUrl).toString(),
25✔
126
                revocation_endpoint: new URL("/api/oauth/revoke", this.options.baseUrl).toString(),
25✔
127
                token_endpoint_auth_methods_supported: ["client_secret_basic"],
25✔
128
                grant_types_supported: ["client_credentials"],
25✔
129
            };
25✔
130

131
            this.oauth2Client = {
25✔
132
                client_id: this.options.credentials.clientId,
25✔
133
                client_secret: this.options.credentials.clientSecret,
25✔
134
            };
25✔
135

136
            this.client.use(this.authMiddleware);
25✔
137
        }
25✔
138
    }
97✔
139

140
    private getOauthClientAuth(): { client: oauth.Client | undefined; clientAuth: oauth.ClientAuth | undefined } {
49✔
141
        if (this.options.credentials?.clientId && this.options.credentials.clientSecret) {
106✔
142
            const clientSecret = this.options.credentials.clientSecret;
43✔
143
            const clientId = this.options.credentials.clientId;
43✔
144

145
            // We are using our own ClientAuth because ClientSecretBasic URL encodes wrongly
146
            // the username and password (for example, encodes `_` to %5F, which is wrong).
147
            return {
43✔
148
                client: { client_id: clientId },
43✔
149
                clientAuth: (_as, client, _body, headers): void => {
43✔
150
                    const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
34✔
151
                    headers.set("Authorization", `Basic ${credentials}`);
34✔
152
                },
34✔
153
            };
43✔
154
        }
43!
155

156
        return { client: undefined, clientAuth: undefined };
63✔
157
    }
106✔
158

159
    private async getNewAccessToken(): Promise<AccessToken | undefined> {
49✔
160
        if (!this.hasCredentials() || !this.oauth2Issuer) {
25!
161
            return undefined;
×
162
        }
×
163

164
        const { client, clientAuth } = this.getOauthClientAuth();
25✔
165
        if (client && clientAuth) {
25✔
166
            try {
25✔
167
                const response = await oauth.clientCredentialsGrantRequest(
25✔
168
                    this.oauth2Issuer,
25✔
169
                    client,
25✔
170
                    clientAuth,
25✔
171
                    new URLSearchParams(),
25✔
172
                    {
25✔
173
                        [oauth.customFetch]: ApiClient.customFetch,
25✔
174
                        headers: {
25✔
175
                            "User-Agent": this.options.userAgent,
25✔
176
                        },
25✔
177
                    }
25✔
178
                );
25✔
179

180
                const result = await oauth.processClientCredentialsResponse(this.oauth2Issuer, client, response);
25!
181
                this.accessToken = {
6✔
182
                    access_token: result.access_token,
6✔
183
                    expires_at: Date.now() + (result.expires_in ?? 0) * 1000,
25!
184
                };
25✔
185
            } catch (error: unknown) {
25!
186
                const err = error instanceof Error ? error : new Error(String(error));
19!
187
                this.logger.error({
19✔
188
                    id: LogId.atlasConnectFailure,
19✔
189
                    context: "apiClient",
19✔
190
                    message: `Failed to request access token: ${err.message}`,
19✔
191
                });
19✔
192
            }
19✔
193
            return this.accessToken;
25✔
194
        }
25!
195

196
        return undefined;
×
197
    }
25✔
198

199
    public async validateAccessToken(): Promise<void> {
49✔
200
        await this.getAccessToken();
19✔
201
    }
19✔
202

203
    public async close(): Promise<void> {
49✔
204
        const { client, clientAuth } = this.getOauthClientAuth();
81✔
205
        try {
81✔
206
            if (this.oauth2Issuer && this.accessToken && client && clientAuth) {
81✔
207
                await oauth.revocationRequest(this.oauth2Issuer, client, clientAuth, this.accessToken.access_token);
9✔
208
            }
7✔
209
        } catch (error: unknown) {
81!
210
            const err = error instanceof Error ? error : new Error(String(error));
2!
211
            this.logger.error({
2✔
212
                id: LogId.atlasApiRevokeFailure,
2✔
213
                context: "apiClient",
2✔
214
                message: `Failed to revoke access token: ${err.message}`,
2✔
215
            });
2✔
216
        }
2✔
217
        this.accessToken = undefined;
81✔
218
    }
81✔
219

220
    public async getIpInfo(): Promise<{
49✔
221
        currentIpv4Address: string;
222
    }> {
22✔
223
        const accessToken = await this.getAccessToken();
22✔
224

225
        const endpoint = "api/private/ipinfo";
22✔
226
        const url = new URL(endpoint, this.options.baseUrl);
22✔
227
        const response = await fetch(url, {
22✔
228
            method: "GET",
22✔
229
            headers: {
22✔
230
                Accept: "application/json",
22✔
231
                Authorization: `Bearer ${accessToken}`,
22✔
232
                "User-Agent": this.options.userAgent,
22✔
233
            },
22✔
234
        });
22✔
235

236
        if (!response.ok) {
22!
237
            throw await ApiClientError.fromResponse(response);
2✔
238
        }
2!
239

240
        return (await response.json()) as Promise<{
20✔
241
            currentIpv4Address: string;
242
        }>;
243
    }
22✔
244

245
    public async sendEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
49✔
246
        if (!this.options.credentials) {
85!
247
            await this.sendUnauthEvents(events);
67✔
248
            return;
67✔
249
        }
67✔
250

251
        try {
18✔
252
            await this.sendAuthEvents(events);
18✔
253
        } catch (error) {
19!
254
            if (error instanceof ApiClientError) {
11✔
255
                if (error.response.status !== 401) {
2!
256
                    throw error;
×
257
                }
×
258
            }
2✔
259

260
            // send unauth events if any of the following are true:
261
            // 1: the token is not valid (not ApiClientError)
262
            // 2: if the api responded with 401 (ApiClientError with status 401)
263
            await this.sendUnauthEvents(events);
11✔
264
        }
10✔
265
    }
85✔
266

267
    private async sendAuthEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
49✔
268
        const accessToken = await this.getAccessToken();
18✔
269
        if (!accessToken) {
18!
270
            throw new Error("No access token available");
8✔
271
        }
8✔
272
        const authUrl = new URL("api/private/v1.0/telemetry/events", this.options.baseUrl);
9✔
273
        const response = await fetch(authUrl, {
9✔
274
            method: "POST",
9✔
275
            headers: {
9✔
276
                Accept: "application/json",
9✔
277
                "Content-Type": "application/json",
9✔
278
                "User-Agent": this.options.userAgent,
9✔
279
                Authorization: `Bearer ${accessToken}`,
9✔
280
            },
9✔
281
            body: JSON.stringify(events),
9✔
282
        });
9✔
283

284
        if (!response.ok) {
11!
285
            throw await ApiClientError.fromResponse(response);
2✔
286
        }
2✔
287
    }
18✔
288

289
    private async sendUnauthEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
49✔
290
        const headers: Record<string, string> = {
78✔
291
            Accept: "application/json",
78✔
292
            "Content-Type": "application/json",
78✔
293
            "User-Agent": this.options.userAgent,
78✔
294
        };
78✔
295

296
        const unauthUrl = new URL("api/private/unauth/telemetry/events", this.options.baseUrl);
78✔
297
        const response = await fetch(unauthUrl, {
78✔
298
            method: "POST",
78✔
299
            headers,
78✔
300
            body: JSON.stringify(events),
78✔
301
        });
78✔
302

303
        if (!response.ok) {
78✔
304
            throw await ApiClientError.fromResponse(response);
1✔
305
        }
1✔
306
    }
78✔
307

308
    // DO NOT EDIT. This is auto-generated code.
309
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
310
    async listClustersForAllProjects(options?: FetchOptions<operations["listClustersForAllProjects"]>) {
49✔
311
        const { data, error, response } = await this.client.GET("/api/atlas/v2/clusters", options);
×
312
        if (error) {
×
313
            throw ApiClientError.fromError(response, error);
×
314
        }
×
315
        return data;
×
316
    }
×
317

318
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
319
    async listProjects(options?: FetchOptions<operations["listProjects"]>) {
49✔
320
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups", options);
4✔
321
        if (error) {
4!
322
            throw ApiClientError.fromError(response, error);
1✔
323
        }
1✔
324
        return data;
3✔
325
    }
4✔
326

327
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
328
    async createProject(options: FetchOptions<operations["createProject"]>) {
49✔
329
        const { data, error, response } = await this.client.POST("/api/atlas/v2/groups", options);
5✔
330
        if (error) {
5!
331
            throw ApiClientError.fromError(response, error);
×
332
        }
×
333
        return data;
5✔
334
    }
5✔
335

336
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
337
    async deleteProject(options: FetchOptions<operations["deleteProject"]>) {
49✔
338
        const { error, response } = await this.client.DELETE("/api/atlas/v2/groups/{groupId}", options);
5✔
339
        if (error) {
2✔
340
            throw ApiClientError.fromError(response, error);
1✔
341
        }
1✔
342
    }
5✔
343

344
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
345
    async getProject(options: FetchOptions<operations["getProject"]>) {
49✔
346
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}", options);
1✔
347
        if (error) {
1!
348
            throw ApiClientError.fromError(response, error);
×
349
        }
×
350
        return data;
1✔
351
    }
1✔
352

353
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
354
    async listProjectIpAccessLists(options: FetchOptions<operations["listProjectIpAccessLists"]>) {
49✔
355
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/accessList", options);
4✔
356
        if (error) {
4!
357
            throw ApiClientError.fromError(response, error);
×
358
        }
×
359
        return data;
4✔
360
    }
4✔
361

362
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
363
    async createProjectIpAccessList(options: FetchOptions<operations["createProjectIpAccessList"]>) {
49✔
364
        const { data, error, response } = await this.client.POST("/api/atlas/v2/groups/{groupId}/accessList", options);
15✔
365
        if (error) {
15!
366
            throw ApiClientError.fromError(response, error);
1✔
367
        }
1!
368
        return data;
14✔
369
    }
15✔
370

371
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
372
    async deleteProjectIpAccessList(options: FetchOptions<operations["deleteProjectIpAccessList"]>) {
49✔
373
        const { error, response } = await this.client.DELETE(
5✔
374
            "/api/atlas/v2/groups/{groupId}/accessList/{entryValue}",
5✔
375
            options
5✔
376
        );
5✔
377
        if (error) {
5!
378
            throw ApiClientError.fromError(response, error);
×
379
        }
×
380
    }
5✔
381

382
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
383
    async listAlerts(options: FetchOptions<operations["listAlerts"]>) {
49✔
384
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/alerts", options);
1✔
385
        if (error) {
1!
386
            throw ApiClientError.fromError(response, error);
×
387
        }
×
388
        return data;
1✔
389
    }
1✔
390

391
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
392
    async listClusters(options: FetchOptions<operations["listClusters"]>) {
49✔
393
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/clusters", options);
1✔
394
        if (error) {
1!
395
            throw ApiClientError.fromError(response, error);
×
396
        }
×
397
        return data;
1✔
398
    }
1✔
399

400
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
401
    async createCluster(options: FetchOptions<operations["createCluster"]>) {
49✔
402
        const { data, error, response } = await this.client.POST("/api/atlas/v2/groups/{groupId}/clusters", options);
1✔
403
        if (error) {
1!
404
            throw ApiClientError.fromError(response, error);
×
405
        }
×
406
        return data;
1✔
407
    }
1✔
408

409
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
410
    async deleteCluster(options: FetchOptions<operations["deleteCluster"]>) {
49✔
411
        const { error, response } = await this.client.DELETE(
1✔
412
            "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
1✔
413
            options
1✔
414
        );
1✔
415
        if (error) {
1!
416
            throw ApiClientError.fromError(response, error);
×
417
        }
×
418
    }
1✔
419

420
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
421
    async getCluster(options: FetchOptions<operations["getCluster"]>) {
49✔
422
        const { data, error, response } = await this.client.GET(
7✔
423
            "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
7✔
424
            options
7✔
425
        );
7✔
426
        if (error) {
7!
427
            throw ApiClientError.fromError(response, error);
×
428
        }
×
429
        return data;
7✔
430
    }
7✔
431

432
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
433
    async listDatabaseUsers(options: FetchOptions<operations["listDatabaseUsers"]>) {
49✔
434
        const { data, error, response } = await this.client.GET(
1✔
435
            "/api/atlas/v2/groups/{groupId}/databaseUsers",
1✔
436
            options
1✔
437
        );
1✔
438
        if (error) {
1!
439
            throw ApiClientError.fromError(response, error);
×
440
        }
×
441
        return data;
1✔
442
    }
1✔
443

444
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
445
    async createDatabaseUser(options: FetchOptions<operations["createDatabaseUser"]>) {
49✔
446
        const { data, error, response } = await this.client.POST(
6✔
447
            "/api/atlas/v2/groups/{groupId}/databaseUsers",
6✔
448
            options
6✔
449
        );
6✔
450
        if (error) {
6!
451
            throw ApiClientError.fromError(response, error);
×
452
        }
×
453
        return data;
6✔
454
    }
6✔
455

456
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
457
    async deleteDatabaseUser(options: FetchOptions<operations["deleteDatabaseUser"]>) {
49✔
458
        const { error, response } = await this.client.DELETE(
7✔
459
            "/api/atlas/v2/groups/{groupId}/databaseUsers/{databaseName}/{username}",
7✔
460
            options
7✔
461
        );
7✔
462
        if (error) {
7✔
463
            throw ApiClientError.fromError(response, error);
2✔
464
        }
2✔
465
    }
7✔
466

467
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
468
    async listFlexClusters(options: FetchOptions<operations["listFlexClusters"]>) {
49✔
469
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/flexClusters", options);
×
470
        if (error) {
×
471
            throw ApiClientError.fromError(response, error);
×
472
        }
×
473
        return data;
×
474
    }
×
475

476
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
477
    async createFlexCluster(options: FetchOptions<operations["createFlexCluster"]>) {
49✔
478
        const { data, error, response } = await this.client.POST(
×
479
            "/api/atlas/v2/groups/{groupId}/flexClusters",
×
480
            options
×
481
        );
×
482
        if (error) {
×
483
            throw ApiClientError.fromError(response, error);
×
484
        }
×
485
        return data;
×
486
    }
×
487

488
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
489
    async deleteFlexCluster(options: FetchOptions<operations["deleteFlexCluster"]>) {
49✔
490
        const { error, response } = await this.client.DELETE(
×
491
            "/api/atlas/v2/groups/{groupId}/flexClusters/{name}",
×
492
            options
×
493
        );
×
494
        if (error) {
×
495
            throw ApiClientError.fromError(response, error);
×
496
        }
×
497
    }
×
498

499
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
500
    async getFlexCluster(options: FetchOptions<operations["getFlexCluster"]>) {
49✔
501
        const { data, error, response } = await this.client.GET(
×
502
            "/api/atlas/v2/groups/{groupId}/flexClusters/{name}",
×
503
            options
×
504
        );
×
505
        if (error) {
×
506
            throw ApiClientError.fromError(response, error);
×
507
        }
×
508
        return data;
×
509
    }
×
510

511
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
512
    async listOrganizations(options?: FetchOptions<operations["listOrganizations"]>) {
49✔
513
        const { data, error, response } = await this.client.GET("/api/atlas/v2/orgs", options);
9!
514
        if (error) {
7!
515
            throw ApiClientError.fromError(response, error);
×
516
        }
×
517
        return data;
7✔
518
    }
9✔
519

520
    // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
521
    async listOrganizationProjects(options: FetchOptions<operations["listOrganizationProjects"]>) {
49✔
522
        const { data, error, response } = await this.client.GET("/api/atlas/v2/orgs/{orgId}/groups", options);
×
523
        if (error) {
×
524
            throw ApiClientError.fromError(response, error);
×
525
        }
×
526
        return data;
×
527
    }
×
528

529
    // DO NOT EDIT. This is auto-generated code.
530
}
49✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc