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

mongodb-js / mongodb-mcp-server / 16626320664

30 Jul 2025 03:04PM UTC coverage: 80.494% (+0.2%) from 80.344%
16626320664

Pull #407

github

web-flow
Merge 0ce7935f6 into c94c58e81
Pull Request #407: chore: Support for proxies for Atlas tools. MCP-87

561 of 735 branches covered (76.33%)

Branch coverage included in aggregate %.

83 of 86 new or added lines in 1 file covered. (96.51%)

1 existing line in 1 file now uncovered.

3157 of 3884 relevant lines covered (81.28%)

48.05 hits per line

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

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

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

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

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

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

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

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

48
    private client: Client<paths>;
49

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

54
    public hasCredentials(): boolean {
70✔
55
        return !!this.oauth2Client && !!this.oauth2Issuer;
138✔
56
    }
138✔
57

58
    private isAccessTokenValid(): boolean {
70✔
59
        return !!(
121✔
60
            this.accessToken &&
121✔
61
            this.accessToken.expires_at != undefined &&
112✔
62
            this.accessToken.expires_at > Date.now()
112✔
63
        );
64
    }
121✔
65

66
    private getAccessToken = async () => {
70✔
67
        if (!this.hasCredentials()) {
121!
NEW
68
            return undefined;
×
UNCOV
69
        }
×
70

71
        if (!this.isAccessTokenValid()) {
121✔
72
            this.accessToken = await this.getNewAccessToken();
11✔
73
        }
11✔
74

75
        return this.accessToken?.access_token;
121✔
76
    };
121✔
77

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

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

