Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 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
20 changes: 18 additions & 2 deletions src/common/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { packageInfo } from "./packageInfo.js";
import ConnectionString from "mongodb-connection-string-url";
import { MongoClientOptions } from "mongodb";
import { ErrorCodes, MongoDBError } from "./errors.js";
import { DeviceIdService } from "../helpers/deviceId.js";
import { AppNameComponents } from "../helpers/connectionOptions.js";

export interface AtlasClusterConnectionInfo {
username: string;
Expand Down Expand Up @@ -67,11 +69,19 @@ export interface ConnectionManagerEvents {

export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
private state: AnyConnectionState;
private deviceId: DeviceIdService;
private clientName: string;

constructor() {
super();

this.state = { tag: "disconnected" };
this.deviceId = DeviceIdService.getInstance();
this.clientName = "unknown";
}

setClientName(clientName: string): void {
this.clientName = clientName;
}

async connect(settings: ConnectionSettings): Promise<AnyConnectionState> {
Expand All @@ -84,9 +94,15 @@ export class ConnectionManager extends EventEmitter<ConnectionManagerEvents> {
let serviceProvider: NodeDriverServiceProvider;
try {
settings = { ...settings };
settings.connectionString = setAppNameParamIfMissing({
const appNameComponents: AppNameComponents = {
appName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
deviceId: this.deviceId.getDeviceId(),
clientName: this.clientName,
};

settings.connectionString = await setAppNameParamIfMissing({
connectionString: settings.connectionString,
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
components: appNameComponents,
});

serviceProvider = await NodeDriverServiceProvider.connect(settings.connectionString, {
Expand Down
5 changes: 3 additions & 2 deletions src/common/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const LogId = {
serverClosed: mongoLogId(1_000_004),
serverCloseFailure: mongoLogId(1_000_005),
serverDuplicateLoggers: mongoLogId(1_000_006),
serverMcpClientSet: mongoLogId(1_000_007),

atlasCheckCredentials: mongoLogId(1_001_001),
atlasDeleteDatabaseUserFailure: mongoLogId(1_001_002),
Expand All @@ -30,8 +31,8 @@ export const LogId = {
telemetryEmitStart: mongoLogId(1_002_003),
telemetryEmitSuccess: mongoLogId(1_002_004),
telemetryMetadataError: mongoLogId(1_002_005),
telemetryDeviceIdFailure: mongoLogId(1_002_006),
telemetryDeviceIdTimeout: mongoLogId(1_002_007),
deviceIdResolutionError: mongoLogId(1_002_006),
deviceIdTimeout: mongoLogId(1_002_007),

toolExecute: mongoLogId(1_003_001),
toolExecuteFailure: mongoLogId(1_003_002),
Expand Down
30 changes: 21 additions & 9 deletions src/common/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ export class Session extends EventEmitter<SessionEvents> {
readonly exportsManager: ExportsManager;
readonly connectionManager: ConnectionManager;
readonly apiClient: ApiClient;
agentRunner?: {
name: string;
version: string;
mcpClient?: {
name?: string;
version?: string;
title?: string;
};

public logger: CompositeLogger;
Expand Down Expand Up @@ -69,13 +70,24 @@ export class Session extends EventEmitter<SessionEvents> {
this.connectionManager.on("connection-errored", (error) => this.emit("connection-error", error.errorReason));
}

setAgentRunner(agentRunner: Implementation | undefined): void {
if (agentRunner?.name && agentRunner?.version) {
this.agentRunner = {
name: agentRunner.name,
version: agentRunner.version,
};
setMcpClient(mcpClient: Implementation | undefined): void {
if (!mcpClient) {
this.connectionManager.setClientName("unknown");
this.logger.debug({
id: LogId.serverMcpClientSet,
context: "session",
message: "MCP client info not found",
});
}

this.mcpClient = {
name: mcpClient?.name || "unknown",
version: mcpClient?.version || "unknown",
title: mcpClient?.title || "unknown",
};

// Set the client name on the connection manager for appName generation
this.connectionManager.setClientName(this.mcpClient.name || "unknown");
}

async disconnect(): Promise<void> {
Expand Down
53 changes: 46 additions & 7 deletions src/helpers/connectionOptions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,59 @@
import { MongoClientOptions } from "mongodb";
import ConnectionString from "mongodb-connection-string-url";

export function setAppNameParamIfMissing({
export interface AppNameComponents {
appName: string;
deviceId?: Promise<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();
}

const appName = components.appName || "unknown";
const deviceId = components.deviceId ? await components.deviceId : "unknown";
const clientName = components.clientName || "unknown";

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

searchParams.set("appName", extendedAppName);

return connectionStringUrl.toString();
}

/**
* Validates the connection string
* @param connectionString - The connection string to validate
* @param looseValidation - Whether to allow loose validation
* @returns void
* @throws Error if the connection string is invalid
*/
export function validateConnectionString(connectionString: string, looseValidation: boolean): void {
try {
new ConnectionString(connectionString, { looseValidation });
} catch (error) {
throw new Error(
`Invalid connection string with error: ${error instanceof Error ? error.message : String(error)}`
);
}
}
170 changes: 170 additions & 0 deletions src/helpers/deviceId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { getDeviceId } from "@mongodb-js/device-id";
import nodeMachineId from "node-machine-id";
import { LogId, LoggerBase } from "../common/logger.js";

export const DEVICE_ID_TIMEOUT = 3000;

/**
* Singleton class for managing device ID retrieval and caching.
* Starts device ID calculation early and is shared across all services.
*/
export class DeviceIdService {
private static instance: DeviceIdService | undefined = undefined;
private deviceId: string | undefined = undefined;
private deviceIdPromise: Promise<string> | undefined = undefined;
private abortController: AbortController | undefined = undefined;
private logger: LoggerBase;
private readonly getMachineId: () => Promise<string>;
private timeout: number;

private constructor(logger: LoggerBase, timeout: number) {
this.logger = logger;
this.timeout = timeout;
this.getMachineId = (): Promise<string> => nodeMachineId.machineId(true);
// Start device ID calculation immediately
this.startDeviceIdCalculation();
}

/**
* Initializes the DeviceIdService singleton with a logger.
* A separated init method is used to use a single instance of the logger.
* @param logger - The logger instance to use
* @returns The DeviceIdService instance
*/
public static init(logger: LoggerBase, timeout?: number): DeviceIdService {
if (DeviceIdService.instance) {
return DeviceIdService.instance;
}
DeviceIdService.instance = new DeviceIdService(logger, timeout ?? DEVICE_ID_TIMEOUT);
return DeviceIdService.instance;
}

/**
* Checks if the DeviceIdService is initialized.
* @returns True if the DeviceIdService is initialized, false otherwise
*/
public static isInitialized(): boolean {
return DeviceIdService.instance !== undefined;
}

/**
* Gets the singleton instance of DeviceIdService.
* @returns The DeviceIdService instance
*/
public static getInstance(): DeviceIdService {
if (!DeviceIdService.instance) {
throw new Error("DeviceIdService not initialized");
}
return DeviceIdService.instance;
}

/**
* Starts the device ID calculation process.
* This method is called automatically in the constructor.
*/
private startDeviceIdCalculation(): void {
if (this.deviceIdPromise) {
return;
}

this.abortController = new AbortController();
this.deviceIdPromise = this.calculateDeviceId();
}

/**
* Gets the device ID, waiting for the calculation to complete if necessary.
* @returns Promise that resolves to the device ID string
*/
public async getDeviceId(): Promise<string> {
if (this.deviceId !== undefined) {
return this.deviceId;
}

if (!this.deviceIdPromise) {
throw new Error("DeviceIdService calculation not started");
}

return this.deviceIdPromise;
}
/**
* Aborts any ongoing device ID calculation.
*/
public close(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = undefined;
}
this.deviceId = undefined;
this.deviceIdPromise = undefined;
DeviceIdService.instance = undefined;
}

/**
* Internal method that performs the actual device ID calculation.
*/
private async calculateDeviceId(): Promise<string> {
if (!this.abortController) {
throw new Error("Device ID calculation not started");
}

try {
const deviceId = await getDeviceId({
Copy link
Collaborator

Choose a reason for hiding this comment

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

getDeviceId already does plenty of error handling, including the abort error. I don't think this try/catch will ever catch anything

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

does it automatically assign unknown upon error? If so we can remove

getMachineId: this.getMachineId,
onError: (reason, error) => {
this.handleDeviceIdError(reason, String(error));
},
timeout: this.timeout,
abortSignal: this.abortController.signal,
});

// Cache the result
this.deviceId = deviceId;
return deviceId;
} catch (error) {
// Check if this was an abort error
if (error instanceof Error && error.name === "AbortError") {
throw error; // Re-throw abort errors
}

this.logger.debug({
id: LogId.deviceIdResolutionError,
context: "deviceId",
message: `Failed to get device ID: ${String(error)}`,
});

// Cache the fallback value
this.deviceId = "unknown";
return "unknown";
} finally {
this.abortController = undefined;
}
}

/**
* Handles device ID error.
* @param reason - The reason for the error
* @param error - The error object
*/
private handleDeviceIdError(reason: string, error: string): void {
switch (reason) {
case "resolutionError":
this.logger.debug({
id: LogId.deviceIdResolutionError,
context: "deviceId",
message: `Resolution error: ${String(error)}`,
});
break;
case "timeout":
this.logger.debug({
id: LogId.deviceIdTimeout,
context: "deviceId",
message: "Device ID retrieval timed out",
noRedaction: true,
});
break;
case "abort":
// No need to log in the case of aborts
break;
}
}
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { packageInfo } from "./common/packageInfo.js";
import { StdioRunner } from "./transports/stdio.js";
import { StreamableHttpRunner } from "./transports/streamableHttp.js";
import { systemCA } from "@mongodb-js/devtools-proxy-support";
import { DeviceIdService } from "./helpers/deviceId.js";

async function main(): Promise<void> {
systemCA().catch(() => undefined); // load system CA asynchronously as in mongosh
Expand All @@ -50,6 +51,7 @@ async function main(): Promise<void> {
assertVersionMode();

const transportRunner = config.transport === "stdio" ? new StdioRunner(config) : new StreamableHttpRunner(config);
const deviceId = DeviceIdService.init(transportRunner.logger);

const shutdown = (): void => {
transportRunner.logger.info({
Expand All @@ -61,6 +63,7 @@ async function main(): Promise<void> {
transportRunner
.close()
.then(() => {
deviceId.close();
transportRunner.logger.info({
id: LogId.serverClosed,
context: "server",
Expand All @@ -69,6 +72,7 @@ async function main(): Promise<void> {
process.exit(0);
})
.catch((error: unknown) => {
deviceId.close();
transportRunner.logger.error({
id: LogId.serverCloseFailure,
context: "server",
Expand Down
Loading
Loading