Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
c5c91e9
feat: update connectionString appName param - [MCP-68]
blva Jul 28, 2025
4cf78c2
Merge remote-tracking branch 'origin/main' into MCP-68
blva Jul 28, 2025
026b91a
add removed test
blva Jul 28, 2025
680e1e1
update tests
blva Jul 28, 2025
92aab61
Merge remote-tracking branch 'origin/main' into MCP-68
blva Jul 31, 2025
eca40e1
add timeout test
blva Jul 31, 2025
6cf0ae6
Merge remote-tracking branch 'origin/main' into MCP-68
blva Jul 31, 2025
524d965
fix
blva Jul 31, 2025
72f1ab8
Update src/helpers/deviceId.ts
blva Jul 31, 2025
c3f0928
add buffering update back
blva Jul 31, 2025
f8de877
squashed commits
blva Jul 31, 2025
8048cf6
Merge remote-tracking branch 'origin/main' into MCP-68
blva Aug 18, 2025
0c620b2
move appName setting to connection manager
blva Aug 18, 2025
d148376
chore: rename agentRunner to mcpClient
blva Aug 18, 2025
13a0349
refactor and address some comments
blva Aug 18, 2025
bb97041
keep getMachineId under deviceId
blva Aug 18, 2025
5fb0284
chore: lint and test fix
blva Aug 18, 2025
a690850
fix typo
blva Aug 18, 2025
a2e1091
fix test
blva Aug 18, 2025
f964e12
fix: update appName and set it to unknown if not available
blva Aug 18, 2025
6e922e6
fix: fix test
blva Aug 18, 2025
4ba3a16
decouple error handling into it's own method
blva Aug 18, 2025
78d430a
more linting
blva Aug 18, 2025
ae1d6d0
reformat
blva Aug 19, 2025
68a462e
reformat and add integration test
blva Aug 19, 2025
720be15
Revert "reformat"
blva Aug 19, 2025
565ca2b
decouple config validation from connection
blva Aug 19, 2025
69609b4
lint
blva Aug 19, 2025
bdf1272
Merge remote-tracking branch 'origin/main' into MCP-68
blva Aug 19, 2025
658cdc8
new device id
blva Aug 20, 2025
dae4f06
simplify device id
blva Aug 20, 2025
858ce1e
fix linting
blva Aug 20, 2025
0bc1a9f
update test
blva Aug 21, 2025
b0972bd
address comment: inject deviceId
blva Aug 22, 2025
e570562
chore: update transport to close deviceId last
blva Aug 22, 2025
8e62d2e
Merge branch 'main' into MCP-68
blva Aug 22, 2025
0ed8d23
chore: add deviceId close to integration
blva Aug 22, 2025
abf285a
fix check
blva Aug 22, 2025
e7727ee
fix check
blva Aug 22, 2025
f9c22d2
Merge branch 'main' into MCP-68
blva Aug 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
45 changes: 38 additions & 7 deletions src/helpers/connectionOptions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,51 @@
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")) {
return connectionStringUrl.toString();
}

// Get deviceId if not provided
let deviceId = components.deviceId;
if (!deviceId) {
deviceId = await getDeviceIdForConnection();
}

// Get clientName if not provided
let clientName = components.clientName;
if (!clientName) {
clientName = "unknown";
}

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

searchParams.set("appName", extendedAppName);

return connectionStringUrl.toString();
}
44 changes: 44 additions & 0 deletions src/helpers/deviceId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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> {
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: new AbortController().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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

done! thanks

) {
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;
Copy link
Collaborator

Choose a reason for hiding this comment

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

would this.deviceId.close make sense here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I moved it to index.ts so I'm closing it there as well, makes sense?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

now moved to transport

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