Skip to content

feat: update connectionString appName param - [MCP-68] #406

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
8 changes: 6 additions & 2 deletions src/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,13 @@ export class Session extends EventEmitter<SessionEvents> {
}

async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise<void> {
connectionString = setAppNameParamIfMissing({
// Use the extended appName format with deviceId and clientName
connectionString = await setAppNameParamIfMissing({
connectionString,
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
components: {
appName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
clientName: this.agentRunner?.name || "unknown",
},
});

try {
Expand Down
36 changes: 29 additions & 7 deletions src/helpers/connectionOptions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
import { MongoClientOptions } from "mongodb";
import ConnectionString from "mongodb-connection-string-url";
import { getDeviceIdForConnection } from "./deviceId.js";

export function setAppNameParamIfMissing({
export interface AppNameComponents {
appName: string;
deviceId?: string;
clientName?: string;
}

/**
* Sets the appName parameter with the extended format: appName--deviceId--clientName
* Only sets the appName if it's not already present in the connection string
* @param connectionString - The connection string to modify
* @param components - The components to build the appName from
* @returns The modified connection string
*/
export async function setAppNameParamIfMissing({
connectionString,
defaultAppName,
components,
}: {
connectionString: string;
defaultAppName?: string;
}): string {
components: AppNameComponents;
}): Promise<string> {
const connectionStringUrl = new ConnectionString(connectionString);

const searchParams = connectionStringUrl.typedSearchParams<MongoClientOptions>();

if (!searchParams.has("appName") && defaultAppName !== undefined) {
searchParams.set("appName", defaultAppName);
// Only set appName if it's not already present
if (searchParams.has("appName")) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Compass / mongosh we only pass the device ID if it's an Atlas host to minimize visibility of this, can we also do this here?

return connectionStringUrl.toString();
}

const deviceId = components.deviceId || (await getDeviceIdForConnection());
Copy link
Collaborator

@gagik gagik Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't end up awaiting device ID a second time, can we organize it so this device ID gets passed from one point of entry and we never fallback to this function? deviceId doesn't get passed in connectToMongoDB right now.

const clientName = components.clientName || "unknown";

// Build the extended appName format: appName--deviceId--clientName
const extendedAppName = `${components.appName}--${deviceId}--${clientName}`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[q] what if components.appName is empty? should it be unknown like the others?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the code we are getting packageInfo stuff, should we not make it a parameter and get package info straight away?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the previous behaviour is that we would set it to appName: ${packageInfo.mcpServerName} ${packageInfo.version}, if it's not defined, so we always send it. I could move that logic here, is that what you mean with the packageInfo question?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would appName be defined? is it really a parameter? is there an env var?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

components.appName seems to be fixed as ${packageInfo.mcpServerName} ${packageInfo.version} always


searchParams.set("appName", extendedAppName);

return connectionStringUrl.toString();
}
46 changes: 46 additions & 0 deletions src/helpers/deviceId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getDeviceId } from "@mongodb-js/device-id";
import nodeMachineId from "node-machine-id";
import logger, { LogId } from "../common/logger.js";

export const DEVICE_ID_TIMEOUT = 3000;

/**
* Retrieves the device ID for telemetry purposes.
* The device ID is generated using the machine ID and additional logic to handle errors.
*
* @returns Promise that resolves to the device ID string
* If an error occurs during retrieval, the function returns "unknown".
*
* @example
* ```typescript
* const deviceId = await getDeviceIdForConnection();
* console.log(deviceId); // Outputs the device ID or "unknown" in case of failure
* ```
*/
export async function getDeviceIdForConnection(): Promise<string> {
const controller = new AbortController();

try {
const deviceId = await getDeviceId({
getMachineId: () => nodeMachineId.machineId(true),
onError: (reason, error) => {
switch (reason) {
case "resolutionError":
logger.debug(LogId.telemetryDeviceIdFailure, "deviceId", String(error));
break;
case "timeout":
logger.debug(LogId.telemetryDeviceIdTimeout, "deviceId", "Device ID retrieval timed out");
break;
case "abort":
// No need to log in the case of aborts
break;
}
},
abortSignal: controller.signal,
});
return deviceId;
} catch (error) {
logger.debug(LogId.telemetryDeviceIdFailure, "deviceId", `Failed to get device ID: ${String(error)}`);
return "unknown";
}
}
36 changes: 4 additions & 32 deletions src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,27 @@ import logger, { LogId } from "../common/logger.js";
import { ApiClient } from "../common/atlas/apiClient.js";
import { MACHINE_METADATA } from "./constants.js";
import { EventCache } from "./eventCache.js";
import nodeMachineId from "node-machine-id";
import { getDeviceId } from "@mongodb-js/device-id";
import { getDeviceIdForConnection } from "../helpers/deviceId.js";
import { detectContainerEnv } from "../helpers/container.js";

type EventResult = {
success: boolean;
error?: Error;
};

export const DEVICE_ID_TIMEOUT = 3000;

export class Telemetry {
private isBufferingEvents: boolean = true;
/** Resolves when the setup is complete or a timeout occurs */
public setupPromise: Promise<[string, boolean]> | undefined;
private deviceIdAbortController = new AbortController();
private eventCache: EventCache;
private getRawMachineId: () => Promise<string>;

private constructor(
private readonly session: Session,
private readonly userConfig: UserConfig,
private readonly commonProperties: CommonProperties,
{ eventCache, getRawMachineId }: { eventCache: EventCache; getRawMachineId: () => Promise<string> }
{ eventCache }: { eventCache: EventCache }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think it'd still be good to pass getDeviceIdForConnection for explicit DI. It also helps us avoid mocking the file which is a bit of JavaScript magic

) {
this.eventCache = eventCache;
this.getRawMachineId = getRawMachineId;
}

static create(
Expand All @@ -40,14 +34,12 @@ export class Telemetry {
{
commonProperties = { ...MACHINE_METADATA },
eventCache = EventCache.getInstance(),
getRawMachineId = () => nodeMachineId.machineId(true),
}: {
eventCache?: EventCache;
getRawMachineId?: () => Promise<string>;
commonProperties?: CommonProperties;
} = {}
): Telemetry {
const instance = new Telemetry(session, userConfig, commonProperties, { eventCache, getRawMachineId });
const instance = new Telemetry(session, userConfig, commonProperties, { eventCache });

void instance.setup();
return instance;
Expand All @@ -57,26 +49,7 @@ export class Telemetry {
if (!this.isTelemetryEnabled()) {
return;
}
this.setupPromise = Promise.all([
getDeviceId({
getMachineId: () => this.getRawMachineId(),
onError: (reason, error) => {
switch (reason) {
case "resolutionError":
logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error));
break;
case "timeout":
logger.debug(LogId.telemetryDeviceIdTimeout, "telemetry", "Device ID retrieval timed out");
break;
case "abort":
// No need to log in the case of aborts
break;
}
},
abortSignal: this.deviceIdAbortController.signal,
}),
detectContainerEnv(),
]);
this.setupPromise = Promise.all([getDeviceIdForConnection(), detectContainerEnv()]);

const [deviceId, containerEnv] = await this.setupPromise;

Expand All @@ -87,7 +60,6 @@ export class Telemetry {
}

public async close(): Promise<void> {
this.deviceIdAbortController.abort();
this.isBufferingEvents = false;
await this.emitEvents(this.eventCache.getEvents());
}
Expand Down
12 changes: 11 additions & 1 deletion src/tools/atlas/connect/connectCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { generateSecurePassword } from "../../../helpers/generatePassword.js";
import logger, { LogId } from "../../../common/logger.js";
import { inspectCluster } from "../../../common/atlas/cluster.js";
import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js";
import { setAppNameParamIfMissing } from "../../../helpers/connectionOptions.js";
import { packageInfo } from "../../../common/packageInfo.js";

const EXPIRY_MS = 1000 * 60 * 60 * 12; // 12 hours

Expand Down Expand Up @@ -120,7 +122,15 @@ export class ConnectClusterTool extends AtlasToolBase {
cn.username = username;
cn.password = password;
cn.searchParams.set("authSource", "admin");
return cn.toString();

const connectionStringWithAuth = cn.toString();
return await setAppNameParamIfMissing({
connectionString: connectionStringWithAuth,
components: {
appName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
clientName: this.session.agentRunner?.name || "unknown",
},
});
}

private async connectToCluster(projectId: string, clusterName: string, connectionString: string): Promise<void> {
Expand Down
11 changes: 4 additions & 7 deletions tests/integration/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { createHmac } from "crypto";
import { Telemetry } from "../../src/telemetry/telemetry.js";
import { Session } from "../../src/common/session.js";
import { config } from "../../src/common/config.js";
import nodeMachineId from "node-machine-id";
import { getDeviceIdForConnection } from "../../src/helpers/deviceId.js";
import { describe, expect, it } from "vitest";

describe("Telemetry", () => {
it("should resolve the actual machine ID", async () => {
const actualId: string = await nodeMachineId.machineId(true);

const actualHashedId = createHmac("sha256", actualId.toUpperCase()).update("atlascli").digest("hex");
it("should resolve the actual device ID", async () => {
const actualDeviceId = await getDeviceIdForConnection();

const telemetry = Telemetry.create(
new Session({
Expand All @@ -23,7 +20,7 @@ describe("Telemetry", () => {

await telemetry.setupPromise;

expect(telemetry.getCommonProperties().device_id).toBe(actualHashedId);
expect(telemetry.getCommonProperties().device_id).toBe(actualDeviceId);
expect(telemetry["isBufferingEvents"]).toBe(false);
});
});
7 changes: 6 additions & 1 deletion tests/integration/tools/mongodb/connect/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
} from "../../../helpers.js";
import { config } from "../../../../../src/common/config.js";
import { defaultTestConfig, setupIntegrationTest } from "../../../helpers.js";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the deviceId utility for consistent testing
vi.mock("../../../../../src/helpers/deviceId.js", () => ({
getDeviceIdForConnection: vi.fn().mockResolvedValue("test-device-id"),
}));

describeWithMongoDB(
"SwitchConnection tool",
Expand Down
34 changes: 33 additions & 1 deletion tests/unit/common/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { Session } from "../../../src/common/session.js";
import { config } from "../../../src/common/config.js";

vi.mock("@mongosh/service-provider-node-driver");
vi.mock("../../../src/helpers/deviceId.js", () => ({
getDeviceIdForConnection: vi.fn().mockResolvedValue("test-device-id"),
}));

const MockNodeDriverServiceProvider = vi.mocked(NodeDriverServiceProvider);

describe("Session", () => {
Expand Down Expand Up @@ -50,7 +54,9 @@ describe("Session", () => {
expect(connectMock).toHaveBeenCalledOnce();
const connectionString = connectMock.mock.calls[0]?.[0];
if (testCase.expectAppName) {
expect(connectionString).toContain("appName=MongoDB+MCP+Server");
// Check for the extended appName format: appName--deviceId--clientName
expect(connectionString).toContain("appName=MongoDB+MCP+Server+");
expect(connectionString).toContain("--test-device-id--");
} else {
expect(connectionString).not.toContain("appName=MongoDB+MCP+Server");
}
Expand All @@ -68,5 +74,31 @@ describe("Session", () => {
expect(connectionConfig?.proxy).toEqual({ useEnvironmentVariableProxies: true });
expect(connectionConfig?.applyProxyToOIDC).toEqual(true);
});

it("should include client name when agent runner is set", async () => {
session.setAgentRunner({ name: "test-client", version: "1.0.0" });

await session.connectToMongoDB("mongodb://localhost:27017", config.connectOptions);
expect(session.serviceProvider).toBeDefined();

const connectMock = MockNodeDriverServiceProvider.connect;
expect(connectMock).toHaveBeenCalledOnce();
const connectionString = connectMock.mock.calls[0]?.[0];

// Should include the client name in the appName
expect(connectionString).toContain("--test-device-id--test-client");
});

it("should use 'unknown' for client name when agent runner is not set", async () => {
await session.connectToMongoDB("mongodb://localhost:27017", config.connectOptions);
expect(session.serviceProvider).toBeDefined();

const connectMock = MockNodeDriverServiceProvider.connect;
expect(connectMock).toHaveBeenCalledOnce();
const connectionString = connectMock.mock.calls[0]?.[0];

// Should use 'unknown' for client name when agent runner is not set
expect(connectionString).toContain("--test-device-id--unknown");
});
});
});
Loading
Loading