Skip to content

Commit a803f48

Browse files
authored
Update connection string app name if not present (#199)
1 parent 119a78a commit a803f48

16 files changed

+174
-9
lines changed

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default defineConfig([
4949
"global.d.ts",
5050
"eslint.config.js",
5151
"jest.config.ts",
52+
"src/types/*.d.ts",
5253
]),
5354
eslintPluginPrettierRecommended,
5455
]);

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"bson": "^6.10.3",
6767
"lru-cache": "^11.1.0",
6868
"mongodb": "^6.15.0",
69+
"mongodb-connection-string-url": "^3.0.2",
6970
"mongodb-log-writer": "^2.4.1",
7071
"mongodb-redact": "^1.1.6",
7172
"mongodb-schema": "^12.6.2",

src/common/atlas/apiClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { AccessToken, ClientCredentials } from "simple-oauth2";
44
import { ApiClientError } from "./apiClientError.js";
55
import { paths, operations } from "./openapi.js";
66
import { CommonProperties, TelemetryEvent } from "../../telemetry/types.js";
7-
import { packageInfo } from "../../packageInfo.js";
7+
import { packageInfo } from "../../helpers/packageInfo.js";
88

99
const ATLAS_API_VERSION = "2025-03-12";
1010

src/helpers/connectionOptions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MongoClientOptions } from "mongodb";
2+
import ConnectionString from "mongodb-connection-string-url";
3+
4+
export function setAppNameParamIfMissing({
5+
connectionString,
6+
defaultAppName,
7+
}: {
8+
connectionString: string;
9+
defaultAppName?: string;
10+
}): string {
11+
const connectionStringUrl = new ConnectionString(connectionString);
12+
13+
const searchParams = connectionStringUrl.typedSearchParams<MongoClientOptions>();
14+
15+
if (!searchParams.has("appName") && defaultAppName !== undefined) {
16+
searchParams.set("appName", defaultAppName);
17+
}
18+
19+
return connectionStringUrl.toString();
20+
}
File renamed without changes.

src/packageInfo.ts renamed to src/helpers/packageInfo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import packageJson from "../package.json" with { type: "json" };
1+
import packageJson from "../../package.json" with { type: "json" };
22

