Skip to content

Commit 1bf59db

Browse files
authored
chore: refactor connections to use the new ConnectionManager to isolate long running processes like OIDC connections MCP-81 (#423)
1 parent a916186 commit 1bf59db

File tree

12 files changed

+488
-110
lines changed

12 files changed

+488
-110
lines changed

src/common/connectionManager.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { ConnectOptions } from "./config.js";
2+
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
3+
import EventEmitter from "events";
4+
import { setAppNameParamIfMissing } from "../helpers/connectionOptions.js";
5+
import { packageInfo } from "./packageInfo.js";
6+
import ConnectionString from "mongodb-connection-string-url";
7+
import { MongoClientOptions } from "mongodb";
8+
import { ErrorCodes, MongoDBError } from "./errors.js";
9+
10+
export interface AtlasClusterConnectionInfo {
11+
username: string;
12+
projectId: string;
13+
clusterName: string;
14+
expiryDate: Date;
15+
}
16+
17+
export interface ConnectionSettings extends ConnectOptions {
18+
connectionString: string;
19+
atlas?: AtlasClusterConnectionInfo;
20+
}
21+
22+
type ConnectionTag = "connected" | "connecting" | "disconnected" | "errored";
23+
type OIDCConnectionAuthType = "oidc-auth-flow" | "oidc-device-flow";
24+
export type ConnectionStringAuthType = "scram" | "ldap" | "kerberos" | OIDCConnectionAuthType | "x.509";
25+
26+
export interface ConnectionState {
27+
tag: ConnectionTag;
28+
connectionStringAuthType?: ConnectionStringAuthType;
29+
connectedAtlasCluster?: AtlasClusterConnectionInfo;
30+
}
31+
32+
export interface ConnectionStateConnected extends ConnectionState {
33+
tag: "connected";
34+
serviceProvider: NodeDriverServiceProvider;
35+
}
36+
37+
export interface ConnectionStateConnecting extends ConnectionState {
38+
tag: "connecting";
39+
serviceProvider: NodeDriverServiceProvider;
40+
oidcConnectionType: OIDCConnectionAuthType;
41+
oidcLoginUrl?: string;
42+
oidcUserCode?: string;
43+
}
44+
45+
export interface ConnectionStateDisconnected extends ConnectionState {
46+
tag: "disconnected";
47+
}
48+
49+
export interface ConnectionStateErrored extends ConnectionState {
50+
tag: "errored";
51+
errorReason: string;
52+
}
53+
54+
export type AnyConnectionState =
55+
| ConnectionStateConnected
56+
| ConnectionStateConnecting
57+
| ConnectionStateDisconnected
58+
| ConnectionStateErrored;
59+
60+
export interface ConnectionManagerEvents {
61+
"connection-requested": [AnyConnectionState];
62+
"connection-succeeded": [ConnectionStateConnected];
63+
"connection-timed-out": [ConnectionStateErrored];
64+
"connection-closed": [ConnectionStateDisconnected];
65+
"connection-errored": [ConnectionStateErrored];
66+
}
67+
68+
export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
69+
private state: AnyConnectionState;
70+
71+
constructor() {
72+
super();
73+
this.state = { tag: "disconnected" };
74+
}
75+
76+
async connect(settings: ConnectionSettings): Promise<AnyConnectionState> {
77+
this.emit("connection-requested", this.state);
78+
79+
if (this.state.tag === "connected" || this.state.tag === "connecting") {
80+
await this.disconnect();
81+
}
82+
83+
let serviceProvider: NodeDriverServiceProvider;
84+
try {
85+
settings = { ...settings };
86+
settings.connectionString = setAppNameParamIfMissing({
87+
connectionString: settings.connectionString,
88+
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
89+
});
90+
91+
serviceProvider = await NodeDriverServiceProvider.connect(settings.connectionString, {
92+
productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/",
93+
productName: "MongoDB MCP",
94+
readConcern: {
95+
level: settings.readConcern,
96+
},
97+
readPreference: settings.readPreference,
98+
writeConcern: {
99+
w: settings.writeConcern,
100+
},
101+
timeoutMS: settings.timeoutMS,
102+
proxy: { useEnvironmentVariableProxies: true },
103+
applyProxyToOIDC: true,
104+
});
105+
} catch (error: unknown) {
106+
const errorReason = error instanceof Error ? error.message : `${error as string}`;
107+
this.changeState("connection-errored", {
108+
tag: "errored",
109+
errorReason,
110+
connectedAtlasCluster: settings.atlas,
111+
});
112+
throw new MongoDBError(ErrorCodes.MisconfiguredConnectionString, errorReason);
113+
}
114+
115+
try {
116+
await serviceProvider?.runCommand?.("admin", { hello: 1 });
117+
118+
return this.changeState("connection-succeeded", {
119+
tag: "connected",
120+
connectedAtlasCluster: settings.atlas,
121+
serviceProvider,
122+
connectionStringAuthType: ConnectionManager.inferConnectionTypeFromSettings(settings),
123+
});
124+
} catch (error: unknown) {
125+
const errorReason = error instanceof Error ? error.message : `${error as string}`;
126+
this.changeState("connection-errored", {
127+
tag: "errored",
128+
errorReason,
129+
connectedAtlasCluster: settings.atlas,
130+
});
131+
throw new MongoDBError(ErrorCodes.NotConnectedToMongoDB, errorReason);
132+
}
133+
}
134+
135+
async disconnect(): Promise<ConnectionStateDisconnected | ConnectionStateErrored> {
136+
if (this.state.tag === "disconnected" || this.state.tag === "errored") {
137+
return this.state;
138+
}
139+
140+
if (this.state.tag === "connected" || this.state.tag === "connecting") {
141+
try {
142+
await this.state.serviceProvider?.close(true);
143+
} finally {
144+
this.changeState("connection-closed", {
145+
tag: "disconnected",
146+
});
147+
}
148+
}
149+
150+
return { tag: "disconnected" };
151+
}
152+
153+
get currentConnectionState(): AnyConnectionState {
154+
return this.state;
155+
}
156+
157+
changeState<Event extends keyof ConnectionManagerEvents, State extends ConnectionManagerEvents[Event][0]>(
158+
event: Event,
159+
newState: State
160+
): State {
161+
this.state = newState;
162+
// TypeScript doesn't seem to be happy with the spread operator and generics
163+
// eslint-disable-next-line
164+
this.emit(event, ...([newState] as any));
165+
return newState;
166+
}
167+
168+
static inferConnectionTypeFromSettings(settings: ConnectionSettings): ConnectionStringAuthType {
169+
const connString = new ConnectionString(settings.connectionString);
170+
const searchParams = connString.typedSearchParams<MongoClientOptions>();
171+
172+
switch (searchParams.get("authMechanism")) {
173+
case "MONGODB-OIDC": {
174+
return "oidc-auth-flow"; // TODO: depending on if we don't have a --browser later it can be oidc-device-flow
175+
}
176+
case "MONGODB-X509":
177+
return "x.509";
178+
case "GSSAPI":
179+
return "kerberos";
180+
case "PLAIN":
181+
if (searchParams.get("authSource") === "$external") {
182+
return "ldap";
183+
}
184+
return "scram";
185+
// default should catch also null, but eslint complains
186+
// about it.
187+
case null:
188+
default:
189+
return "scram";
190+
}
191+
}
192+
}