96
    constructor(options: ApiClientOptions) {
70✔
97
        this.options = {
98✔
98
            ...options,
98✔
99
            userAgent:
98✔
100
                options.userAgent ||
98✔
101
                `AtlasMCP/${packageInfo.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
74✔
102
        };
98✔
103

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

117
        if (this.options.credentials?.clientId && this.options.credentials?.clientSecret) {
98✔
118
            this.oauth2Issuer = {
60✔
119
                issuer: this.options.baseUrl,
60✔
120
                token_endpoint: new URL("/api/oauth/token", this.options.baseUrl).toString(),
60✔
121
                revocation_endpoint: new URL("/api/oauth/revoke", this.options.baseUrl).toString(),
60✔
122
                token_endpoint_auth_methods_supported: ["client_secret_basic"],
60✔
123
                grant_types_supported: ["client_credentials"],
60✔
124
            };
60✔
125

126
            this.oauth2Client = {
60✔
127
                client_id: this.options.credentials.clientId,
60✔
128
                client_secret: this.options.credentials.clientSecret,
60✔
129
            };
60✔
130

131
            this.client.use(this.authMiddleware);
60✔
132
        }
60✔
133
    }
98✔
134

135
    private getOauthClientAuth(): { client: oauth.Client | undefined; clientAuth: oauth.ClientAuth | undefined } {
70✔
136
        if (this.options.credentials?.clientId && this.options.credentials.clientSecret) {
85✔
137
            const clientSecret = this.options.credentials.clientSecret;
57✔
138
            const clientId = this.options.credentials.clientId;
57✔
139

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

151
        return { client: undefined, clientAuth: undefined };
28✔
152
    }
85✔
153

154
    private async getNewAccessToken(): Promise<AccessToken | undefined> {
70✔
155
        if (!this.hasCredentials() || !this.oauth2Issuer) {
11!
NEW
156
            return undefined;
×
NEW
157
        }
×
158

159
        const { client, clientAuth } = this.getOauthClientAuth();
11✔
160
        if (client && clientAuth) {
11✔
161
            try {
11✔
162
                const response = await oauth.clientCredentialsGrantRequest(
11✔
163
                    this.oauth2Issuer,
11✔
164
                    client,
11✔
165
                    clientAuth,
11✔
166
                    new URLSearchParams(),
11✔
167
                    {
11✔
168
                        [oauth.customFetch]: ApiClient.customFetch,
11✔
169
                        headers: {
11✔
170
                            "User-Agent": this.options.userAgent,
11✔
171
                        },
11✔
172
                    }
11✔
173
                );
11✔
174

175
                const result = await oauth.processClientCredentialsResponse(this.oauth2Issuer, client, response);
11✔
176
                this.accessToken = {
7✔
177
                    access_token: result.access_token,
7✔
178
                    expires_at: Date.now() + (result.expires_in ?? 0) * 1000,
11!
179
                };
11✔
180
            } catch (error: unknown) {
11✔
181
                const err = error instanceof Error ? error : new Error(String(error));
4!
182
                logger.error(LogId.atlasConnectFailure, "apiClient", `Failed to request access token: ${err.message}`);
4✔
183
            }
4✔
184
            return this.accessToken;
11✔
185
        }
11✔
186
    }
11✔
187

188
    public async validateAccessToken(): Promise<void> {
70✔
189
        await this.getAccessToken();
5✔
190
    }
5✔
191

192
    public async close(): Promise<void> {
70✔
193
        const { client, clientAuth } = this.getOauthClientAuth();
74✔
194
        try {
74✔
195
            if (this.oauth2Issuer && this.accessToken && client && clientAuth) {
74✔
196
                await oauth.revocationRequest(this.oauth2Issuer, client, clientAuth, this.accessToken.access_token);
13✔
197
            }
8✔
198
        } catch (error: unknown) {
74✔
199
            const err = error instanceof Error ? error : new Error(String(error));
4!
200
            logger.error(LogId.atlasApiRevokeFailure, "apiClient", `Failed to revoke access token: ${err.message}`);
4✔
201
        }
4✔
202
        this.accessToken = undefined;
73✔
203
    }
74✔
204

205
    public async getIpInfo(): Promise<{
70✔
206
        currentIpv4Address: string;
207
    }> {
13✔
208
        const accessToken = await this.getAccessToken();
13✔
209

210
        const endpoint = "api/private/ipinfo";
13✔
211
        const url = new URL(endpoint, this.options.baseUrl);
13✔
212
        const response = await fetch(url, {
13✔
213
            method: "GET",
13✔
214
            headers: {
13✔
215
                Accept: "application/json",
13✔
216
                Authorization: `Bearer ${accessToken}`,
13✔
217
                "User-Agent": this.options.userAgent,
13✔
218
            },
13✔
219
        });
13✔
220

221
        if (!response.ok) {
13!
222
            throw await ApiClientError.fromResponse(response);
×
223
        }
×
224

225
        return (await response.json()) as Promise<{
13✔
226
            currentIpv4Address: string;
227
        }>;
228
    }
13✔
229

230
    public async sendEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
70✔
231
        if (!this.options.credentials) {
10!
232
            await this.sendUnauthEvents(events);
×
233
            return;
×
234
        }
×
235

236
        try {
10✔
237
            await this.sendAuthEvents(events);
10✔
238
        } catch (error) {
10✔
239
            if (error instanceof ApiClientError) {
8✔
240
                if (error.response.status !== 401) {
4!
241
                    throw error;
×
242
                }
×
243
            }
4✔
244

245
            // send unauth events if any of the following are true:
246
            // 1: the token is not valid (not ApiClientError)
247
            // 2: if the api responded with 401 (ApiClientError with status 401)
248
            await this.sendUnauthEvents(events);
8✔
249
        }
6✔
250
    }
10✔
251

252
    private async sendAuthEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
70✔
253
        const accessToken = await this.getAccessToken();
10✔
254
        if (!accessToken) {
10✔
255
            throw new Error("No access token available");
2✔
256
        }
2✔
257
        const authUrl = new URL("api/private/v1.0/telemetry/events", this.options.baseUrl);
6✔
258
        const response = await fetch(authUrl, {
6✔
259
            method: "POST",
6✔
260
            headers: {
6✔
261
                Accept: "application/json",
6✔
262
                "Content-Type": "application/json",
6✔
263
                "User-Agent": this.options.userAgent,
6✔
264
                Authorization: `Bearer ${accessToken}`,
6✔
265
            },
6✔
266
            body: JSON.stringify(events),
6✔
267
        });
6✔
268

269
        if (!response.ok) {
10✔
270
            throw await ApiClientError.fromResponse(response);
4✔
271
        }
4✔
272
    }
10✔
273

274
    private async sendUnauthEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
70✔
275
        const headers: Record<string, string> = {
8✔
276
            Accept: "application/json",
8✔
277
            "Content-Type": "application/json",
8✔
278
            "User-Agent": this.options.userAgent,
8✔
279
        };
8✔
280

281
        const unauthUrl = new URL("api/private/unauth/telemetry/events", this.options.baseUrl);
8✔
282
        const response = await fetch(unauthUrl, {
8✔
283
            method: "POST",
8✔
284
            headers,
8✔
285
            body: JSON.stringify(events),
8✔
286
        });
8✔
287

288
        if (!response.ok) {
8✔
289
            throw await ApiClientError.fromResponse(response);
2✔
290
        }
2✔
291
    }
8✔
292

293
    // DO NOT EDIT. This is auto-generated code.
294
    async listClustersForAllProjects(options?: FetchOptions<operations["listClustersForAllProjects"]>) {
70✔
295
        const { data, error, response } = await this.client.GET("/api/atlas/v2/clusters", options);
×
296
        if (error) {
×
297
            throw ApiClientError.fromError(response, error);
×
298
        }
×
299
        return data;
×
300
    }
×
301

302
    async listProjects(options?: FetchOptions<operations["listProjects"]>) {
70✔
303
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups", options);
6✔
304
        if (error) {
6✔
305
            throw ApiClientError.fromError(response, error);
2✔
306
        }
2✔
307
        return data;
4✔
308
    }
6✔
309

310
    async createProject(options: FetchOptions<operations["createProject"]>) {
70✔
311
        const { data, error, response } = await this.client.POST("/api/atlas/v2/groups", options);
5✔
312
        if (error) {
5!
313
            throw ApiClientError.fromError(response, error);
×
314
        }
×
315
        return data;
5✔
316
    }
5✔
317

318
    async deleteProject(options: FetchOptions<operations["deleteProject"]>) {
70✔
319
        const { error, response } = await this.client.DELETE("/api/atlas/v2/groups/{groupId}", options);
5✔
320
        if (error) {
5!
321
            throw ApiClientError.fromError(response, error);
×
322
        }
×
323
    }
5✔
324

325
    async getProject(options: FetchOptions<operations["getProject"]>) {
70✔
326
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}", options);
1✔
327
        if (error) {
1!
328
            throw ApiClientError.fromError(response, error);
×
329
        }
×
330
        return data;
1✔
331
    }
1✔
332

333
    async listProjectIpAccessLists(options: FetchOptions<operations["listProjectIpAccessLists"]>) {
70✔
334
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/accessList", options);
4✔
335
        if (error) {
4!
336
            throw ApiClientError.fromError(response, error);
×
337
        }
×
338
        return data;
4✔
339
    }
4✔
340

341
    async createProjectIpAccessList(options: FetchOptions<operations["createProjectIpAccessList"]>) {
70✔
342
        const { data, error, response } = await this.client.POST("/api/atlas/v2/groups/{groupId}/accessList", options);
10✔
343
        if (error) {
10!
344
            throw ApiClientError.fromError(response, error);
×
345
        }
×
346
        return data;
10✔
347
    }
10✔
348

349
    async deleteProjectIpAccessList(options: FetchOptions<operations["deleteProjectIpAccessList"]>) {
70✔
350
        const { error, response } = await this.client.DELETE(
5✔
351
            "/api/atlas/v2/groups/{groupId}/accessList/{entryValue}",
5✔
352
            options
5✔
353
        );
5✔
354
        if (error) {
5!
355
            throw ApiClientError.fromError(response, error);
×
356
        }
×
357
    }
5✔
358

359
    async listAlerts(options: FetchOptions<operations["listAlerts"]>) {
70✔
360
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/alerts", options);
1✔
361
        if (error) {
1!
362
            throw ApiClientError.fromError(response, error);
×
363
        }
×
364
        return data;
1✔
365
    }
1✔
366

367
    async listClusters(options: FetchOptions<operations["listClusters"]>) {
70✔
368
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/clusters", options);
1✔
369
        if (error) {
1!
370
            throw ApiClientError.fromError(response, error);
×
371
        }
×
372
        return data;
1✔
373
    }
1✔
374

375
    async createCluster(options: FetchOptions<operations["createCluster"]>) {
70✔
376
        const { data, error, response } = await this.client.POST("/api/atlas/v2/groups/{groupId}/clusters", options);
1✔
377
        if (error) {
1!
378
            throw ApiClientError.fromError(response, error);
×
379
        }
×
380
        return data;
1✔
381
    }
1✔
382

383
    async deleteCluster(options: FetchOptions<operations["deleteCluster"]>) {
70✔
384
        const { error, response } = await this.client.DELETE(
1✔
385
            "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
1✔
386
            options
1✔
387
        );
1✔
388
        if (error) {
1!
389
            throw ApiClientError.fromError(response, error);
×
390
        }
×
391
    }
1✔
392

393
    async getCluster(options: FetchOptions<operations["getCluster"]>) {
70✔
394
        const { data, error, response } = await this.client.GET(
43✔
395
            "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
43✔
396
            options
43✔
397
        );
43✔
398

399
        if (error) {
43✔
400
            throw ApiClientError.fromError(response, error);
1✔
401
        }
1✔
402
        return data;
42✔
403
    }
43✔
404

405
    async listDatabaseUsers(options: FetchOptions<operations["listDatabaseUsers"]>) {
70✔
406
        const { data, error, response } = await this.client.GET(
1✔
407
            "/api/atlas/v2/groups/{groupId}/databaseUsers",
1✔
408
            options
1✔
409
        );
1✔
410
        if (error) {
1!
411
            throw ApiClientError.fromError(response, error);
×
412
        }
×
413
        return data;
1✔
414
    }
1✔
415

416
    async createDatabaseUser(options: FetchOptions<operations["createDatabaseUser"]>) {
70✔
417
        const { data, error, response } = await this.client.POST(
5✔
418
            "/api/atlas/v2/groups/{groupId}/databaseUsers",
5✔
419
            options
5✔
420
        );
5✔
421
        if (error) {
5!
422
            throw ApiClientError.fromError(response, error);
×
423
        }
×
424
        return data;
5✔
425
    }
5✔
426

427
    async deleteDatabaseUser(options: FetchOptions<operations["deleteDatabaseUser"]>) {
70✔
428
        const { error, response } = await this.client.DELETE(
7✔
429
            "/api/atlas/v2/groups/{groupId}/databaseUsers/{databaseName}/{username}",
7✔
430
            options
7✔
431
        );
7✔
432
        if (error) {
7✔
433
            throw ApiClientError.fromError(response, error);
2✔
434
        }
2✔
435
    }
7✔
436

437
    async listFlexClusters(options: FetchOptions<operations["listFlexClusters"]>) {
70✔
438
        const { data, error, response } = await this.client.GET("/api/atlas/v2/groups/{groupId}/flexClusters", options);
×
439
        if (error) {
×
440
            throw ApiClientError.fromError(response, error);
×
441
        }
×
442
        return data;
×
443
    }
×
444

445
    async createFlexCluster(options: FetchOptions<operations["createFlexCluster"]>) {
70✔
446
        const { data, error, response } = await this.client.POST(
×
447
            "/api/atlas/v2/groups/{groupId}/flexClusters",
×
448
            options
×
449
        );
×
450
        if (error) {
×
451
            throw ApiClientError.fromError(response, error);
×
452
        }
×
453
        return data;
×
454
    }
×
455

456
    async deleteFlexCluster(options: FetchOptions<operations["deleteFlexCluster"]>) {
70✔
457
        const { error, response } = await this.client.DELETE(
×
458
            "/api/atlas/v2/groups/{groupId}/flexClusters/{name}",
×
459
            options
×
460
        );
×
461
        if (error) {
×
462
            throw ApiClientError.fromError(response, error);
×
463
        }
×
464
    }
×
465

466
    async getFlexCluster(options: FetchOptions<operations["getFlexCluster"]>) {
70✔
467
        const { data, error, response } = await this.client.GET(
×
468
            "/api/atlas/v2/groups/{groupId}/flexClusters/{name}",
×
469
            options
×
470
        );
×
471
        if (error) {
×
472
            throw ApiClientError.fromError(response, error);
×
473
        }
×
474
        return data;
×
475
    }
×
476

477
    async listOrganizations(options?: FetchOptions<operations["listOrganizations"]>) {
70✔
478
        const { data, error, response } = await this.client.GET("/api/atlas/v2/orgs", options);
11!
479
        if (error) {
7!
480
            throw ApiClientError.fromError(response, error);
×
481
        }
×
482
        return data;
7✔
483
    }
11✔
484

485
    async listOrganizationProjects(options: FetchOptions<operations["listOrganizationProjects"]>) {
70✔
486
        const { data, error, response } = await this.client.GET("/api/atlas/v2/orgs/{orgId}/groups", options);
×
487
        if (error) {
×
488
            throw ApiClientError.fromError(response, error);
×
489
        }
×
490
        return data;
×
491
    }
×
492

493
    // DO NOT EDIT. This is auto-generated code.
494
}
70✔
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