Skip to content
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
"generate": "pnpm run generate:api && pnpm run generate:arguments",
"generate:api": "./scripts/generate.sh",
"generate:arguments": "tsx scripts/generateArguments.ts",
"pretest": "pnpm run build",
"test": "vitest --project eslint-rules --project unit-and-integration --coverage",
"pretest:accuracy": "pnpm run build",
"test:accuracy": "sh ./scripts/accuracy/runAccuracyTests.sh",
"test:long-running-tests": "vitest --project long-running-tests --coverage",
"atlas:cleanup": "vitest --project atlas-cleanup"
Expand Down
1 change: 1 addition & 0 deletions src/common/config/argsParserOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export const OPTIONS = {
boolean: [
"apiDeprecationErrors",
"apiStrict",
"dryRun",
"embeddingsValidation",
"help",
"indexCheck",
Expand Down
6 changes: 6 additions & 0 deletions src/common/config/userConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,10 @@ export const UserConfigSchema = z4.object({
)
.default([])
.describe("An array of preview features that are enabled."),
dryRun: z4
.boolean()
.default(false)
.describe(
"When true, runs the server in dry mode: dumps configuration and enabled tools, then exits without starting the server."
),
});
50 changes: 39 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,23 @@ import { StdioRunner } from "./transports/stdio.js";
import { StreamableHttpRunner } from "./transports/streamableHttp.js";
import { systemCA } from "@mongodb-js/devtools-proxy-support";
import { Keychain } from "./common/keychain.js";
import { DryRunModeRunner } from "./transports/dryModeRunner.js";

async function main(): Promise<void> {
systemCA().catch(() => undefined); // load system CA asynchronously as in mongosh

const config = createUserConfig();
assertHelpMode(config);
assertVersionMode(config);
if (config.help) {
handleHelpRequest();
}

if (config.version) {
handleVersionRequest();
}

if (config.dryRun) {
await handleDryRunRequest(config);
}

const transportRunner =
config.transport === "stdio"
Expand Down Expand Up @@ -133,17 +143,35 @@ main().catch((error: unknown) => {
process.exit(1);
});

function assertHelpMode(config: UserConfig): void | never {
if (config.help) {
console.log("For usage information refer to the README.md:");
console.log("https://github.com/mongodb-js/mongodb-mcp-server?tab=readme-ov-file#quick-start");
process.exit(0);
}
function handleHelpRequest(): never {
console.log("For usage information refer to the README.md:");
console.log("https://github.com/mongodb-js/mongodb-mcp-server?tab=readme-ov-file#quick-start");
process.exit(0);
}

function assertVersionMode(config: UserConfig): void | never {
if (config.version) {
console.log(packageInfo.version);
function handleVersionRequest(): never {
console.log(packageInfo.version);
process.exit(0);
}

export async function handleDryRunRequest(config: UserConfig): Promise<never> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this function might as well be moved to dryModeRunner.ts

Copy link
Collaborator

Choose a reason for hiding this comment

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

although I also am fine with keeping as is because of usage of process.exit

Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe these functions shouldn't process.exit themselves and instead return and then a larger function would process.exit?

Copy link
Collaborator Author

@himanshusinghs himanshusinghs Nov 26, 2025

Choose a reason for hiding this comment

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

I think this function might as well be moved to [dryModeRunner.ts]

It was originally there but I moved it to index file based on the suggestion here. For me, either is fine but I still moved it to index only to have all the CLI args handling in one place.

I think its fine for them to take control of exiting - that's a pattern I have seen commander where different args have different action paths and they all independently terminate the process.

try {
const runner = new DryRunModeRunner({
userConfig: config,
logger: {
log(message): void {
console.log(message);
},
error(message): void {
console.error(message);
},
},
});
await runner.start();
await runner.close();
process.exit(0);
} catch (error) {
console.error(`Fatal error running server in dry run mode: ${error as string}`);
process.exit(1);
}
}
52 changes: 52 additions & 0 deletions src/transports/dryModeRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { InMemoryTransport } from "./inMemoryTransport.js";
import { TransportRunnerBase, type TransportRunnerConfig } from "./base.js";
import { type Server } from "../server.js";

export type DryRunModeTestHelpers = {
logger: {
log(this: void, message: string): void;
error(this: void, message: string): void;
};
};

type DryRunModeRunnerConfig = TransportRunnerConfig & DryRunModeTestHelpers;

export class DryRunModeRunner extends TransportRunnerBase {
private server: Server | undefined;
private consoleLogger: DryRunModeTestHelpers["logger"];

constructor({ logger, ...transportRunnerConfig }: DryRunModeRunnerConfig) {
super(transportRunnerConfig);
this.consoleLogger = logger;
}

override async start(): Promise<void> {
this.server = await this.setupServer();
const transport = new InMemoryTransport();

await this.server.connect(transport);
this.dumpConfig();
this.dumpTools();
}

override async closeTransport(): Promise<void> {
await this.server?.close();
}

private dumpConfig(): void {
this.consoleLogger.log("Configuration:");
this.consoleLogger.log(JSON.stringify(this.userConfig, null, 2));
}

private dumpTools(): void {
const tools =
this.server?.tools
.filter((tool) => tool.isEnabled())
.map((tool) => ({
name: tool.name,
category: tool.category,
})) ?? [];
this.consoleLogger.log("Enabled tools:");
this.consoleLogger.log(JSON.stringify(tools, null, 2));
}
}
30 changes: 30 additions & 0 deletions tests/e2e/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import path from "path";
import { execFile } from "child_process";
import { promisify } from "util";
import { describe, expect, it } from "vitest";
import packageJson from "../../package.json" with { type: "json" };

const execFileAsync = promisify(execFile);
const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "index.js");

describe("CLI entrypoint", () => {
it("should handle version request", async () => {
const { stdout, stderr } = await execFileAsync(process.execPath, [CLI_PATH, "--version"]);
expect(stdout).toContain(packageJson.version);
expect(stderr).toEqual("");
});

it("should handle help request", async () => {
const { stdout, stderr } = await execFileAsync(process.execPath, [CLI_PATH, "--help"]);
expect(stdout).toContain("For usage information refer to the README.md");
expect(stderr).toEqual("");
});

it("should handle dry run request", async () => {
const { stdout } = await execFileAsync(process.execPath, [CLI_PATH, "--dryRun"]);
expect(stdout).toContain("Configuration:");
expect(stdout).toContain("Enabled tools:");
// We don't do stderr assertions because in our CI, for docker-less env
// atlas local tools push message on stderr stream.
});
});
2 changes: 1 addition & 1 deletion tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Server, type ServerOptions } from "../../src/server.js";
import { Telemetry } from "../../src/telemetry/telemetry.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "./inMemoryTransport.js";
import { InMemoryTransport } from "../../src/transports/inMemoryTransport.js";
import { type UserConfig } from "../../src/common/config/userConfig.js";
import { ResourceUpdatedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/tools/mongodb/mongodbTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Session } from "../../../../src/common/session.js";
import { CompositeLogger } from "../../../../src/common/logger.js";
import { DeviceId } from "../../../../src/helpers/deviceId.js";
import { ExportsManager } from "../../../../src/common/exportsManager.js";
import { InMemoryTransport } from "../../inMemoryTransport.js";
import { InMemoryTransport } from "../../../../src/transports/inMemoryTransport.js";
import { Telemetry } from "../../../../src/telemetry/telemetry.js";
import { Server } from "../../../../src/server.js";
import { type ConnectionErrorHandler, connectionErrorHandler } from "../../../../src/common/connectionErrorHandler.js";
Expand Down
106 changes: 37 additions & 69 deletions tests/unit/common/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,81 +25,49 @@ function createEnvironment(): {
};
}

// Expected hardcoded values (what we had before)
const expectedDefaults = {
apiBaseUrl: "https://cloud.mongodb.com/",
logPath: getLogPath(),
exportsPath: getExportsPath(),
exportTimeoutMs: 5 * 60 * 1000, // 5 minutes
exportCleanupIntervalMs: 2 * 60 * 1000, // 2 minutes
disabledTools: [],
telemetry: "enabled",
readOnly: false,
indexCheck: false,
confirmationRequiredTools: [
"atlas-create-access-list",
"atlas-create-db-user",
"drop-database",
"drop-collection",
"delete-many",
"drop-index",
],
transport: "stdio",
httpPort: 3000,
httpHost: "127.0.0.1",
loggers: ["disk", "mcp"],
idleTimeoutMs: 10 * 60 * 1000, // 10 minutes
notificationTimeoutMs: 9 * 60 * 1000, // 9 minutes
httpHeaders: {},
maxDocumentsPerQuery: 100,
maxBytesPerQuery: 16 * 1024 * 1024, // ~16 mb
atlasTemporaryDatabaseUserLifetimeMs: 4 * 60 * 60 * 1000, // 4 hours
voyageApiKey: "",
vectorSearchDimensions: 1024,
vectorSearchSimilarityFunction: "euclidean",
embeddingsValidation: true,
previewFeatures: [],
dryRun: false,
};

describe("config", () => {
it("should generate defaults from UserConfigSchema that match expected values", () => {
// Expected hardcoded values (what we had before)
const expectedDefaults = {
apiBaseUrl: "https://cloud.mongodb.com/",
logPath: getLogPath(),
exportsPath: getExportsPath(),
exportTimeoutMs: 5 * 60 * 1000, // 5 minutes
exportCleanupIntervalMs: 2 * 60 * 1000, // 2 minutes
disabledTools: [],
telemetry: "enabled",
readOnly: false,
indexCheck: false,
confirmationRequiredTools: [
"atlas-create-access-list",
"atlas-create-db-user",
"drop-database",
"drop-collection",
"delete-many",
"drop-index",
],
transport: "stdio",
httpPort: 3000,
httpHost: "127.0.0.1",
loggers: ["disk", "mcp"],
idleTimeoutMs: 10 * 60 * 1000, // 10 minutes
notificationTimeoutMs: 9 * 60 * 1000, // 9 minutes
httpHeaders: {},
maxDocumentsPerQuery: 100,
maxBytesPerQuery: 16 * 1024 * 1024, // ~16 mb
atlasTemporaryDatabaseUserLifetimeMs: 4 * 60 * 60 * 1000, // 4 hours
voyageApiKey: "",
vectorSearchDimensions: 1024,
vectorSearchSimilarityFunction: "euclidean",
embeddingsValidation: true,
previewFeatures: [],
};
expect(UserConfigSchema.parse({})).toStrictEqual(expectedDefaults);
});

it("should generate defaults when no config sources are populated", () => {
const expectedDefaults = {
apiBaseUrl: "https://cloud.mongodb.com/",
logPath: getLogPath(),
exportsPath: getExportsPath(),
exportTimeoutMs: 5 * 60 * 1000, // 5 minutes
exportCleanupIntervalMs: 2 * 60 * 1000, // 2 minutes
disabledTools: [],
telemetry: "enabled",
readOnly: false,
indexCheck: false,
confirmationRequiredTools: [
"atlas-create-access-list",
"atlas-create-db-user",
"drop-database",
"drop-collection",
"delete-many",
"drop-index",
],
transport: "stdio",
httpPort: 3000,
httpHost: "127.0.0.1",
loggers: ["disk", "mcp"],
idleTimeoutMs: 10 * 60 * 1000, // 10 minutes
notificationTimeoutMs: 9 * 60 * 1000, // 9 minutes
httpHeaders: {},
maxDocumentsPerQuery: 100,
maxBytesPerQuery: 16 * 1024 * 1024, // ~16 mb
atlasTemporaryDatabaseUserLifetimeMs: 4 * 60 * 60 * 1000, // 4 hours
voyageApiKey: "",
vectorSearchDimensions: 1024,
vectorSearchSimilarityFunction: "euclidean",
embeddingsValidation: true,
previewFeatures: [],
};
expect(createUserConfig()).toStrictEqual(expectedDefaults);
});

Expand Down
42 changes: 42 additions & 0 deletions tests/unit/transports/dryModeRunner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { DryRunModeRunner, type DryRunModeTestHelpers } from "../../../src/transports/dryModeRunner.js";
import { type UserConfig } from "../../../src/common/config/userConfig.js";
import { type TransportRunnerConfig } from "../../../src/transports/base.js";
import { defaultTestConfig } from "../../integration/helpers.js";

describe("DryModeRunner", () => {
let loggerMock: DryRunModeTestHelpers["logger"];
let runnerConfig: TransportRunnerConfig;

beforeEach(() => {
loggerMock = {
log: vi.fn(),
error: vi.fn(),
};
runnerConfig = {
userConfig: defaultTestConfig,
} as TransportRunnerConfig;
});

afterEach(() => {
vi.clearAllMocks();
});

it.each([{ transport: "http", httpHost: "127.0.0.1", httpPort: "3001" }, { transport: "stdio" }] as Array<
Partial<UserConfig>
>)("should handle dry run request for transport - $transport", async (partialConfig) => {
runnerConfig.userConfig = {
...runnerConfig.userConfig,
...partialConfig,
dryRun: true,
};
const runner = new DryRunModeRunner({ logger: loggerMock, ...runnerConfig });
await runner.start();
expect(loggerMock.log).toHaveBeenNthCalledWith(1, "Configuration:");
expect(loggerMock.log).toHaveBeenNthCalledWith(2, JSON.stringify(runnerConfig.userConfig, null, 2));
expect(loggerMock.log).toHaveBeenNthCalledWith(3, "Enabled tools:");
expect(loggerMock.log).toHaveBeenNthCalledWith(4, expect.stringContaining('"name": "connect"'));
// Because switch-connection is not enabled by default
expect(loggerMock.log).toHaveBeenNthCalledWith(4, expect.not.stringContaining('"name": "switch-connection"'));
});
});