Skip to content
36 changes: 22 additions & 14 deletions src/common/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ export type AnyConnectionState =
| ConnectionStateErrored;

export interface ConnectionManagerEvents {
"connection-requested": [AnyConnectionState];
"connection-succeeded": [ConnectionStateConnected];
"connection-timed-out": [ConnectionStateErrored];
"connection-closed": [ConnectionStateDisconnected];
"connection-errored": [ConnectionStateErrored];
"connection-request": [AnyConnectionState];
"connection-success": [ConnectionStateConnected];
"connection-time-out": [ConnectionStateErrored];
"connection-close": [ConnectionStateDisconnected];
"connection-error": [ConnectionStateErrored];
}

export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
Expand Down Expand Up @@ -101,14 +101,15 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
}

async connect(settings: ConnectionSettings): Promise<AnyConnectionState> {
this.emit("connection-requested", this.state);
this.emit("connection-request", this.state);

if (this.state.tag === "connected" || this.state.tag === "connecting") {
await this.disconnect();
}

let serviceProvider: NodeDriverServiceProvider;
let connectionInfo: ConnectionInfo;
let connectionStringAuthType: ConnectionStringAuthType = "scram";

try {
settings = { ...settings };
Expand Down Expand Up @@ -137,6 +138,11 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
connectionInfo.driverOptions.proxy ??= { useEnvironmentVariableProxies: true };
connectionInfo.driverOptions.applyProxyToOIDC ??= true;

connectionStringAuthType = ConnectionManager.inferConnectionTypeFromSettings(
this.userConfig,
connectionInfo
);

serviceProvider = await NodeDriverServiceProvider.connect(
connectionInfo.connectionString,
{
Expand All @@ -149,9 +155,10 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
);
} catch (error: unknown) {
const errorReason = error instanceof Error ? error.message : `${error as string}`;
this.changeState("connection-errored", {
this.changeState("connection-error", {
tag: "errored",
errorReason,
connectionStringAuthType,
connectedAtlasCluster: settings.atlas,
});
throw new MongoDBError(ErrorCodes.MisconfiguredConnectionString, errorReason);
Expand All @@ -162,7 +169,7 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
if (connectionType.startsWith("oidc")) {
void this.pingAndForget(serviceProvider);

return this.changeState("connection-requested", {
return this.changeState("connection-request", {
tag: "connecting",
connectedAtlasCluster: settings.atlas,
serviceProvider,
Expand All @@ -173,17 +180,18 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {

await serviceProvider?.runCommand?.("admin", { hello: 1 });

return this.changeState("connection-succeeded", {
return this.changeState("connection-success", {
tag: "connected",
connectedAtlasCluster: settings.atlas,
serviceProvider,
connectionStringAuthType: connectionType,
});
} catch (error: unknown) {
const errorReason = error instanceof Error ? error.message : `${error as string}`;
this.changeState("connection-errored", {
this.changeState("connection-error", {
tag: "errored",
errorReason,
connectionStringAuthType,
connectedAtlasCluster: settings.atlas,
});
throw new MongoDBError(ErrorCodes.NotConnectedToMongoDB, errorReason);
Expand All @@ -199,7 +207,7 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
try {
await this.state.serviceProvider?.close(true);
} finally {
this.changeState("connection-closed", {
this.changeState("connection-close", {
tag: "disconnected",
});
}
Expand Down Expand Up @@ -231,7 +239,7 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {

private onOidcAuthSucceeded(): void {
if (this.state.tag === "connecting" && this.state.connectionStringAuthType?.startsWith("oidc")) {
this.changeState("connection-succeeded", { ...this.state, tag: "connected" });
this.changeState("connection-success", { ...this.state, tag: "connected" });
}

this.logger.info({
Expand All @@ -243,7 +251,7 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {

private onOidcNotifyDeviceFlow(flowInfo: { verificationUrl: string; userCode: string }): void {
if (this.state.tag === "connecting" && this.state.connectionStringAuthType?.startsWith("oidc")) {
this.changeState("connection-requested", {
this.changeState("connection-request", {
...this.state,
tag: "connecting",
connectionStringAuthType: "oidc-device-flow",
Expand Down Expand Up @@ -317,7 +325,7 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
message: String(error),
});
} finally {
this.changeState("connection-errored", { tag: "errored", errorReason: String(error) });
this.changeState("connection-error", { tag: "errored", errorReason: String(error) });
}
}
}
19 changes: 7 additions & 12 deletions src/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ConnectionManager,
ConnectionSettings,
ConnectionStateConnected,
ConnectionStateErrored,
} from "./connectionManager.js";
import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { ErrorCodes, MongoDBError } from "./errors.js";
Expand All @@ -28,7 +29,7 @@ export type SessionEvents = {
connect: [];
close: [];
disconnect: [];
"connection-error": [string];
"connection-error": [ConnectionStateErrored];
};

export class Session extends EventEmitter<SessionEvents> {
Expand Down Expand Up @@ -66,10 +67,10 @@ export class Session extends EventEmitter<SessionEvents> {
this.apiClient = new ApiClient({ baseUrl: apiBaseUrl, credentials }, logger);
this.exportsManager = exportsManager;
this.connectionManager = connectionManager;
this.connectionManager.on("connection-succeeded", () => this.emit("connect"));
this.connectionManager.on("connection-timed-out", (error) => this.emit("connection-error", error.errorReason));
this.connectionManager.on("connection-closed", () => this.emit("disconnect"));
this.connectionManager.on("connection-errored", (error) => this.emit("connection-error", error.errorReason));
this.connectionManager.on("connection-success", () => this.emit("connect"));
this.connectionManager.on("connection-time-out", (error) => this.emit("connection-error", error));
this.connectionManager.on("connection-close", () => this.emit("disconnect"));
this.connectionManager.on("connection-error", (error) => this.emit("connection-error", error));
}

setMcpClient(mcpClient: Implementation | undefined): void {
Expand Down Expand Up @@ -136,13 +137,7 @@ export class Session extends EventEmitter<SessionEvents> {
}

async connectToMongoDB(settings: ConnectionSettings): Promise<void> {
try {
await this.connectionManager.connect({ ...settings });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : (error as string);
this.emit("connection-error", message);
throw error;
}
await this.connectionManager.connect({ ...settings });
}

get isConnectedToMongoDB(): boolean {
Expand Down
27 changes: 20 additions & 7 deletions src/resources/common/debug.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ReactiveResource } from "../resource.js";
import type { Telemetry } from "../../telemetry/telemetry.js";
import type { Session, UserConfig } from "../../lib.js";
import type { AtlasClusterConnectionInfo, ConnectionStateErrored } from "../../common/connectionManager.js";

type ConnectionStateDebuggingInformation = {
readonly tag: "connected" | "connecting" | "disconnected" | "errored";
readonly connectionStringAuthType?: "scram" | "ldap" | "kerberos" | "oidc-auth-flow" | "oidc-device-flow" | "x.509";
readonly oidcLoginUrl?: string;
readonly oidcUserCode?: string;
readonly errorReason?: string;
readonly connectedAtlasCluster?: AtlasClusterConnectionInfo;
};

export class DebugResource extends ReactiveResource<
Expand Down Expand Up @@ -35,15 +35,21 @@ export class DebugResource extends ReactiveResource<
}
reduce(
eventName: "connect" | "disconnect" | "close" | "connection-error",
event: string | undefined
event: ConnectionStateErrored | undefined
): ConnectionStateDebuggingInformation {
void event;

switch (eventName) {
case "connect":
return { tag: "connected" };
case "connection-error":
return { tag: "errored", errorReason: event };
case "connection-error": {
return {
tag: "errored",
connectionStringAuthType: event?.connectionStringAuthType,
connectedAtlasCluster: event?.connectedAtlasCluster,
errorReason:
event?.errorReason ??
"Could not find a reason. This might be a bug in the MCP Server. Please open an issue in https://github.com/mongodb-js/mongodb-mcp-server.",
};
}
case "disconnect":
case "close":
return { tag: "disconnected" };
Expand All @@ -59,6 +65,13 @@ export class DebugResource extends ReactiveResource<
break;
case "errored":
result += `The user is not connected to a MongoDB cluster because of an error.\n`;
if (this.current.connectedAtlasCluster) {
result += `Attempted connecting to Atlas Cluster "${this.current.connectedAtlasCluster.clusterName}" in project with id "${this.current.connectedAtlasCluster.projectId}".\n`;
}

if (this.current.connectionStringAuthType !== undefined) {
result += `The inferred authentication mechanism is "${this.current.connectionStringAuthType}".\n`;
}
result += `<error>${this.current.errorReason}</error>`;
break;
case "connecting":
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/common/connectionManager.oidc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe.skipIf(process.platform !== "linux")("ConnectionManager OIDC Tests", as
await connectionManager.disconnect();
// for testing, force disconnecting AND setting the connection to closed to reset the
// state of the connection manager
connectionManager.changeState("connection-closed", { tag: "disconnected" });
connectionManager.changeState("connection-close", { tag: "disconnected" });

await integration.connectMcpClient();
}, DEFAULT_TIMEOUT);
Expand Down
78 changes: 60 additions & 18 deletions tests/integration/common/connectionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,27 @@ describeWithMongoDB("Connection Manager", (integration) => {
await connectionManager().disconnect();
// for testing, force disconnecting AND setting the connection to closed to reset the
// state of the connection manager
connectionManager().changeState("connection-closed", { tag: "disconnected" });
connectionManager().changeState("connection-close", { tag: "disconnected" });
});

describe("when successfully connected", () => {
type ConnectionManagerSpies = {
"connection-requested": (event: ConnectionManagerEvents["connection-requested"][0]) => void;
"connection-succeeded": (event: ConnectionManagerEvents["connection-succeeded"][0]) => void;
"connection-timed-out": (event: ConnectionManagerEvents["connection-timed-out"][0]) => void;
"connection-closed": (event: ConnectionManagerEvents["connection-closed"][0]) => void;
"connection-errored": (event: ConnectionManagerEvents["connection-errored"][0]) => void;
"connection-request": (event: ConnectionManagerEvents["connection-request"][0]) => void;
"connection-success": (event: ConnectionManagerEvents["connection-success"][0]) => void;
"connection-time-out": (event: ConnectionManagerEvents["connection-time-out"][0]) => void;
"connection-close": (event: ConnectionManagerEvents["connection-close"][0]) => void;
"connection-error": (event: ConnectionManagerEvents["connection-error"][0]) => void;
};

let connectionManagerSpies: ConnectionManagerSpies;

beforeEach(async () => {
connectionManagerSpies = {
"connection-requested": vi.fn(),
"connection-succeeded": vi.fn(),
"connection-timed-out": vi.fn(),
"connection-closed": vi.fn(),
"connection-errored": vi.fn(),
"connection-request": vi.fn(),
"connection-success": vi.fn(),
"connection-time-out": vi.fn(),
"connection-close": vi.fn(),
"connection-error": vi.fn(),
};

for (const [event, spy] of Object.entries(connectionManagerSpies)) {
Expand All @@ -62,11 +62,11 @@ describeWithMongoDB("Connection Manager", (integration) => {
});

it("should notify that the connection was requested", () => {
expect(connectionManagerSpies["connection-requested"]).toHaveBeenCalledOnce();
expect(connectionManagerSpies["connection-request"]).toHaveBeenCalledOnce();
});

it("should notify that the connection was successful", () => {
expect(connectionManagerSpies["connection-succeeded"]).toHaveBeenCalledOnce();
expect(connectionManagerSpies["connection-success"]).toHaveBeenCalledOnce();
});

describe("when disconnects", () => {
Expand All @@ -75,7 +75,7 @@ describeWithMongoDB("Connection Manager", (integration) => {
});

it("should notify that it was disconnected before connecting", () => {
expect(connectionManagerSpies["connection-closed"]).toHaveBeenCalled();
expect(connectionManagerSpies["connection-close"]).toHaveBeenCalled();
});

it("should be marked explicitly as disconnected", () => {
Expand All @@ -91,11 +91,11 @@ describeWithMongoDB("Connection Manager", (integration) => {
});

it("should notify that it was disconnected before connecting", () => {
expect(connectionManagerSpies["connection-closed"]).toHaveBeenCalled();
expect(connectionManagerSpies["connection-close"]).toHaveBeenCalled();
});

it("should notify that it was connected again", () => {
expect(connectionManagerSpies["connection-succeeded"]).toHaveBeenCalled();
expect(connectionManagerSpies["connection-success"]).toHaveBeenCalled();
});

it("should be marked explicitly as connected", () => {
Expand All @@ -115,11 +115,53 @@ describeWithMongoDB("Connection Manager", (integration) => {
});

it("should notify that it was disconnected before connecting", () => {
expect(connectionManagerSpies["connection-closed"]).toHaveBeenCalled();
expect(connectionManagerSpies["connection-close"]).toHaveBeenCalled();
});

it("should notify that it failed connecting", () => {
expect(connectionManagerSpies["connection-errored"]).toHaveBeenCalled();
expect(connectionManagerSpies["connection-error"]).toHaveBeenCalledWith({
tag: "errored",
connectedAtlasCluster: undefined,
connectionStringAuthType: "scram",
errorReason: "Unable to parse localhost:xxxxx with URL",
});
});

it("should be marked explicitly as connected", () => {
expect(connectionManager().currentConnectionState.tag).toEqual("errored");
});
});

describe("when fails to connect to a new atlas cluster", () => {
const atlas = {
username: "",
projectId: "",
clusterName: "My Atlas Cluster",
expiryDate: new Date(),
};

beforeEach(async () => {
try {
await connectionManager().connect({
connectionString: "mongodb://localhost:xxxxx",
atlas,
});
} catch (_error: unknown) {
void _error;
}
});

it("should notify that it was disconnected before connecting", () => {
expect(connectionManagerSpies["connection-close"]).toHaveBeenCalled();
});

it("should notify that it failed connecting", () => {
expect(connectionManagerSpies["connection-error"]).toHaveBeenCalledWith({
tag: "errored",
connectedAtlasCluster: atlas,
connectionStringAuthType: "scram",
errorReason: "Unable to parse localhost:xxxxx with URL",
});
});

it("should be marked explicitly as connected", () => {
Expand Down
Loading
Loading