Skip to content

Commit c5c91e9

Browse files
committed
feat: update connectionString appName param - [MCP-68]
1 parent 5fcbf05 commit c5c91e9

File tree

10 files changed

+261
-108
lines changed

10 files changed

+261
-108
lines changed

src/common/session.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,15 @@ export class Session extends EventEmitter<{
9898
}
9999

100100
async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise<void> {
101-
connectionString = setAppNameParamIfMissing({
101+
// Use the extended appName format with deviceId and clientName
102+
connectionString = await setAppNameParamIfMissing({
102103
connectionString,
103-
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
104+
components: {
105+
appName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
106+
clientName: this.agentRunner?.name || "unknown",
107+
},
104108
});
109+
105110
this.serviceProvider = await NodeDriverServiceProvider.connect(connectionString, {
106111
productDocsLink: "https://github.com/mongodb-js/mongodb-mcp-server/",
107112
productName: "MongoDB MCP",

src/helpers/connectionOptions.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,51 @@
11
import { MongoClientOptions } from "mongodb";
22
import ConnectionString from "mongodb-connection-string-url";
3+
import { getDeviceIdForConnection } from "./deviceId.js";
34

4-
export function setAppNameParamIfMissing({
5+
export interface AppNameComponents {
6+
appName: string;
7+
deviceId?: string;
8+
clientName?: string;
9+
}
10+
11+
/**
12+
* Sets the appName parameter with the extended format: appName--deviceId--clientName
13+
* Only sets the appName if it's not already present in the connection string
14+
* @param connectionString - The connection string to modify
15+
* @param components - The components to build the appName from
16+
* @returns The modified connection string
17+
*/
18+
export async function setAppNameParamIfMissing({
519
connectionString,
6-
defaultAppName,
20+
components,
721
}: {
822
connectionString: string;
9-
defaultAppName?: string;
10-
}): string {
23+
components: AppNameComponents;
24+
}): Promise<string> {
1125
const connectionStringUrl = new ConnectionString(connectionString);
12-
1326
const searchParams = connectionStringUrl.typedSearchParams<MongoClientOptions>();
1427

15-
if (!searchParams.has("appName") && defaultAppName !== undefined) {
16-
searchParams.set("appName", defaultAppName);
28+
// Only set appName if it's not already present
29+
if (searchParams.has("appName")) {
30+
return connectionStringUrl.toString();
31+
}
32+
33+
// Get deviceId if not provided
34+
let deviceId = components.deviceId;
35+
if (!deviceId) {
36+
deviceId = await getDeviceIdForConnection();
1737
}
1838

39+
// Get clientName if not provided
40+
let clientName = components.clientName;
41+
if (!clientName) {
42+
clientName = "unknown";
43+
}
44+
45+
// Build the extended appName format: appName--deviceId--clientName
46+
const extendedAppName = `${components.appName}--${deviceId}--${clientName}`;
47+
48+
searchParams.set("appName", extendedAppName);
49+
1950
return connectionStringUrl.toString();
2051
}

src/helpers/deviceId.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { getDeviceId } from "@mongodb-js/device-id";
2+
import nodeMachineId from "node-machine-id";
3+
import logger, { LogId } from "../common/logger.js";
4+
5+
export const DEVICE_ID_TIMEOUT = 3000;
6+
7+
/**
8+
* Sets the appName parameter with the extended format: appName--deviceId--clientName
9+
* Only sets the appName if it's not already present in the connection string
10+
*
11+
* @param connectionString - The connection string to modify
12+
* @param components - The components to build the appName from
13+
* @returns Promise that resolves to the modified connection string
14+
*
15+
* @example
16+
* ```typescript
17+
* const result = await setExtendedAppNameParam({
18+
* connectionString: "mongodb://localhost:27017",
19+
* components: { appName: "MyApp", clientName: "Cursor" }
20+
* });
21+
* // Result: "mongodb://localhost:27017/?appName=MyApp--deviceId--Cursor"
22+
* ```
23+
*/
24+
export async function getDeviceIdForConnection(): Promise<string> {
25+
try {
26+
const deviceId = await getDeviceId({
27+
getMachineId: () => nodeMachineId.machineId(true),
28+
onError: (reason, error) => {
29+
switch (reason) {
30+
case "resolutionError":
31+
logger.debug(LogId.telemetryDeviceIdFailure, "deviceId", String(error));
32+
break;
33+
case "timeout":
34+
logger.debug(LogId.telemetryDeviceIdTimeout, "deviceId", "Device ID retrieval timed out");
35+
break;
36+
case "abort":
37+
// No need to log in the case of aborts
38+
break;
39+
}
40+
},
41+
abortSignal: new AbortController().signal,
42+
});
43+
return deviceId;
44+
} catch (error) {
45+
logger.debug(LogId.telemetryDeviceIdFailure, "deviceId", `Failed to get device ID: ${String(error)}`);
46+
return "unknown";
47+
}
48+
}

src/telemetry/telemetry.ts

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,27 @@ import logger, { LogId } from "../common/logger.js";
55
import { ApiClient } from "../common/atlas/apiClient.js";
66
import { MACHINE_METADATA } from "./constants.js";
77
import { EventCache } from "./eventCache.js";
8-
import nodeMachineId from "node-machine-id";
9-
import { getDeviceId } from "@mongodb-js/device-id";
8+
import { getDeviceIdForConnection } from "../helpers/deviceId.js";
109
import { detectContainerEnv } from "../helpers/container.js";
1110

1211
type EventResult = {
1312
success: boolean;
1413
error?: Error;
1514
};
1615

17-
export const DEVICE_ID_TIMEOUT = 3000;
18-
1916
export class Telemetry {
2017
private isBufferingEvents: boolean = true;
2118
/** Resolves when the setup is complete or a timeout occurs */
2219
public setupPromise: Promise<[string, boolean]> | undefined;
23-
private deviceIdAbortController = new AbortController();
2420
private eventCache: EventCache;
25-
private getRawMachineId: () => Promise<string>;
2621

2722
private constructor(
2823
private readonly session: Session,
2924
private readonly userConfig: UserConfig,
3025
private readonly commonProperties: CommonProperties,
31-
{ eventCache, getRawMachineId }: { eventCache: EventCache; getRawMachineId: () => Promise<string> }
26+
{ eventCache }: { eventCache: EventCache }
3227
) {
3328
this.eventCache = eventCache;
34-
this.getRawMachineId = getRawMachineId;
3529
}
3630

3731
static create(
@@ -40,14 +34,12 @@ export class Telemetry {
4034
{
4135
commonProperties = { ...MACHINE_METADATA },
4236
eventCache = EventCache.getInstance(),
43-
getRawMachineId = () => nodeMachineId.machineId(true),
4437
}: {
4538
eventCache?: EventCache;
46-
getRawMachineId?: () => Promise<string>;
4739
commonProperties?: CommonProperties;
4840
} = {}
4941
): Telemetry {
50-
const instance = new Telemetry(session, userConfig, commonProperties, { eventCache, getRawMachineId });
42+
const instance = new Telemetry(session, userConfig, commonProperties, { eventCache });
5143

5244
void instance.setup();
5345
return instance;
@@ -57,26 +49,7 @@ export class Telemetry {
5749
if (!this.isTelemetryEnabled()) {
5850
return;
5951
}
60-
this.setupPromise = Promise.all([
61-
getDeviceId({
62-
getMachineId: () => this.getRawMachineId(),
63-
onError: (reason, error) => {
64-
switch (reason) {
65-
case "resolutionError":
66-
logger.debug(LogId.telemetryDeviceIdFailure, "telemetry", String(error));
67-
break;
68-
case "timeout":
69-
logger.debug(LogId.telemetryDeviceIdTimeout, "telemetry", "Device ID retrieval timed out");
70-
break;
71-
case "abort":
72-
// No need to log in the case of aborts
73-
break;
74-
}
75-
},
76-
abortSignal: this.deviceIdAbortController.signal,
77-
}),
78-
detectContainerEnv(),
79-
]);
52+
this.setupPromise = Promise.all([getDeviceIdForConnection(), detectContainerEnv()]);
8053

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

@@ -87,8 +60,6 @@ export class Telemetry {
8760
}
8861

8962
public async close(): Promise<void> {
90-
this.deviceIdAbortController.abort();
91-
this.isBufferingEvents = false;
9263
await this.emitEvents(this.eventCache.getEvents());
9364
}
9465

src/tools/atlas/connect/connectCluster.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { generateSecurePassword } from "../../../helpers/generatePassword.js";
66
import logger, { LogId } from "../../../common/logger.js";
77
import { inspectCluster } from "../../../common/atlas/cluster.js";
88
import { ensureCurrentIpInAccessList } from "../../../common/atlas/accessListUtils.js";
9+
import { setAppNameParamIfMissing } from "../../../helpers/connectionOptions.js";
10+
import { packageInfo } from "../../../common/packageInfo.js";
911

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

@@ -120,7 +122,15 @@ export class ConnectClusterTool extends AtlasToolBase {
120122
cn.username = username;
121123
cn.password = password;
122124
cn.searchParams.set("authSource", "admin");
123-
return cn.toString();
125+
126+
const connectionStringWithAuth = cn.toString();
127+
return await setAppNameParamIfMissing({
128+
connectionString: connectionStringWithAuth,
129+
components: {
130+
appName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
131+
clientName: this.session.agentRunner?.name || "unknown",
132+
},
133+
});
124134
}
125135

126136
private async connectToCluster(projectId: string, clusterName: string, connectionString: string): Promise<void> {

tests/integration/telemetry.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { createHmac } from "crypto";
21
import { Telemetry } from "../../src/telemetry/telemetry.js";
32
import { Session } from "../../src/common/session.js";
43
import { config } from "../../src/common/config.js";
5-
import nodeMachineId from "node-machine-id";
4+
import { getDeviceIdForConnection } from "../../src/helpers/deviceId.js";
65
import { describe, expect, it } from "vitest";
76

87
describe("Telemetry", () => {
9-
it("should resolve the actual machine ID", async () => {
10-
const actualId: string = await nodeMachineId.machineId(true);
11-
12-
const actualHashedId = createHmac("sha256", actualId.toUpperCase()).update("atlascli").digest("hex");
8+
it("should resolve the actual device ID", async () => {
9+
const actualDeviceId = await getDeviceIdForConnection();
1310

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

2421
await telemetry.setupPromise;
2522

26-
expect(telemetry.getCommonProperties().device_id).toBe(actualHashedId);
23+
expect(telemetry.getCommonProperties().device_id).toBe(actualDeviceId);
2724
expect(telemetry["isBufferingEvents"]).toBe(false);
2825
});
2926
});

tests/integration/tools/mongodb/connect/connect.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
} from "../../../helpers.js";
88
import { config } from "../../../../../src/common/config.js";
99
import { defaultTestConfig, setupIntegrationTest } from "../../../helpers.js";
10-
import { beforeEach, describe, expect, it } from "vitest";
10+
import { beforeEach, describe, expect, it, vi } from "vitest";
11+
12+
// Mock the deviceId utility for consistent testing
13+
vi.mock("../../../../../src/helpers/deviceId.js", () => ({
14+
getDeviceIdForConnection: vi.fn().mockResolvedValue("test-device-id"),
15+
}));
1116

1217
describeWithMongoDB(
1318
"SwitchConnection tool",

tests/unit/common/session.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { Session } from "../../../src/common/session.js";
44
import { config } from "../../../src/common/config.js";
55

66
vi.mock("@mongosh/service-provider-node-driver");
7+
vi.mock("../../../src/helpers/deviceId.js", () => ({
8+
getDeviceIdForConnection: vi.fn().mockResolvedValue("test-device-id"),
9+
}));
10+
711
const MockNodeDriverServiceProvider = vi.mocked(NodeDriverServiceProvider);
812

913
describe("Session", () => {
@@ -50,11 +54,39 @@ describe("Session", () => {
5054
expect(connectMock).toHaveBeenCalledOnce();
5155
const connectionString = connectMock.mock.calls[0]?.[0];
5256
if (testCase.expectAppName) {
53-
expect(connectionString).toContain("appName=MongoDB+MCP+Server");
57+
// Check for the extended appName format: appName--deviceId--clientName
58+
expect(connectionString).toContain("appName=MongoDB+MCP+Server+");
59+
expect(connectionString).toContain("--test-device-id--");
5460
} else {
5561
expect(connectionString).not.toContain("appName=MongoDB+MCP+Server");
5662
}
5763
});
5864
}
65+
66+
it("should include client name when agent runner is set", async () => {
67+
session.setAgentRunner({ name: "test-client", version: "1.0.0" });
68+
69+
await session.connectToMongoDB("mongodb://localhost:27017", config.connectOptions);
70+
expect(session.serviceProvider).toBeDefined();
71+
72+
const connectMock = MockNodeDriverServiceProvider.connect;
73+
expect(connectMock).toHaveBeenCalledOnce();
74+
const connectionString = connectMock.mock.calls[0]?.[0];
75+
76+
// Should include the client name in the appName
77+
expect(connectionString).toContain("--test-device-id--test-client");
78+
});
79+
80+
it("should use 'unknown' for client name when agent runner is not set", async () => {
81+
await session.connectToMongoDB("mongodb://localhost:27017", config.connectOptions);
82+
expect(session.serviceProvider).toBeDefined();
83+
84+
const connectMock = MockNodeDriverServiceProvider.connect;
85+
expect(connectMock).toHaveBeenCalledOnce();
86+
const connectionString = connectMock.mock.calls[0]?.[0];
87+
88+
// Should use 'unknown' for client name when agent runner is not set
89+
expect(connectionString).toContain("--test-device-id--unknown");
90+
});
5991
});
6092
});

0 commit comments

Comments
 (0)