33
export const packageInfo = {
44
version: packageJson.version,

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { config } from "./config.js";
77
import { Session } from "./session.js";
88
import { Server } from "./server.js";
9-
import { packageInfo } from "./packageInfo.js";
9+
import { packageInfo } from "./helpers/packageInfo.js";
1010
import { Telemetry } from "./telemetry/telemetry.js";
1111

1212
try {

src/session.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Implementation } from "@modelcontextprotocol/sdk/types.js";
44
import logger, { LogId } from "./logger.js";
55
import EventEmitter from "events";
66
import { ConnectOptions } from "./config.js";
7+
import { setAppNameParamIfMissing } from "./helpers/connectionOptions.js";
8+
import { packageInfo } from "./helpers/packageInfo.js";
79

810
export interface SessionOptions {
911
apiBaseUrl: string;
@@ -98,6 +100,10 @@ export class Session extends EventEmitter<{
98100
}
99101

100102
async connectToMongoDB(connectionString: string, connectOptions: ConnectOptions): Promise<void> {
103+
connectionString = setAppNameParamIfMissing({
104+
connectionString,
105+
defaultAppName: `${packageInfo.mcpServerName} ${packageInfo.version}`,
106+
});
101107
const provider = await NodeDriverServiceProvider.connect(connectionString, {
102108
productDocsLink: "https://docs.mongodb.com/todo-mcp",
103109
productName: "MongoDB MCP",

src/telemetry/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { packageInfo } from "../packageInfo.js";
1+
import { packageInfo } from "../helpers/packageInfo.js";
22
import { type CommonStaticProperties } from "./types.js";
33

44
/**

src/telemetry/telemetry.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { MACHINE_METADATA } from "./constants.js";
77
import { EventCache } from "./eventCache.js";
88
import { createHmac } from "crypto";
99
import nodeMachineId from "node-machine-id";
10-
import { DeferredPromise } from "../deferred-promise.js";
10+
import { DeferredPromise } from "../helpers/deferred-promise.js";
1111

1212
type EventResult = {
1313
success: boolean;
@@ -40,7 +40,6 @@ export class Telemetry {
4040
commonProperties = { ...MACHINE_METADATA },
4141
eventCache = EventCache.getInstance(),
4242

43-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
4443
getRawMachineId = () => nodeMachineId.machineId(true),
4544
}: {
4645
eventCache?: EventCache;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
declare module "mongodb-connection-string-url" {
2+
import { URL } from "whatwg-url";
3+
import { redactConnectionString, ConnectionStringRedactionOptions } from "./redact";
4+
export { redactConnectionString, ConnectionStringRedactionOptions };
5+
declare class CaseInsensitiveMap<K extends string = string> extends Map<K, string> {
6+
delete(name: K): boolean;
7+
get(name: K): string | undefined;
8+
has(name: K): boolean;
9+
set(name: K, value: any): this;
10+
_normalizeKey(name: any): K;
11+
}
12+
declare abstract class URLWithoutHost extends URL {
13+
abstract get host(): never;
14+
abstract set host(value: never);
15+
abstract get hostname(): never;
16+
abstract set hostname(value: never);
17+
abstract get port(): never;
18+
abstract set port(value: never);
19+
abstract get href(): string;
20+
abstract set href(value: string);
21+
}
22+
export interface ConnectionStringParsingOptions {
23+
looseValidation?: boolean;
24+
}
25+
export declare class ConnectionString extends URLWithoutHost {
26+
_hosts: string[];
27+
constructor(uri: string, options?: ConnectionStringParsingOptions);
28+
get host(): never;
29+
set host(_ignored: never);
30+
get hostname(): never;
31+
set hostname(_ignored: never);
32+
get port(): never;
33+
set port(_ignored: never);
34+
get href(): string;
35+
set href(_ignored: string);
36+
get isSRV(): boolean;
37+
get hosts(): string[];
38+
set hosts(list: string[]);
39+
toString(): string;
40+
clone(): ConnectionString;
41+
redact(options?: ConnectionStringRedactionOptions): ConnectionString;
42+
typedSearchParams<T extends {}>(): {
43+
append(name: keyof T & string, value: any): void;
44+
delete(name: keyof T & string): void;
45+
get(name: keyof T & string): string | null;
46+
getAll(name: keyof T & string): string[];
47+
has(name: keyof T & string): boolean;
48+
set(name: keyof T & string, value: any): void;
49+
keys(): IterableIterator<keyof T & string>;
50+
values(): IterableIterator<string>;
51+
entries(): IterableIterator<[keyof T & string, string]>;
52+
_normalizeKey(name: keyof T & string): string;
53+
[Symbol.iterator](): IterableIterator<[keyof T & string, string]>;
54+
sort(): void;
55+
forEach<THIS_ARG = void>(
56+
callback: (this: THIS_ARG, value: string, name: string, searchParams: any) => void,
57+
thisArg?: THIS_ARG | undefined
58+
): void;
59+
readonly [Symbol.toStringTag]: "URLSearchParams";
60+
};
61+
}
62+
export declare class CommaAndColonSeparatedRecord<
63+
K extends {} = Record<string, unknown>,
64+
> extends CaseInsensitiveMap<keyof K & string> {
65+
constructor(from?: string | null);
66+
toString(): string;
67+
}
68+
export default ConnectionString;
69+
}

tests/integration/telemetry.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import nodeMachineId from "node-machine-id";
66

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

1211
const actualHashedId = createHmac("sha256", actualId.toUpperCase()).update("atlascli").digest("hex");

tests/unit/deferred-promise.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DeferredPromise } from "../../src/deferred-promise.js";
1+
import { DeferredPromise } from "../../src/helpers/deferred-promise.js";
22
import { jest } from "@jest/globals";
33

44
describe("DeferredPromise", () => {

tests/unit/session.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { jest } from "@jest/globals";
2+
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
3+
import { Session } from "../../src/session.js";
4+
import { config } from "../../src/config.js";
5+
6+
jest.mock("@mongosh/service-provider-node-driver");
7+
const MockNodeDriverServiceProvider = NodeDriverServiceProvider as jest.MockedClass<typeof NodeDriverServiceProvider>;
8+
9+
describe("Session", () => {
10+
let session: Session;
11+
beforeEach(() => {
12+
session = new Session({
13+
apiClientId: "test-client-id",
14+
apiBaseUrl: "https://api.test.com",
15+
});
16+
17+
MockNodeDriverServiceProvider.connect = jest.fn(() =>
18+
Promise.resolve({} as unknown as NodeDriverServiceProvider)
19+
);
20+
});
21+
22+
describe("connectToMongoDB", () => {
23+
const testCases: {
24+
connectionString: string;
25+
expectAppName: boolean;
26+
name: string;
27+
}[] = [
28+
{
29+
connectionString: "mongodb://localhost:27017",
30+
expectAppName: true,
31+
name: "db without appName",
32+
},
33+
{
34+
connectionString: "mongodb://localhost:27017?appName=CustomAppName",
35+
expectAppName: false,
36+
name: "db with custom appName",
37+
},
38+
{
39+
connectionString:
40+
"mongodb+srv://test.mongodb.net/test?retryWrites=true&w=majority&appName=CustomAppName",
41+
expectAppName: false,
42+
name: "atlas db with custom appName",
43+
},
44+
];
45+
46+
for (const testCase of testCases) {
47+
it(`should update connection string for ${testCase.name}`, async () => {
48+
await session.connectToMongoDB(testCase.connectionString, config.connectOptions);
49+
expect(session.serviceProvider).toBeDefined();
50+
51+
// eslint-disable-next-line @typescript-eslint/unbound-method
52+
const connectMock = MockNodeDriverServiceProvider.connect as jest.Mock<
53+
typeof NodeDriverServiceProvider.connect
54+
>;
55+
expect(connectMock).toHaveBeenCalledOnce();
56+
const connectionString = connectMock.mock.calls[0][0];
57+
if (testCase.expectAppName) {
58+
expect(connectionString).toContain("appName=MongoDB+MCP+Server");
59+
} else {
60+
expect(connectionString).not.toContain("appName=MongoDB+MCP+Server");
61+
}
62+
});
63+
}
64+
});
65+
});

tests/unit/telemetry.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,11 @@ describe("Telemetry", () => {
303303
});
304304

305305
afterEach(() => {
306-
process.env.DO_NOT_TRACK = originalEnv;
306+
if (originalEnv) {
307+
process.env.DO_NOT_TRACK = originalEnv;
308+
} else {
309+
delete process.env.DO_NOT_TRACK;
310+
}
307311
});
308312

309313
it("should not send events", async () => {

0 commit comments

Comments
 (0)