Skip to content

Commit e200559

Browse files
authored
fix: validate creds (#222)
1 parent 9e76f95 commit e200559

File tree

4 files changed

+236
-5
lines changed

4 files changed

+236
-5
lines changed

src/common/atlas/apiClient.ts

+38-4
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

+18-1
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

+8
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,7 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati
7077
version: "5.2.3",
7178
}),
7279
});
80+
7381
await mcpServer.connect(serverTransport);
7482
await mcpClient.connect(clientTransport);
7583
});

tests/unit/apiClient.test.ts

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

0 commit comments

Comments
 (0)