From 6086e74a09a70bb4f9230cbb34f3bd705e133800 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Mon, 24 Nov 2025 13:15:45 +0100 Subject: [PATCH 1/8] feat: introduces dry mode for CLI --- src/common/config/argsParserOptions.ts | 1 + src/common/config/userConfig.ts | 6 ++ src/index.ts | 2 + src/transports/dryModeRunner.ts | 79 +++++++++++++++++++ .../transports}/inMemoryTransport.ts | 0 tests/integration/helpers.ts | 2 +- .../tools/mongodb/mongodbTool.test.ts | 2 +- tests/unit/transports/dryModeRunner.test.ts | 48 +++++++++++ 8 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/transports/dryModeRunner.ts rename {tests/integration => src/transports}/inMemoryTransport.ts (100%) create mode 100644 tests/unit/transports/dryModeRunner.test.ts diff --git a/src/common/config/argsParserOptions.ts b/src/common/config/argsParserOptions.ts index a3a723c0f..dc4fc8aa5 100644 --- a/src/common/config/argsParserOptions.ts +++ b/src/common/config/argsParserOptions.ts @@ -59,6 +59,7 @@ export const OPTIONS = { boolean: [ "apiDeprecationErrors", "apiStrict", + "dry", "embeddingsValidation", "help", "indexCheck", diff --git a/src/common/config/userConfig.ts b/src/common/config/userConfig.ts index 91a38a647..1eae87406 100644 --- a/src/common/config/userConfig.ts +++ b/src/common/config/userConfig.ts @@ -173,4 +173,10 @@ export const UserConfigSchema = z4.object({ ) .default([]) .describe("An array of preview features that are enabled."), + dry: z4 + .boolean() + .default(false) + .describe( + "When true, runs the server in dry mode: dumps configuration and enabled tools, then exits without starting the server." + ), }); diff --git a/src/index.ts b/src/index.ts index fd030ede5..56cf290f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ 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 { DryModeRunner } from "./transports/dryModeRunner.js"; async function main(): Promise { systemCA().catch(() => undefined); // load system CA asynchronously as in mongosh @@ -51,6 +52,7 @@ async function main(): Promise { const config = createUserConfig(); assertHelpMode(config); assertVersionMode(config); + await DryModeRunner.assertDryMode({ userConfig: config }); const transportRunner = config.transport === "stdio" diff --git a/src/transports/dryModeRunner.ts b/src/transports/dryModeRunner.ts new file mode 100644 index 000000000..05dc5eedf --- /dev/null +++ b/src/transports/dryModeRunner.ts @@ -0,0 +1,79 @@ +import { InMemoryTransport } from "./inMemoryTransport.js"; +import { TransportRunnerBase, type TransportRunnerConfig } from "./base.js"; +import { type Server } from "../server.js"; + +export type DryModeTestHelpers = { + exit(this: void, exitCode: number): never; + logger: { + log(this: void, message: string): void; + error(this: void, message: string): void; + }; +}; + +type DryModeRunnerConfig = TransportRunnerConfig & DryModeTestHelpers; + +const defaultLogger: DryModeTestHelpers["logger"] = { + log(message) { + console.warn(message); + }, + error(message) { + console.error(message); + }, +}; + +export class DryModeRunner extends TransportRunnerBase { + private server: Server | undefined; + private exitProcess: DryModeTestHelpers["exit"]; + private consoleLogger: DryModeTestHelpers["logger"]; + + constructor({ exit, logger, ...transportRunnerConfig }: DryModeRunnerConfig) { + super(transportRunnerConfig); + this.exitProcess = exit; + this.consoleLogger = logger; + } + + async start(): Promise { + try { + this.server = await this.setupServer(); + const transport = new InMemoryTransport(); + + await this.server.connect(transport); + } catch (error: unknown) { + this.consoleLogger.error(`Fatal error running server: ${error as string}`); + this.exitProcess(1); + } + } + + async closeTransport(): Promise { + 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.map((tool) => ({ + name: tool.name, + description: tool.description, + category: tool.category, + })); + this.consoleLogger.log("Enabled tools:"); + this.consoleLogger.log(JSON.stringify(tools, null, 2)); + } + + static async assertDryMode( + runnerConfig: TransportRunnerConfig, + exit: DryModeTestHelpers["exit"] = (exitCode: number) => process.exit(exitCode), + logger: DryModeTestHelpers["logger"] = defaultLogger + ): Promise | never { + if (runnerConfig.userConfig.dry) { + const runner = new this({ ...runnerConfig, exit, logger }); + await runner.start(); + runner.dumpConfig(); + runner.dumpTools(); + exit(0); + } + } +} diff --git a/tests/integration/inMemoryTransport.ts b/src/transports/inMemoryTransport.ts similarity index 100% rename from tests/integration/inMemoryTransport.ts rename to src/transports/inMemoryTransport.ts diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 24d0d25a4..5bd445113 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -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"; diff --git a/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index 56fd86bd3..12d374699 100644 --- a/tests/integration/tools/mongodb/mongodbTool.test.ts +++ b/tests/integration/tools/mongodb/mongodbTool.test.ts @@ -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"; diff --git a/tests/unit/transports/dryModeRunner.test.ts b/tests/unit/transports/dryModeRunner.test.ts new file mode 100644 index 000000000..9b7cbc5bf --- /dev/null +++ b/tests/unit/transports/dryModeRunner.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { DryModeRunner, type DryModeTestHelpers } 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 exitMock: DryModeTestHelpers["exit"]; + let loggerMock: DryModeTestHelpers["logger"]; + let runnerConfig: TransportRunnerConfig; + + beforeEach(() => { + exitMock = vi.fn(); + loggerMock = { + log: vi.fn(), + error: vi.fn(), + }; + runnerConfig = { + userConfig: defaultTestConfig, + } as TransportRunnerConfig; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should not do anything if dry mode is disabled", async () => { + await DryModeRunner.assertDryMode(runnerConfig, exitMock, loggerMock); + expect(exitMock).not.toHaveBeenCalled(); + expect(loggerMock.log).not.toHaveBeenCalled(); + }); + + it.each([{ transport: "http", httpHost: "127.0.0.1", httpPort: "3001" }, { transport: "stdio" }] as Array< + Partial + >)("should run in dry mode if enabled for transport - $transport", async (partialConfig) => { + runnerConfig.userConfig = { + ...runnerConfig.userConfig, + ...partialConfig, + dry: true, + }; + await DryModeRunner.assertDryMode(runnerConfig, exitMock, loggerMock); + expect(exitMock).toHaveBeenCalledWith(0); + 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"')); + }); +}); From 709f4ee37e122d43d5653e24d5a9eacd64dcf882 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Mon, 24 Nov 2025 13:24:06 +0100 Subject: [PATCH 2/8] chore: fix config expectation --- tests/unit/common/config.test.ts | 105 +++++++++++-------------------- 1 file changed, 36 insertions(+), 69 deletions(-) diff --git a/tests/unit/common/config.test.ts b/tests/unit/common/config.test.ts index b9815215f..7c9799c6a 100644 --- a/tests/unit/common/config.test.ts +++ b/tests/unit/common/config.test.ts @@ -25,81 +25,48 @@ 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: [], +}; + 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); }); From ef465af0e314338760a35db3414421e593785a38 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 25 Nov 2025 12:32:41 +0100 Subject: [PATCH 3/8] chore: address PR feedback 1. assert function are not assertions so fixed them with relevant names 2. moved static method logic to cli entry point and added e2e for test cases --- package.json | 2 +- src/common/config/argsParserOptions.ts | 2 +- src/common/config/userConfig.ts | 2 +- src/index.ts | 51 ++++++++++++++----- src/transports/dryModeRunner.ts | 54 +++++---------------- tests/e2e/cli.test.ts | 29 +++++++++++ tests/unit/common/config.test.ts | 1 + tests/unit/transports/dryModeRunner.test.ts | 24 ++++----- 8 files changed, 92 insertions(+), 73 deletions(-) create mode 100644 tests/e2e/cli.test.ts diff --git a/package.json b/package.json index ca9faad2d..ae776ca94 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/common/config/argsParserOptions.ts b/src/common/config/argsParserOptions.ts index dc4fc8aa5..cdf7daa1d 100644 --- a/src/common/config/argsParserOptions.ts +++ b/src/common/config/argsParserOptions.ts @@ -59,7 +59,7 @@ export const OPTIONS = { boolean: [ "apiDeprecationErrors", "apiStrict", - "dry", + "dryRun", "embeddingsValidation", "help", "indexCheck", diff --git a/src/common/config/userConfig.ts b/src/common/config/userConfig.ts index 1eae87406..8a3b7ef2b 100644 --- a/src/common/config/userConfig.ts +++ b/src/common/config/userConfig.ts @@ -173,7 +173,7 @@ export const UserConfigSchema = z4.object({ ) .default([]) .describe("An array of preview features that are enabled."), - dry: z4 + dryRun: z4 .boolean() .default(false) .describe( diff --git a/src/index.ts b/src/index.ts index 56cf290f1..6845ce980 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,15 +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 { DryModeRunner } from "./transports/dryModeRunner.js"; +import { DryRunModeRunner } from "./transports/dryModeRunner.js"; async function main(): Promise { systemCA().catch(() => undefined); // load system CA asynchronously as in mongosh const config = createUserConfig(); - assertHelpMode(config); - assertVersionMode(config); - await DryModeRunner.assertDryMode({ userConfig: config }); + if (config.help) { + handleHelpRequest(); + } + + if (config.version) { + handleVersionRequest(); + } + + if (config.dryRun) { + await handleDryRunRequest(config); + } const transportRunner = config.transport === "stdio" @@ -135,17 +143,34 @@ 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 { + try { + const runner = new DryRunModeRunner({ + userConfig: config, + logger: { + log(message): void { + console.log(message); + }, + error(message): void { + console.error(message); + }, + }, + }); + await runner.start(); process.exit(0); + } catch (error) { + console.error(`Fatal error running server in dry run mode: ${error as string}`); + process.exit(1); } } diff --git a/src/transports/dryModeRunner.ts b/src/transports/dryModeRunner.ts index 05dc5eedf..e66c513ff 100644 --- a/src/transports/dryModeRunner.ts +++ b/src/transports/dryModeRunner.ts @@ -2,49 +2,34 @@ import { InMemoryTransport } from "./inMemoryTransport.js"; import { TransportRunnerBase, type TransportRunnerConfig } from "./base.js"; import { type Server } from "../server.js"; -export type DryModeTestHelpers = { - exit(this: void, exitCode: number): never; +export type DryRunModeTestHelpers = { logger: { log(this: void, message: string): void; error(this: void, message: string): void; }; }; -type DryModeRunnerConfig = TransportRunnerConfig & DryModeTestHelpers; +type DryRunModeRunnerConfig = TransportRunnerConfig & DryRunModeTestHelpers; -const defaultLogger: DryModeTestHelpers["logger"] = { - log(message) { - console.warn(message); - }, - error(message) { - console.error(message); - }, -}; - -export class DryModeRunner extends TransportRunnerBase { +export class DryRunModeRunner extends TransportRunnerBase { private server: Server | undefined; - private exitProcess: DryModeTestHelpers["exit"]; - private consoleLogger: DryModeTestHelpers["logger"]; + private consoleLogger: DryRunModeTestHelpers["logger"]; - constructor({ exit, logger, ...transportRunnerConfig }: DryModeRunnerConfig) { + constructor({ logger, ...transportRunnerConfig }: DryRunModeRunnerConfig) { super(transportRunnerConfig); - this.exitProcess = exit; this.consoleLogger = logger; } - async start(): Promise { - try { - this.server = await this.setupServer(); - const transport = new InMemoryTransport(); + override async start(): Promise { + this.server = await this.setupServer(); + const transport = new InMemoryTransport(); - await this.server.connect(transport); - } catch (error: unknown) { - this.consoleLogger.error(`Fatal error running server: ${error as string}`); - this.exitProcess(1); - } + await this.server.connect(transport); + this.dumpConfig(); + this.dumpTools(); } - async closeTransport(): Promise { + override async closeTransport(): Promise { await this.server?.close(); } @@ -56,24 +41,9 @@ export class DryModeRunner extends TransportRunnerBase { private dumpTools(): void { const tools = this.server?.tools.map((tool) => ({ name: tool.name, - description: tool.description, category: tool.category, })); this.consoleLogger.log("Enabled tools:"); this.consoleLogger.log(JSON.stringify(tools, null, 2)); } - - static async assertDryMode( - runnerConfig: TransportRunnerConfig, - exit: DryModeTestHelpers["exit"] = (exitCode: number) => process.exit(exitCode), - logger: DryModeTestHelpers["logger"] = defaultLogger - ): Promise | never { - if (runnerConfig.userConfig.dry) { - const runner = new this({ ...runnerConfig, exit, logger }); - await runner.start(); - runner.dumpConfig(); - runner.dumpTools(); - exit(0); - } - } } diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts new file mode 100644 index 000000000..d82a1efc9 --- /dev/null +++ b/tests/e2e/cli.test.ts @@ -0,0 +1,29 @@ +import path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { describe, expect, it } from "vitest"; +import packageJson from "../../package.json" with { type: "json" }; + +const execAsync = promisify(exec); +const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "index.js"); + +describe("CLI entrypoint", () => { + it("should handle version request", async () => { + const { stdout, stderr } = await execAsync(`${process.execPath} ${CLI_PATH} --version`); + expect(stdout).toContain(packageJson.version); + expect(stderr).toEqual(""); + }); + + it("should handle help request", async () => { + const { stdout, stderr } = await execAsync(`${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, stderr } = await execAsync(`${process.execPath} ${CLI_PATH} --dryRun`); + expect(stdout).toContain("Configuration:"); + expect(stdout).toContain("Enabled tools:"); + expect(stderr).toEqual(""); + }); +}); diff --git a/tests/unit/common/config.test.ts b/tests/unit/common/config.test.ts index 7c9799c6a..e28509e3d 100644 --- a/tests/unit/common/config.test.ts +++ b/tests/unit/common/config.test.ts @@ -59,6 +59,7 @@ const expectedDefaults = { vectorSearchSimilarityFunction: "euclidean", embeddingsValidation: true, previewFeatures: [], + dryRun: false, }; describe("config", () => { diff --git a/tests/unit/transports/dryModeRunner.test.ts b/tests/unit/transports/dryModeRunner.test.ts index 9b7cbc5bf..e5bc6ec58 100644 --- a/tests/unit/transports/dryModeRunner.test.ts +++ b/tests/unit/transports/dryModeRunner.test.ts @@ -1,16 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { DryModeRunner, type DryModeTestHelpers } from "../../../src/transports/dryModeRunner.js"; +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 exitMock: DryModeTestHelpers["exit"]; - let loggerMock: DryModeTestHelpers["logger"]; +describe.only("DryModeRunner", () => { + let loggerMock: DryRunModeTestHelpers["logger"]; let runnerConfig: TransportRunnerConfig; beforeEach(() => { - exitMock = vi.fn(); loggerMock = { log: vi.fn(), error: vi.fn(), @@ -24,25 +22,21 @@ describe("DryModeRunner", () => { vi.clearAllMocks(); }); - it("should not do anything if dry mode is disabled", async () => { - await DryModeRunner.assertDryMode(runnerConfig, exitMock, loggerMock); - expect(exitMock).not.toHaveBeenCalled(); - expect(loggerMock.log).not.toHaveBeenCalled(); - }); - it.each([{ transport: "http", httpHost: "127.0.0.1", httpPort: "3001" }, { transport: "stdio" }] as Array< Partial - >)("should run in dry mode if enabled for transport - $transport", async (partialConfig) => { + >)("should handle dry run request for transport - $transport", async (partialConfig) => { runnerConfig.userConfig = { ...runnerConfig.userConfig, ...partialConfig, - dry: true, + dryRun: true, }; - await DryModeRunner.assertDryMode(runnerConfig, exitMock, loggerMock); - expect(exitMock).toHaveBeenCalledWith(0); + 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"')); }); }); From 51a3b2d6683878c410bd66cb28f81ef0c9c32aa3 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 25 Nov 2025 12:34:28 +0100 Subject: [PATCH 4/8] chore: dump only enabled tools --- src/transports/dryModeRunner.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/transports/dryModeRunner.ts b/src/transports/dryModeRunner.ts index e66c513ff..c0ff697f9 100644 --- a/src/transports/dryModeRunner.ts +++ b/src/transports/dryModeRunner.ts @@ -39,10 +39,12 @@ export class DryRunModeRunner extends TransportRunnerBase { } private dumpTools(): void { - const tools = this.server?.tools.map((tool) => ({ - name: tool.name, - category: tool.category, - })); + 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)); } From fe83ace61161736d9d02d29caff0d09b8f700269 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 25 Nov 2025 12:37:16 +0100 Subject: [PATCH 5/8] Update tests/unit/transports/dryModeRunner.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/unit/transports/dryModeRunner.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/transports/dryModeRunner.test.ts b/tests/unit/transports/dryModeRunner.test.ts index e5bc6ec58..3d7f470dd 100644 --- a/tests/unit/transports/dryModeRunner.test.ts +++ b/tests/unit/transports/dryModeRunner.test.ts @@ -4,7 +4,7 @@ import { type UserConfig } from "../../../src/common/config/userConfig.js"; import { type TransportRunnerConfig } from "../../../src/transports/base.js"; import { defaultTestConfig } from "../../integration/helpers.js"; -describe.only("DryModeRunner", () => { +describe("DryModeRunner", () => { let loggerMock: DryRunModeTestHelpers["logger"]; let runnerConfig: TransportRunnerConfig; From 14891a596ae62468a212eeff9bc56a4fde1b2374 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 25 Nov 2025 12:42:30 +0100 Subject: [PATCH 6/8] chore: address copilot suggestions --- src/transports/dryModeRunner.ts | 13 +++++++------ tests/e2e/cli.test.ts | 10 +++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/transports/dryModeRunner.ts b/src/transports/dryModeRunner.ts index c0ff697f9..6a2a34403 100644 --- a/src/transports/dryModeRunner.ts +++ b/src/transports/dryModeRunner.ts @@ -39,12 +39,13 @@ export class DryRunModeRunner extends TransportRunnerBase { } private dumpTools(): void { - const tools = this.server?.tools - .filter((tool) => tool.isEnabled()) - .map((tool) => ({ - name: tool.name, - category: tool.category, - })); + 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)); } diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index d82a1efc9..5052f2301 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -1,27 +1,27 @@ import path from "path"; -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { describe, expect, it } from "vitest"; import packageJson from "../../package.json" with { type: "json" }; -const execAsync = promisify(exec); +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 execAsync(`${process.execPath} ${CLI_PATH} --version`); + 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 execAsync(`${process.execPath} ${CLI_PATH} --help`); + 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, stderr } = await execAsync(`${process.execPath} ${CLI_PATH} --dryRun`); + const { stdout, stderr } = await execFileAsync(process.execPath, [CLI_PATH, "--dryRun"]); expect(stdout).toContain("Configuration:"); expect(stdout).toContain("Enabled tools:"); expect(stderr).toEqual(""); From 3438d589814cd395c7b199f086ab582e80021625 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 25 Nov 2025 15:15:08 +0100 Subject: [PATCH 7/8] chore: remove stderr assertions --- tests/e2e/cli.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 5052f2301..7a9cf1cea 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -21,9 +21,10 @@ describe("CLI entrypoint", () => { }); it("should handle dry run request", async () => { - const { stdout, stderr } = await execFileAsync(process.execPath, [CLI_PATH, "--dryRun"]); + const { stdout } = await execFileAsync(process.execPath, [CLI_PATH, "--dryRun"]); expect(stdout).toContain("Configuration:"); expect(stdout).toContain("Enabled tools:"); - expect(stderr).toEqual(""); + // We don't do stderr assertions because in our CI, for docker-less env + // atlas local tools push message on stderr stream. }); }); From 5884a5a158e1727a8a203990a132f555c67dbf35 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Tue, 25 Nov 2025 17:10:59 +0100 Subject: [PATCH 8/8] chore: stop runner as well --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 6845ce980..2da924be5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,6 +168,7 @@ export async function handleDryRunRequest(config: UserConfig): Promise { }, }); await runner.start(); + await runner.close(); process.exit(0); } catch (error) { console.error(`Fatal error running server in dry run mode: ${error as string}`);