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 a3a723c0f..cdf7daa1d 100644 --- a/src/common/config/argsParserOptions.ts +++ b/src/common/config/argsParserOptions.ts @@ -59,6 +59,7 @@ export const OPTIONS = { boolean: [ "apiDeprecationErrors", "apiStrict", + "dryRun", "embeddingsValidation", "help", "indexCheck", diff --git a/src/common/config/userConfig.ts b/src/common/config/userConfig.ts index 91a38a647..8a3b7ef2b 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."), + 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." + ), }); diff --git a/src/index.ts b/src/index.ts index fd030ede5..2da924be5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { 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" @@ -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 { + 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); } } diff --git a/src/transports/dryModeRunner.ts b/src/transports/dryModeRunner.ts new file mode 100644 index 000000000..6a2a34403 --- /dev/null +++ b/src/transports/dryModeRunner.ts @@ -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 { + this.server = await this.setupServer(); + const transport = new InMemoryTransport(); + + await this.server.connect(transport); + this.dumpConfig(); + this.dumpTools(); + } + + override 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 + .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/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/e2e/cli.test.ts b/tests/e2e/cli.test.ts new file mode 100644 index 000000000..7a9cf1cea --- /dev/null +++ b/tests/e2e/cli.test.ts @@ -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. + }); +}); 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/common/config.test.ts b/tests/unit/common/config.test.ts index b9815215f..e28509e3d 100644 --- a/tests/unit/common/config.test.ts +++ b/tests/unit/common/config.test.ts @@ -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); }); diff --git a/tests/unit/transports/dryModeRunner.test.ts b/tests/unit/transports/dryModeRunner.test.ts new file mode 100644 index 000000000..3d7f470dd --- /dev/null +++ b/tests/unit/transports/dryModeRunner.test.ts @@ -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 + >)("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"')); + }); +});