Skip to content

Update connection string app name if not present #199

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

Merged
merged 8 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default defineConfig([
"global.d.ts",
"eslint.config.js",
"jest.config.ts",
"src/types/*.d.ts",
]),
eslintPluginPrettierRecommended,
]);
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"bson": "^6.10.3",
"lru-cache": "^11.1.0",
"mongodb": "^6.15.0",
"mongodb-connection-string-url": "^3.0.2",
"mongodb-log-writer": "^2.4.1",
"mongodb-redact": "^1.1.6",
"mongodb-schema": "^12.6.2",
Expand Down
2 changes: 1 addition & 1 deletion src/common/atlas/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AccessToken, ClientCredentials } from "simple-oauth2";
import { ApiClientError } from "./apiClientError.js";
import { paths, operations } from "./openapi.js";
import { CommonProperties, TelemetryEvent } from "../../telemetry/types.js";
import { packageInfo } from "../../packageInfo.js";
import { packageInfo } from "../../helpers/packageInfo.js";

const ATLAS_API_VERSION = "2025-03-12";

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

export function setAppNameParamIfMissing({
connectionString,
defaultAppName,
}: {
connectionString: string;
defaultAppName?: string;
}): string {
const connectionStringUrl = new ConnectionString(connectionString);

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

if (!searchParams.has("appName") && defaultAppName !== undefined) {
searchParams.set("appName", defaultAppName);
}

return connectionStringUrl.toString();
}
File renamed without changes.
2 changes: 1 addition & 1 deletion src/packageInfo.ts → src/helpers/packageInfo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import packageJson from "../package.json" with { type: "json" };
import packageJson from "../../package.json" with { type: "json" };