src/common/session.ts

Lines changed: 48 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
21
import { ApiClient, ApiClientCredentials } from "./atlas/apiClient.js";
32
import { Implementation } from "@modelcontextprotocol/sdk/types.js";
43
import logger, { LogId } from "./logger.js";
54
import EventEmitter from "events";
6-
import { ConnectOptions } from "./config.js";
7-
import { setAppNameParamIfMissing } from "../helpers/connectionOptions.js";
8-
import { packageInfo } from "./packageInfo.js";
5+
import {
6+
AtlasClusterConnectionInfo,
7+
ConnectionManager,
8+
ConnectionSettings,
9+
ConnectionStateConnected,
10+
} from "./connectionManager.js";
11+
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
12+
import { ErrorCodes, MongoDBError } from "./errors.js";
913

1014
export interface SessionOptions {
1115
apiBaseUrl: string;
1216
apiClientId?: string;
1317
apiClientSecret?: string;
18+
connectionManager?: ConnectionManager;
1419
}
1520

1621
export type SessionEvents = {
@@ -22,20 +27,14 @@ export type SessionEvents = {
2227

2328
export class Session extends EventEmitter<SessionEvents> {
2429
sessionId?: string;
25-
serviceProvider?: NodeDriverServiceProvider;
30+
connectionManager: ConnectionManager;
2631
apiClient: ApiClient;
2732
agentRunner?: {
2833
name: string;
2934
version: string;
3035
};
31-
connectedAtlasCluster?: {
32-
username: string;
33-
projectId: string;
34-
clusterName: string;
35-
expiryDate: Date;
36-
};
3736

38-
constructor({ apiBaseUrl, apiClientId, apiClientSecret }: SessionOptions) {
37+
constructor({ apiBaseUrl, apiClientId, apiClientSecret, connectionManager }: SessionOptions) {
3938
super();
4039

4140
const credentials: ApiClientCredentials | undefined =
@@ -46,10 +45,13 @@ export class Session extends EventEmitter<SessionEvents> {
4645
}
4746
: undefined;
4847

49-
this.apiClient = new ApiClient({
50-
baseUrl: apiBaseUrl,
51-
credentials,
52-
});
48+
this.apiClient = new ApiClient({ baseUrl: apiBaseUrl, credentials });
49+
50+
this.connectionManager = connectionManager ?? new ConnectionManager();
51+
this.connectionManager.on("connection-succeeded", () => this.emit("connect"));
52+
this.connectionManager.on("connection-timed-out", (error) => this.emit("connection-error", error.errorReason));
53+
this.connectionManager.on("connection-closed", () => this.emit("disconnect"));
54+
this.connectionManager.on("connection-errored", (error) => this.emit("connection-error", error.errorReason));
5355
}
5456

5557
setAgentRunner(agentRunner: Implementation | undefined) {
@@ -62,22 +64,22 @@ export class Session extends EventEmitter<SessionEvents> {
6264
}
6365

6466
async disconnect(): Promise<void> {
65-
if (this.serviceProvider) {
66-
try {
67-
await this.serviceProvider.close(true);
68-
} catch (err: unknown) {
69-
const error = err instanceof Error ? err : new Error(String(err));
70-
logger.error(LogId.mongodbDisconnectFailure, "Error closing service provider:", error.message);
71-
}
72-
this.serviceProvider = undefined;
67+
const atlasCluster = this.connectedAtlasCluster;
68+
69+
try {
70+
await this.connectionManager.disconnect();
71+
} catch (err: unknown) {
72+
const error = err instanceof Error ? err : new Error(String(err));
73+
logger.error(LogId.mongodbDisconnectFailure, "Error closing service provider:", error.message);
7374
}
74-
if (this.connectedAtlasCluster?.username && this.connectedAtlasCluster?.projectId) {
75+
76+
if (atlasCluster?.username && atlasCluster?.projectId) {
7577
void this.apiClient
7678
.deleteDatabaseUser({
7779
params: {
7880
path: {
79-
groupId: this.connectedAtlasCluster.projectId,
80-
username: this.connectedAtlasCluster.username,
81+
groupId: atlasCluster.projectId,
82+
username: atlasCluster.username,
8183
databaseName: "admin",
8284
},
8385
},
@@ -90,9 +92,7 @@ export class Session extends EventEmitter<SessionEvents> {
9092
`Error deleting previous database user: ${error.message}`
9193
);
9294
});
93-
this.connectedAtlasCluster = undefined;
9495
}
95-
this.emit("disconnect");
9696
}
9797

9898
async close(): Promise<void> {
@@ -101,35 +101,30 @@ export class Session extends EventEmitter<SessionEvents> {
101101
this.emit("close");
102102
}
103103

104-
async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise<void> {
105-
connectionString = setAppNameParamIfMissing({
106-
connectionString,
107-
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
108-
});
109-
104+
async connectToMongoDB(settings: ConnectionSettings): Promise<void> {
110105
try {
111-
this.serviceProvider = await NodeDriverServiceProvider.connect(connectionString, {
112-
productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/",
113-
productName: "MongoDB MCP",
114-
readConcern: {
115-
level: connectOptions.readConcern,
116-
},
117-
readPreference: connectOptions.readPreference,
118-
writeConcern: {
119-
w: connectOptions.writeConcern,
120-
},
121-
timeoutMS: connectOptions.timeoutMS,
122-
proxy: { useEnvironmentVariableProxies: true },
123-
applyProxyToOIDC: true,
124-
});
125-
126-
await this.serviceProvider?.runCommand?.("admin", { hello: 1 });
106+
await this.connectionManager.connect({ ...settings });
127107
} catch (error: unknown) {
128-
const message = error instanceof Error ? error.message : `${error as string}`;
108+
const message = error instanceof Error ? error.message : (error as string);
129109
this.emit("connection-error", message);
130110
throw error;
131111
}
112+
}
113+
114+
get isConnectedToMongoDB(): boolean {
115+
return this.connectionManager.currentConnectionState.tag === "connected";
116+
}
117+
118+
get serviceProvider(): NodeDriverServiceProvider {
119+
if (this.isConnectedToMongoDB) {
120+
const state = this.connectionManager.currentConnectionState as ConnectionStateConnected;
121+
return state.serviceProvider;
122+
}
123+
124+
throw new MongoDBError(ErrorCodes.NotConnectedToMongoDB, "Not connected to MongoDB");
125+
}
132126

133-
this.emit("connect");
127+
get connectedAtlasCluster(): AtlasClusterConnectionInfo | undefined {
128+
return this.connectionManager.currentConnectionState.connectedAtlasCluster;
134129
}
135130
}

src/resources/common/debug.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ type ConnectionStateDebuggingInformation = {
1010

1111
export class DebugResource extends ReactiveResource(
1212
{
13-
name: "debug-mongodb-connectivity",
14-
uri: "debug://mongodb-connectivity",
13+
name: "debug-mongodb",
14+
uri: "debug://mongodb",
1515
config: {
16-
description: "Debugging information for connectivity issues.",
16+
description:
17+
"Debugging information for MongoDB connectivity issues. Tracks the last connectivity error and attempt information.",
1718
},
1819
},
1920
{

0 commit comments

Comments
 (0)