Skip to content

Commit ffeea6b

Browse files
committed
fix: validate creds
1 parent d061543 commit ffeea6b

File tree

5 files changed

+243
-5
lines changed

5 files changed

+243
-5
lines changed

src/common/atlas/apiClient.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ export class ApiClient {
8989
return !!(this.oauth2Client && this.accessToken);
9090
}
9191

92+
public async hasValidAccessToken(): Promise<boolean> {
93+
const accessToken = await this.getAccessToken();
94+
return accessToken !== undefined;
95+
}
96+
9297
public async getIpInfo(): Promise<{
9398
currentIpv4Address: string;
9499
}> {
@@ -115,7 +120,6 @@ export class ApiClient {
115120
}
116121

117122
async sendEvents(events: TelemetryEvent<CommonProperties>[]): Promise<void> {
118-
let endpoint = "api/private/unauth/telemetry/events";
119123
const headers: Record<string, string> = {
120124
Accept: "application/json",
121125
"Content-Type": "application/json",
@@ -124,12 +128,41 @@ export class ApiClient {
124128

125129
const accessToken = await this.getAccessToken();
126130
if (accessToken) {
127-
endpoint = "api/private/v1.0/telemetry/events";
131+
const authUrl = new URL("api/private/v1.0/telemetry/events", this.options.baseUrl);
128132
headers["Authorization"] = `Bearer ${accessToken}`;
133+
134+
try {
135+
const response = await fetch(authUrl, {
136+
method: "POST",
137+
headers,
138+
body: JSON.stringify(events),
139+
});
140+
141+
if (response.ok) {
142+
return;
143+
}
144+
145+
// If anything other than 401, throw the error
146+
if (response.status !== 401) {
147+
throw await ApiClientError.fromResponse(response);
148+
}
149+
150+
// For 401, fall through to unauthenticated endpoint
151+
delete headers["Authorization"];
152+
} catch (error) {
153+
// If the error is not a 401, rethrow it
154+
if (!(error instanceof ApiClientError) || error.response.status !== 401) {
155+
throw error;
156+
}
157+
158+
// For 401 errors, fall through to unauthenticated endpoint
159+
delete headers["Authorization"];
160+
}
129161
}
130162

131-
const url = new URL(endpoint, this.options.baseUrl);
132-
const response = await fetch(url, {
163+
// Send to unauthenticated endpoint (either as fallback from 401 or direct if no token)
164+
const unauthUrl = new URL("api/private/unauth/telemetry/events", this.options.baseUrl);
165+
const response = await fetch(unauthUrl, {
133166
method: "POST",
134167
headers,
135168
body: JSON.stringify(events),
@@ -237,6 +270,7 @@ export class ApiClient {
237270
"/api/atlas/v2/groups/{groupId}/clusters/{clusterName}",
238271
options
239272
);
273+
240274
if (error) {
241275
throw ApiClientError.fromError(response, error);
242276
}

src/server.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class Server {
104104
* @param command - The server command (e.g., "start", "stop", "register", "deregister")
105105
* @param additionalProperties - Additional properties specific to the event
106106
*/
107-
emitServerEvent(command: ServerCommand, commandDuration: number, error?: Error) {
107+
private emitServerEvent(command: ServerCommand, commandDuration: number, error?: Error) {
108108
const event: ServerEvent = {
109109
timestamp: new Date().toISOString(),
110110
source: "mdbmcp",
@@ -185,5 +185,22 @@ export class Server {
185185
throw new Error("Failed to connect to MongoDB instance using the connection string from the config");
186186
}
187187
}
188+
189+
if (this.userConfig.apiClientId && this.userConfig.apiClientSecret) {
190+
try {
191+
await this.session.apiClient.hasValidAccessToken();
192+
} catch (error) {
193+
if (this.userConfig.connectionString === undefined) {
194+
console.error("Failed to validate MongoDB Atlas the credentials from the config: ", error);
195+
196+
throw new Error(
197+
"Failed to connect to MongoDB Atlas instance using the credentials from the config"
198+
);
199+
}
200+
console.error(
201+
"Failed to validate MongoDB Atlas the credentials from the config, but validated the connection string."
202+
);
203+
}
204+
}
188205
}
189206
}

tests/integration/helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import { Session } from "../../src/session.js";
88
import { Telemetry } from "../../src/telemetry/telemetry.js";
99
import { config } from "../../src/config.js";
10+
import { jest } from "@jest/globals";
1011

1112
interface ParameterInfo {
1213
name: string;
@@ -57,6 +58,12 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati
5758
apiClientSecret: userConfig.apiClientSecret,
5859
});
5960

61+
// Mock hasValidAccessToken for tests
62+
if (userConfig.apiClientId && userConfig.apiClientSecret) {
63+
const mockFn = jest.fn<() => Promise<boolean>>().mockResolvedValue(true);
64+
session.apiClient.hasValidAccessToken = mockFn;
65+
}
66+
6067
userConfig.telemetry = "disabled";
6168

6269
const telemetry = Telemetry.create(session, userConfig);
@@ -70,6 +77,9 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati
7077
version: "5.2.3",
7178
}),
7279
});
80+
81+
// mock validation
82+
7383
await mcpServer.connect(serverTransport);
7484
await mcpClient.connect(clientTransport);
7585
});

tests/integration/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { beforeAll } from "@jest/globals";
12
import { defaultTestConfig, expectDefined, setupIntegrationTest } from "./helpers.js";
23
import { describeWithMongoDB } from "./tools/mongodb/mongodbHelpers.js";
34

tests/unit/apiClient.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { jest } from "@jest/globals";
2+
import { ApiClient } from "../../src/common/atlas/apiClient.js";
3+
import {
4+
CommonProperties,
5+
TelemetryEvent,
6+
CommonStaticProperties,
7+
TelemetryResult,
8+
} from "../../src/telemetry/types.js";
9+
10+
describe("ApiClient", () => {
11+
let apiClient: ApiClient;
12+
13+
const mockEvents: TelemetryEvent<CommonProperties>[] = [
14+
{
15+
timestamp: new Date().toISOString(),
16+
source: "mdbmcp",
17+
properties: {
18+
mcp_client_version: "1.0.0",
19+
mcp_client_name: "test-client",
20+
mcp_server_version: "1.0.0",
21+
mcp_server_name: "test-server",
22+
platform: "test-platform",
23+
arch: "test-arch",
24+
os_type: "test-os",
25+
component: "test-component",
26+
duration_ms: 100,
27+
result: "success" as TelemetryResult,
28+
category: "test-category",
29+
},
30+
},
31+
];
32+
33+
beforeEach(() => {
34+
apiClient = new ApiClient({
35+
baseUrl: "https://api.test.com",
36+
credentials: {
37+
clientId: "test-client-id",
38+
clientSecret: "test-client-secret",
39+
},
40+
});
41+
42+
// @ts-ignore - accessing private property for testing
43+
apiClient.getAccessToken = jest.fn().mockResolvedValue("mockToken");
44+
});
45+
46+
afterEach(() => {
47+
jest.clearAllMocks();
48+
});
49+
50+
describe("constructor", () => {
51+
it("should create a client with the correct configuration", () => {
52+
expect(apiClient).toBeDefined();
53+
expect(apiClient.hasCredentials()).toBeDefined();
54+
});
55+
});
56+
57+
describe("listProjects", () => {
58+
it("should return a list of projects", async () => {
59+
const mockProjects = {
60+
results: [
61+
{ id: "1", name: "Project 1" },
62+
{ id: "2", name: "Project 2" },
63+
],
64+
totalCount: 2,
65+
};
66+
67+
const mockGet = jest.fn().mockImplementation(async () => ({
68+
data: mockProjects,
69+
error: null,
70+
response: new Response(),
71+
}));
72+
73+
// @ts-ignore - accessing private property for testing
74+
apiClient.client.GET = mockGet;
75+
76+
const result = await apiClient.listProjects();
77+
78+
expect(mockGet).toHaveBeenCalledWith("/api/atlas/v2/groups", undefined);
79+
expect(result).toEqual(mockProjects);
80+
});
81+
82+
it("should throw an error when the API call fails", async () => {
83+
const mockError = {
84+
reason: "Test error",
85+
detail: "Something went wrong",
86+
};
87+
88+
const mockGet = jest.fn().mockImplementation(async () => ({
89+
data: null,
90+
error: mockError,
91+
response: new Response(),
92+
}));
93+
94+
// @ts-ignore - accessing private property for testing
95+
apiClient.client.GET = mockGet;
96+
97+
await expect(apiClient.listProjects()).rejects.toThrow();
98+
});
99+
});
100+
101+
describe("sendEvents", () => {
102+
it("should send events to authenticated endpoint when token is available", async () => {
103+
const mockFetch = jest.spyOn(global, "fetch");
104+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
105+
106+
await apiClient.sendEvents(mockEvents);
107+
108+
const url = new URL("api/private/v1.0/telemetry/events", "https://api.test.com");
109+
expect(mockFetch).toHaveBeenCalledWith(url, {
110+
method: "POST",
111+
headers: {
112+
"Content-Type": "application/json",
113+
Authorization: expect.stringContaining("Bearer"),
114+
Accept: "application/json",
115+
"User-Agent": expect.stringContaining("AtlasMCP"),
116+
},
117+
body: JSON.stringify(mockEvents),
118+
});
119+
});
120+
121+
it("should fall back to unauthenticated endpoint when token is not available", async () => {
122+
const mockFetch = jest.spyOn(global, "fetch");
123+
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
124+
125+
// @ts-ignore - accessing private property for testing
126+
apiClient.getAccessToken = jest.fn().mockResolvedValue(undefined);
127+
128+
await apiClient.sendEvents(mockEvents);
129+
130+
const url = new URL("api/private/unauth/telemetry/events", "https://api.test.com");
131+
expect(mockFetch).toHaveBeenCalledWith(url, {
132+
method: "POST",
133+
headers: {
134+
"Content-Type": "application/json",
135+
Accept: "application/json",
136+
"User-Agent": expect.stringContaining("AtlasMCP"),
137+
},
138+
body: JSON.stringify(mockEvents),
139+
});
140+
});
141+
142+
it("should fall back to unauthenticated endpoint on 401 error", async () => {
143+
const mockFetch = jest.spyOn(global, "fetch");
144+
mockFetch
145+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
146+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
147+
148+
await apiClient.sendEvents(mockEvents);
149+
150+
const url = new URL("api/private/unauth/telemetry/events", "https://api.test.com");
151+
expect(mockFetch).toHaveBeenCalledTimes(2);
152+
expect(mockFetch).toHaveBeenLastCalledWith(url, {
153+
method: "POST",
154+
headers: {
155+
"Content-Type": "application/json",
156+
Accept: "application/json",
157+
"User-Agent": expect.stringContaining("AtlasMCP"),
158+
},
159+
body: JSON.stringify(mockEvents),
160+
});
161+
});
162+
163+
it("should throw error when both authenticated and unauthenticated requests fail", async () => {
164+
const mockFetch = jest.spyOn(global, "fetch");
165+
mockFetch
166+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
167+
.mockResolvedValueOnce(new Response(null, { status: 500 }));
168+
169+
const mockToken = "test-token";
170+
// @ts-ignore - accessing private property for testing
171+
apiClient.getAccessToken = jest.fn().mockResolvedValue(mockToken);
172+
173+
await expect(apiClient.sendEvents(mockEvents)).rejects.toThrow();
174+
});
175+
});
176+
});

0 commit comments

Comments
 (0)