export const packageInfo = {
version: packageJson.version,
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { config } from "./config.js";
import { Session } from "./session.js";
import { Server } from "./server.js";
import { packageInfo } from "./packageInfo.js";
import { packageInfo } from "./helpers/packageInfo.js";
import { Telemetry } from "./telemetry/telemetry.js";

try {
Expand Down
6 changes: 6 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Implementation } from "@modelcontextprotocol/sdk/types.js";
import logger, { LogId } from "./logger.js";
import EventEmitter from "events";
import { ConnectOptions } from "./config.js";
import { setAppNameParamIfMissing } from "./helpers/connectionOptions.js";
import { packageInfo } from "./helpers/packageInfo.js";

export interface SessionOptions {
apiBaseUrl: string;
Expand Down Expand Up @@ -98,6 +100,10 @@ export class Session extends EventEmitter<{
}

async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise<void> {
connectionString = setAppNameParamIfMissing({
connectionString,
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
});
const provider = await NodeDriverServiceProvider.connect(connectionString, {
productDocsLink: "https://docs.mongodb.com/todo-mcp",
productName: "MongoDB MCP",
Expand Down
2 changes: 1 addition & 1 deletion src/telemetry/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { packageInfo } from "../packageInfo.js";
import { packageInfo } from "../helpers/packageInfo.js";
import { type CommonStaticProperties } from "./types.js";

/**
Expand Down
3 changes: 1 addition & 2 deletions src/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { MACHINE_METADATA } from "./constants.js";
import { EventCache } from "./eventCache.js";
import { createHmac } from "crypto";
import nodeMachineId from "node-machine-id";
import { DeferredPromise } from "../deferred-promise.js";
import { DeferredPromise } from "../helpers/deferred-promise.js";

type EventResult = {
success: boolean;
Expand Down Expand Up @@ -40,7 +40,6 @@ export class Telemetry {
commonProperties = { ...MACHINE_METADATA },
eventCache = EventCache.getInstance(),

// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
getRawMachineId = () => nodeMachineId.machineId(true),
}: {
eventCache?: EventCache;
Expand Down
69 changes: 69 additions & 0 deletions src/types/mongodb-connection-string-url.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
declare module "mongodb-connection-string-url" {
import { URL } from "whatwg-url";
import { redactConnectionString, ConnectionStringRedactionOptions } from "./redact";
export { redactConnectionString, ConnectionStringRedactionOptions };
declare class CaseInsensitiveMap<K extends string = string> extends Map<K, string> {
delete(name: K): boolean;
get(name: K): string | undefined;
has(name: K): boolean;
set(name: K, value: any): this;
_normalizeKey(name: any): K;
}
declare abstract class URLWithoutHost extends URL {
abstract get host(): never;
abstract set host(value: never);
abstract get hostname(): never;
abstract set hostname(value: never);
abstract get port(): never;
abstract set port(value: never);
abstract get href(): string;
abstract set href(value: string);
}
export interface ConnectionStringParsingOptions {
looseValidation?: boolean;
}
export declare class ConnectionString extends URLWithoutHost {
_hosts: string[];
constructor(uri: string, options?: ConnectionStringParsingOptions);
get host(): never;
set host(_ignored: never);
get hostname(): never;
set hostname(_ignored: never);
get port(): never;
set port(_ignored: never);
get href(): string;
set href(_ignored: string);
get isSRV(): boolean;
get hosts(): string[];
set hosts(list: string[]);
toString(): string;
clone(): ConnectionString;
redact(options?: ConnectionStringRedactionOptions): ConnectionString;
typedSearchParams<T extends {}>(): {
append(name: keyof T & string, value: any): void;
delete(name: keyof T & string): void;
get(name: keyof T & string): string | null;
getAll(name: keyof T & string): string[];
has(name: keyof T & string): boolean;
set(name: keyof T & string, value: any): void;
keys(): IterableIterator<keyof T & string>;
values(): IterableIterator<string>;
entries(): IterableIterator<[keyof T & string, string]>;
_normalizeKey(name: keyof T & string): string;
[Symbol.iterator](): IterableIterator<[keyof T & string, string]>;
sort(): void;
forEach<THIS_ARG = void>(
callback: (this: THIS_ARG, value: string, name: string, searchParams: any) => void,
thisArg?: THIS_ARG | undefined
): void;
readonly [Symbol.toStringTag]: "URLSearchParams";
};
}
export declare class CommaAndColonSeparatedRecord<
K extends {} = Record<string, unknown>,
> extends CaseInsensitiveMap<keyof K & string> {
constructor(from?: string | null);
toString(): string;
}
export default ConnectionString;
}
1 change: 0 additions & 1 deletion tests/integration/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import nodeMachineId from "node-machine-id";

describe("Telemetry", () => {
it("should resolve the actual machine ID", async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const actualId: string = await nodeMachineId.machineId(true);

const actualHashedId = createHmac("sha256", actualId.toUpperCase()).update("atlascli").digest("hex");
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/deferred-promise.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DeferredPromise } from "../../src/deferred-promise.js";
import { DeferredPromise } from "../../src/helpers/deferred-promise.js";
import { jest } from "@jest/globals";

describe("DeferredPromise", () => {
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { jest } from "@jest/globals";
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { Session } from "../../src/session.js";
import { config } from "../../src/config.js";

jest.mock("@mongosh/service-provider-node-driver");
const MockNodeDriverServiceProvider = NodeDriverServiceProvider as jest.MockedClass<typeof NodeDriverServiceProvider>;

describe("Session", () => {
let session: Session;
beforeEach(() => {
session = new Session({
apiClientId: "test-client-id",
apiBaseUrl: "https://api.test.com",
});

MockNodeDriverServiceProvider.connect = jest.fn(() =>
Promise.resolve({} as unknown as NodeDriverServiceProvider)
);
});

describe("connectToMongoDB", () => {
const testCases: {
connectionString: string;
expectAppName: boolean;
name: string;
}[] = [
{
connectionString: "mongodb://localhost:27017",
expectAppName: true,
name: "db without appName",
},
{
connectionString: "mongodb://localhost:27017?appName=CustomAppName",
expectAppName: false,
name: "db with custom appName",
},
{
connectionString:
"mongodb+srv://test.mongodb.net/test?retryWrites=true&w=majority&appName=CustomAppName",
expectAppName: false,
name: "atlas db with custom appName",
},
];

for (const testCase of testCases) {
it(`should update connection string for ${testCase.name}`, async () => {
await session.connectToMongoDB(testCase.connectionString, config.connectOptions);
expect(session.serviceProvider).toBeDefined();

// eslint-disable-next-line @typescript-eslint/unbound-method
const connectMock = MockNodeDriverServiceProvider.connect as jest.Mock<
typeof NodeDriverServiceProvider.connect
>;
expect(connectMock).toHaveBeenCalledOnce();
const connectionString = connectMock.mock.calls[0][0];
if (testCase.expectAppName) {
expect(connectionString).toContain("appName=MongoDB+MCP+Server");
} else {
expect(connectionString).not.toContain("appName=MongoDB+MCP+Server");
}
});
}
});
});
6 changes: 5 additions & 1 deletion tests/unit/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,11 @@ describe("Telemetry", () => {
});

afterEach(() => {
process.env.DO_NOT_TRACK = originalEnv;
if (originalEnv) {
process.env.DO_NOT_TRACK = originalEnv;
} else {
delete process.env.DO_NOT_TRACK;
}
});

it("should not send events", async () => {
Expand Down
Loading