Skip to content

Commit eed30f8

Browse files
committed
Merge branch 'main' into ni/connect-guidance
* main: chore: revoke access tokens on server shutdown [MCP-53] (#352) fix: turn atlas-connect-cluster async (#343)
2 parents 04a3a00 + c40eeab commit eed30f8

File tree

9 files changed

+241
-54
lines changed

9 files changed

+241
-54
lines changed

.vscode/launch.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
55
"version": "0.2.0",
66
"configurations": [
7+
{
8+
"type": "node",
9+
"request": "launch",
10+
"name": "Launch Tests",
11+
"runtimeExecutable": "npm",
12+
"runtimeArgs": ["test"],
13+
"cwd": "${workspaceFolder}",
14+
"envFile": "${workspaceFolder}/.env"
15+
},
716
{
817
"type": "node",
918
"request": "launch",

src/common/atlas/apiClient.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ApiClientError } from "./apiClientError.js";
55
import { paths, operations } from "./openapi.js";
66
import { CommonProperties, TelemetryEvent } from "../../telemetry/types.js";
77
import { packageInfo } from "../../helpers/packageInfo.js";
8+
import logger, { LogId } from "../../logger.js";
89

910
const ATLAS_API_VERSION = "2025-03-12";
1011

@@ -34,9 +35,7 @@ export class ApiClient {
3435

3536
private getAccessToken = async () => {
3637
if (this.oauth2Client && (!this.accessToken || this.accessToken.expired())) {
37-
this.accessToken = await this.oauth2Client.getToken({
38-
agent: this.options.userAgent,
39-
});
38+
this.accessToken = await this.oauth2Client.getToken({});
4039
}
4140
return this.accessToken?.token.access_token as string | undefined;
4241
};
@@ -49,7 +48,9 @@ export class ApiClient {
4948

5049
try {
5150
const accessToken = await this.getAccessToken();
52-
request.headers.set("Authorization", `Bearer ${accessToken}`);
51+
if (accessToken) {
52+
request.headers.set("Authorization", `Bearer ${accessToken}`);
53+
}
5354
return request;
5455
} catch {
5556
// ignore not availble tokens, API will return 401
@@ -81,20 +82,38 @@ export class ApiClient {
8182
auth: {
8283
tokenHost: this.options.baseUrl,
8384
tokenPath: "/api/oauth/token",
85+
revokePath: "/api/oauth/revoke",
86+
},
87+
http: {
88+
headers: {
89+
"User-Agent": this.options.userAgent,
90+
},
8491
},
8592
});
8693
this.client.use(this.authMiddleware);
8794
}
8895
}
8996

9097
public hasCredentials(): boolean {
91-
return !!(this.oauth2Client && this.accessToken);
98+
return !!this.oauth2Client;
9299
}
93100

94101
public async validateAccessToken(): Promise<void> {
95102
await this.getAccessToken();
96103
}
97104

105+
public async close(): Promise<void> {
106+
if (this.accessToken) {
107+
try {
108+
await this.accessToken.revoke("access_token");
109+
} catch (error: unknown) {
110+
const err = error instanceof Error ? error : new Error(String(error));
111+
logger.error(LogId.atlasApiRevokeFailure, "apiClient", `Failed to revoke access token: ${err.message}`);
112+
}
113+
this.accessToken = undefined;
114+
}
115+
}
116+
98117
public async getIpInfo(): Promise<{
99118
currentIpv4Address: string;
100119
}> {

src/logger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const LogId = {
1717
atlasDeleteDatabaseUserFailure: mongoLogId(1_001_002),
1818
atlasConnectFailure: mongoLogId(1_001_003),
1919
atlasInspectFailure: mongoLogId(1_001_004),
20+
atlasConnectAttempt: mongoLogId(1_001_005),
21+
atlasConnectSucceeded: mongoLogId(1_001_006),
22+
atlasApiRevokeFailure: mongoLogId(1_001_007),
2023

2124
telemetryDisabled: mongoLogId(1_002_001),
2225
telemetryEmitFailure: mongoLogId(1_002_002),

src/session.ts

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,35 +67,33 @@ export class Session extends EventEmitter<{
6767
}
6868
this.serviceProvider = undefined;
6969
}
70-
if (!this.connectedAtlasCluster) {
71-
this.emit("disconnect");
72-
return;
73-
}
74-
void this.apiClient
75-
.deleteDatabaseUser({
76-
params: {
77-
path: {
78-
groupId: this.connectedAtlasCluster.projectId,
79-
username: this.connectedAtlasCluster.username,
80-
databaseName: "admin",
70+
if (this.connectedAtlasCluster?.username && this.connectedAtlasCluster?.projectId) {
71+
void this.apiClient
72+
.deleteDatabaseUser({
73+
params: {
74+
path: {
75+
groupId: this.connectedAtlasCluster.projectId,
76+
username: this.connectedAtlasCluster.username,
77+
databaseName: "admin",
78+
},
8179
},
82-
},
83-
})
84-
.catch((err: unknown) => {
85-
const error = err instanceof Error ? err : new Error(String(err));
86-
logger.error(
87-
LogId.atlasDeleteDatabaseUserFailure,
88-
"atlas-connect-cluster",
89-
`Error deleting previous database user: ${error.message}`
90-
);
91-
});
92-
this.connectedAtlasCluster = undefined;
93-
80+
})
81+
.catch((err: unknown) => {
82+
const error = err instanceof Error ? err : new Error(String(err));
83+
logger.error(
84+
LogId.atlasDeleteDatabaseUserFailure,
85+
"atlas-connect-cluster",
86+
`Error deleting previous database user: ${error.message}`
87+
);
88+
});
89+
this.connectedAtlasCluster = undefined;
90+
}
9491
this.emit("disconnect");
9592
}
9693

9794
async close(): Promise<void> {
9895
await this.disconnect();
96+
await this.apiClient.close();
9997
this.emit("close");
10098
}
10199

src/tools/atlas/connect/connectCluster.ts

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,46 @@ export class ConnectClusterTool extends AtlasToolBase {
2121
clusterName: z.string().describe("Atlas cluster name"),
2222
};
2323

24-
protected async execute({ projectId, clusterName }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
25-
await this.session.disconnect();
24+
private async queryConnection(
25+
projectId: string,
26+
clusterName: string
27+
): Promise<"connected" | "disconnected" | "connecting" | "connected-to-other-cluster" | "unknown"> {
28+
if (!this.session.connectedAtlasCluster) {
29+
if (this.session.serviceProvider) {
30+
return "connected-to-other-cluster";
31+
}
32+
return "disconnected";
33+
}
34+
35+
if (
36+
this.session.connectedAtlasCluster.projectId !== projectId ||
37+
this.session.connectedAtlasCluster.clusterName !== clusterName
38+
) {
39+
return "connected-to-other-cluster";
40+
}
41+
42+
if (!this.session.serviceProvider) {
43+
return "connecting";
44+
}
45+
46+
try {
47+
await this.session.serviceProvider.runCommand("admin", {
48+
ping: 1,
49+
});
2650

51+
return "connected";
52+
} catch (err: unknown) {
53+
const error = err instanceof Error ? err : new Error(String(err));
54+
logger.debug(
55+
LogId.atlasConnectFailure,
56+
"atlas-connect-cluster",
57+
`error querying cluster: ${error.message}`
58+
);
59+
return "unknown";
60+
}
61+
}
62+
63+
private async prepareClusterConnection(projectId: string, clusterName: string): Promise<string> {
2764
const cluster = await inspectCluster(this.session.apiClient, projectId, clusterName);
2865

2966
if (!cluster.connectionString) {
@@ -82,14 +119,32 @@ export class ConnectClusterTool extends AtlasToolBase {
82119
cn.username = username;
83120
cn.password = password;
84121
cn.searchParams.set("authSource", "admin");
85-
const connectionString = cn.toString();
122+
return cn.toString();
123+
}
86124

125+
private async connectToCluster(projectId: string, clusterName: string, connectionString: string): Promise<void> {
87126
let lastError: Error | undefined = undefined;
88127

89-
for (let i = 0; i < 20; i++) {
128+
logger.debug(
129+
LogId.atlasConnectAttempt,
130+
"atlas-connect-cluster",
131+
`attempting to connect to cluster: ${this.session.connectedAtlasCluster?.clusterName}`
132+
);
133+
134+
// try to connect for about 5 minutes
135+
for (let i = 0; i < 600; i++) {
136+
if (
137+
!this.session.connectedAtlasCluster ||
138+
this.session.connectedAtlasCluster.projectId != projectId ||
139+
this.session.connectedAtlasCluster.clusterName != clusterName
140+
) {
141+
throw new Error("Cluster connection aborted");
142+
}
143+
90144
try {
91-
await this.session.connectToMongoDB(connectionString, this.config.connectOptions);
92145
lastError = undefined;
146+
147+
await this.session.connectToMongoDB(connectionString, this.config.connectOptions);
93148
break;
94149
} catch (err: unknown) {
95150
const error = err instanceof Error ? err : new Error(String(err));
@@ -107,14 +162,94 @@ export class ConnectClusterTool extends AtlasToolBase {
107162
}
108163

109164
if (lastError) {
165+
if (
166+
this.session.connectedAtlasCluster?.projectId == projectId &&
167+
this.session.connectedAtlasCluster?.clusterName == clusterName &&
168+
this.session.connectedAtlasCluster?.username
169+
) {
170+
void this.session.apiClient
171+
.deleteDatabaseUser({
172+
params: {
173+
path: {
174+
groupId: this.session.connectedAtlasCluster.projectId,
175+
username: this.session.connectedAtlasCluster.username,
176+
databaseName: "admin",
177+
},
178+
},
179+
})
180+
.catch((err: unknown) => {
181+
const error = err instanceof Error ? err : new Error(String(err));
182+
logger.debug(
183+
LogId.atlasConnectFailure,
184+
"atlas-connect-cluster",
185+
`error deleting database user: ${error.message}`
186+
);
187+
});
188+
}
189+
this.session.connectedAtlasCluster = undefined;
110190
throw lastError;
111191
}
112192

193+
logger.debug(
194+
LogId.atlasConnectSucceeded,
195+
"atlas-connect-cluster",
196+
`connected to cluster: ${this.session.connectedAtlasCluster?.clusterName}`
197+
);
198+
}
199+
200+
protected async execute({ projectId, clusterName }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
201+
for (let i = 0; i < 60; i++) {
202+
const state = await this.queryConnection(projectId, clusterName);
203+
switch (state) {
204+
case "connected": {
205+
return {
206+
content: [
207+
{
208+
type: "text",
209+
text: `Connected to cluster "${clusterName}".`,
210+
},
211+
],
212+
};
213+
}
214+
case "connecting": {
215+
break;
216+
}
217+
case "connected-to-other-cluster":
218+
case "disconnected":
219+
case "unknown":
220+
default: {
221+
await this.session.disconnect();
222+
const connectionString = await this.prepareClusterConnection(projectId, clusterName);
223+
224+
// try to connect for about 5 minutes asynchronously
225+
void this.connectToCluster(projectId, clusterName, connectionString).catch((err: unknown) => {
226+
const error = err instanceof Error ? err : new Error(String(err));
227+
logger.error(
228+
LogId.atlasConnectFailure,
229+
"atlas-connect-cluster",
230+
`error connecting to cluster: ${error.message}`
231+
);
232+
});
233+
break;
234+
}
235+
}
236+
237+
await sleep(500);
238+
}
239+
113240
return {
114241
content: [
115242
{
116-
type: "text",
117-
text: `Connected to cluster "${clusterName}"`,
243+
type: "text" as const,
244+
text: `Attempting to connect to cluster "${clusterName}"...`,
245+
},
246+
{
247+
type: "text" as const,
248+
text: `Warning: Provisioning a user and connecting to the cluster may take more time, please check again in a few seconds.`,
249+
},
250+
{
251+
type: "text" as const,
252+
text: `Warning: Make sure your IP address was enabled in the allow list setting of the Atlas cluster.`,
118253
},
119254
],
120255
};

src/tools/mongodb/connect/connect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class ConnectTool extends MongoDBToolBase {
4646

4747
constructor(session: Session, config: UserConfig, telemetry: Telemetry) {
4848
super(session, config, telemetry);
49-
session.on("close", () => {
49+
session.on("disconnect", () => {
5050
this.updateMetadata();
5151
});
5252
}

src/tools/mongodb/mongodbTool.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,25 @@ export abstract class MongoDBToolBase extends ToolBase {
1616
public category: ToolCategory = "mongodb";
1717

1818
protected async ensureConnected(): Promise<NodeDriverServiceProvider> {
19-
if (!this.session.serviceProvider && this.config.connectionString) {
20-
try {
21-
await this.connectToMongoDB(this.config.connectionString);
22-
} catch (error) {
23-
logger.error(
24-
LogId.mongodbConnectFailure,
25-
"mongodbTool",
26-
`Failed to connect to MongoDB instance using the connection string from the config: ${error as string}`
19+
if (!this.session.serviceProvider) {
20+
if (this.session.connectedAtlasCluster) {
21+
throw new MongoDBError(
22+
ErrorCodes.NotConnectedToMongoDB,
23+
`Attempting to connect to Atlas cluster "${this.session.connectedAtlasCluster.clusterName}", try again in a few seconds.`
2724
);
28-
throw new MongoDBError(ErrorCodes.MisconfiguredConnectionString, "Not connected to MongoDB.");
25+
}
26+
27+
if (this.config.connectionString) {
28+
try {
29+
await this.connectToMongoDB(this.config.connectionString);
30+
} catch (error) {
31+
logger.error(
32+
LogId.mongodbConnectFailure,
33+
"mongodbTool",
34+
`Failed to connect to MongoDB instance using the connection string from the config: ${error as string}`
35+
);
36+
throw new MongoDBError(ErrorCodes.MisconfiguredConnectionString, "Not connected to MongoDB.");
37+
}
2938
}
3039
}
3140

tests/integration/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ export function setupIntegrationTest(getUserConfig: () => UserConfig): Integrati
8484
});
8585

8686
afterEach(async () => {
87-
if (mcpServer) {
88-
await mcpServer.session.close();
87+
if (mcpServer && !mcpServer.session.connectedAtlasCluster) {
88+
await mcpServer.session.disconnect();
8989
}
9090
});
9191

0 commit comments

Comments
 (0)