From 7f3ed9c571b02e95f470270406714fc690cf4b90 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 12:38:04 +0200 Subject: [PATCH 1/9] feat(elicitation): add user consent configuration through elicitation MCP-185 Adds an option to require confirmation for certain tools using the new elicitation API. This is not supported by most client yet, only notably VSCode. Clients which support it will see a confirmation option with a summary before the action is run. If the client doesn't support elicitation, the action will simply be auto-approved. This option can be confirmed with `confirmationRequiredTools` and has a default set of `drop-database`, `drop-collection`, `delete-many`, `atlas-create-db-user`, `atlas-create-access-list` enabled. In VSCode one must first click "Respond" (which sets action to "accepted") and then choose a value. I decided to let there be an explcit choice of Yes / No in JSON schema instead of opting to just rely on "Respond" as it is not immediately clear that `Respond = Yes` and I imagine this vagueness in the API spec will lead to confusion across clients so it's best to have an explicit JSON schema value for confirmation. I also went with enum string Yes / No and not boolean since the displayed value for this is more user friendly. --- README.md | 9 + src/common/config.ts | 10 + src/elicitation.ts | 59 +++ src/server.ts | 16 +- src/telemetry/types.ts | 1 + src/tools/atlas/create/createAccessList.ts | 27 ++ src/tools/atlas/create/createDBUser.ts | 18 + src/tools/mongodb/connect/connect.ts | 9 +- src/tools/mongodb/delete/deleteMany.ts | 14 + src/tools/mongodb/delete/dropCollection.ts | 8 + src/tools/mongodb/delete/dropDatabase.ts | 8 + src/tools/tool.ts | 61 ++- src/transports/base.ts | 4 + tests/integration/elicitation.test.ts | 451 +++++++++++++++++++++ tests/unit/elicitation.test.ts | 137 +++++++ tests/unit/toolBase.test.ts | 129 ++++++ tests/utils/elicitationMocks.ts | 67 +++ 17 files changed, 1012 insertions(+), 16 deletions(-) create mode 100644 src/elicitation.ts create mode 100644 tests/integration/elicitation.test.ts create mode 100644 tests/unit/elicitation.test.ts create mode 100644 tests/unit/toolBase.test.ts create mode 100644 tests/utils/elicitationMocks.ts diff --git a/README.md b/README.md index 5948b6c70..46391b6cc 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow | `loggers` | `MDB_MCP_LOGGERS` | disk,mcp | Comma separated values, possible values are `mcp`, `disk` and `stderr`. See [Logger Options](#logger-options) for details. | | `logPath` | `MDB_MCP_LOG_PATH` | see note\* | Folder to store logs. | | `disabledTools` | `MDB_MCP_DISABLED_TOOLS` | | An array of tool names, operation types, and/or categories of tools that will be disabled. | +| `confirmationRequiredTools` | `MDB_MCP_CONFIRMATION_REQUIRED_TOOLS` | create-access-list,create-db-user,drop-database,drop-collection,delete-many | An array of tool names that require user confirmation before execution. **Requires the client to support [elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation)**. | | `readOnly` | `MDB_MCP_READ_ONLY` | false | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. | | `indexCheck` | `MDB_MCP_INDEX_CHECK` | false | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. | | `telemetry` | `MDB_MCP_TELEMETRY` | enabled | When set to disabled, disables telemetry collection. | @@ -418,6 +419,14 @@ Operation types: - `metadata` - Tools that read metadata, such as list databases, list collections, collection schema, etc. - `connect` - Tools that allow you to connect or switch the connection to a MongoDB instance. If this is disabled, you will need to provide a connection string through the config when starting the server. +#### Require Confirmation + +If your client supports [elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation), you can set the MongoDB MCP server to request user confirmation before executing certain tools. + +When a tool is marked as requiring confirmation, the server will send an elicitation request to the client. The client with elicitation support will then prompt the user for confirmation and send the response back to the server. If the client does not support elicitation, the tool will execute without confirmation. + +You can set the `confirmationRequiredTools` configuration option to specify the names of tools which require confirmation. By default, the following tools have this setting enabled: `drop-database`, `drop-collection`, `delete-many`, `atlas-create-db-user`, `atlas-create-access-list`. + #### Read-Only Mode The `readOnly` configuration option allows you to restrict the MCP server to only use tools with "read", "connect", and "metadata" operation types. When enabled, all tools that have "create", "update" or "delete" operation types will not be registered with the server. diff --git a/src/common/config.ts b/src/common/config.ts index 90d1fc807..8702c96e2 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -160,7 +160,9 @@ export interface UserConfig extends CliOptions { exportTimeoutMs: number; exportCleanupIntervalMs: number; connectionString?: string; + // TODO: Use a type tracking all tool names. disabledTools: Array; + confirmationRequiredTools: Array; readOnly?: boolean; indexCheck?: boolean; transport: "stdio" | "http"; @@ -183,6 +185,13 @@ export const defaultUserConfig: UserConfig = { telemetry: "enabled", readOnly: false, indexCheck: false, + confirmationRequiredTools: [ + "atlas-create-access-list", + "atlas-create-db-user", + "drop-database", + "drop-collection", + "delete-many", + ], transport: "stdio", httpPort: 3000, httpHost: "127.0.0.1", @@ -442,6 +451,7 @@ export function setupUserConfig({ userConfig.disabledTools = commaSeparatedToArray(userConfig.disabledTools); userConfig.loggers = commaSeparatedToArray(userConfig.loggers); + userConfig.confirmationRequiredTools = commaSeparatedToArray(userConfig.confirmationRequiredTools); if (userConfig.connectionString && userConfig.connectionSpecifier) { const connectionInfo = generateConnectionInfoFromCliArgs(userConfig); diff --git a/src/elicitation.ts b/src/elicitation.ts new file mode 100644 index 000000000..70f517851 --- /dev/null +++ b/src/elicitation.ts @@ -0,0 +1,59 @@ +import type { PrimitiveSchemaDefinition } from "@modelcontextprotocol/sdk/types.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export class Elicitation { + private readonly server: McpServer["server"]; + constructor({ server }: { server: McpServer["server"] }) { + this.server = server; + } + + /** + * Checks if the client supports elicitation capabilities. + * @returns True if the client supports elicitation, false otherwise. + */ + public supportsElicitation(): boolean { + const clientCapabilities = this.server.getClientCapabilities(); + return clientCapabilities?.elicitation !== undefined; + } + + /** + * Requests a boolean confirmation from the user. + * @param message - The message to display to the user. + * @returns True if the user confirms the action or the client does not support elicitation, false otherwise. + */ + public async requestConfirmation(message: string): Promise { + if (!this.supportsElicitation()) { + return true; + } + + const result = await this.server.elicitInput({ + message, + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + return result.action === "accept" && result.content?.confirmation === "Yes"; + } + + /** + * The schema for the confirmation question. + * TODO: In the future would be good to use Zod 4's toJSONSchema() to generate the schema. + */ + public static CONFIRMATION_SCHEMA: MCPElicitationSchema = { + type: "object", + properties: { + confirmation: { + type: "string", + title: "Would you like to confirm?", + description: "Would you like to confirm?", + enum: ["Yes", "No"], + enumNames: ["Yes, I confirm", "No, I do not confirm"], + }, + }, + required: ["confirmation"], + }; +} + +export type MCPElicitationSchema = { + type: "object"; + properties: Record; + required?: string[]; +}; diff --git a/src/server.ts b/src/server.ts index d3cc9dbd6..dc2556ef7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,12 +22,14 @@ import type { ToolBase } from "./tools/tool.js"; import { validateConnectionString } from "./helpers/connectionOptions.js"; import { packageInfo } from "./common/packageInfo.js"; import { type ConnectionErrorHandler } from "./common/connectionErrorHandler.js"; +import type { Elicitation } from "./elicitation.js"; export interface ServerOptions { session: Session; userConfig: UserConfig; mcpServer: McpServer; telemetry: Telemetry; + elicitation: Elicitation; connectionErrorHandler: ConnectionErrorHandler; } @@ -36,6 +38,7 @@ export class Server { public readonly mcpServer: McpServer; private readonly telemetry: Telemetry; public readonly userConfig: UserConfig; + public readonly elicitation: Elicitation; public readonly tools: ToolBase[] = []; public readonly connectionErrorHandler: ConnectionErrorHandler; @@ -48,12 +51,13 @@ export class Server { private readonly startTime: number; private readonly subscriptions = new Set(); - constructor({ session, mcpServer, userConfig, telemetry, connectionErrorHandler }: ServerOptions) { + constructor({ session, mcpServer, userConfig, telemetry, connectionErrorHandler, elicitation }: ServerOptions) { this.startTime = Date.now(); this.session = session; this.telemetry = telemetry; this.mcpServer = mcpServer; this.userConfig = userConfig; + this.elicitation = elicitation; this.connectionErrorHandler = connectionErrorHandler; } @@ -184,6 +188,7 @@ export class Server { event.properties.startup_time_ms = commandDuration; event.properties.read_only_mode = this.userConfig.readOnly || false; event.properties.disabled_tools = this.userConfig.disabledTools || []; + event.properties.confirmation_required_tools = this.userConfig.confirmationRequiredTools || []; } if (command === "stop") { event.properties.runtime_duration_ms = Date.now() - this.startTime; @@ -193,12 +198,17 @@ export class Server { } } - this.telemetry.emitEvents([event]); + void this.telemetry.emitEvents([event]); } private registerTools(): void { for (const toolConstructor of [...AtlasTools, ...MongoDbTools]) { - const tool = new toolConstructor(this.session, this.userConfig, this.telemetry); + const tool = new toolConstructor({ + session: this.session, + config: this.userConfig, + telemetry: this.telemetry, + elicitation: this.elicitation, + }); if (tool.register(this)) { this.tools.push(tool); } diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index f0392344e..c1eced5af 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -45,6 +45,7 @@ export type ServerEventProperties = { runtime_duration_ms?: number; read_only_mode?: boolean; disabled_tools?: string[]; + confirmation_required_tools?: string[]; }; export type ServerEvent = TelemetryEvent; diff --git a/src/tools/atlas/create/createAccessList.ts b/src/tools/atlas/create/createAccessList.ts index 0cf3b808e..fe5a862ff 100644 --- a/src/tools/atlas/create/createAccessList.ts +++ b/src/tools/atlas/create/createAccessList.ts @@ -76,4 +76,31 @@ export class CreateAccessListTool extends AtlasToolBase { ], }; } + + protected getConfirmationMessage({ + projectId, + ipAddresses, + cidrBlocks, + comment, + currentIpAddress, + }: ToolArgs): string { + const accessDescription = []; + if (ipAddresses?.length) { + accessDescription.push(`- **IP addresses**: ${ipAddresses.join(", ")}`); + } + if (cidrBlocks?.length) { + accessDescription.push(`- **CIDR blocks**: ${cidrBlocks.join(", ")}`); + } + if (currentIpAddress) { + accessDescription.push("- **Current IP address**"); + } + + return ( + `You are about to add the following entries to the access list for Atlas project "${projectId}":\n\n` + + accessDescription.join("\n") + + `\n\n**Comment**: ${comment || DEFAULT_ACCESS_LIST_COMMENT}\n\n` + + "This will allow network access to your MongoDB Atlas clusters from these IP addresses/ranges. " + + "Do you want to proceed?" + ); + } } diff --git a/src/tools/atlas/create/createDBUser.ts b/src/tools/atlas/create/createDBUser.ts index 18d22d358..69c5edc4e 100644 --- a/src/tools/atlas/create/createDBUser.ts +++ b/src/tools/atlas/create/createDBUser.ts @@ -96,4 +96,22 @@ export class CreateDBUserTool extends AtlasToolBase { ], }; } + + protected getConfirmationMessage({ + projectId, + username, + password, + roles, + clusters, + }: ToolArgs): string { + return ( + `You are about to create a database user in Atlas project \`${projectId}\`:\n\n` + + `**Username**: \`${username}\`\n\n` + + `**Password**: ${password ? "(User-provided password)" : "(Auto-generated secure password)"}\n\n` + + `**Roles**: ${roles.map((role) => `${role.roleName}${role.collectionName ? ` on ${role.databaseName}.${role.collectionName}` : ` on ${role.databaseName}`}`).join(", ")}\n\n` + + `**Cluster Access**: ${clusters?.length ? clusters.join(", ") : "All clusters in the project"}\n\n` + + "This will create a new database user with the specified permissions. " + + "**Do you confirm the execution of the action?**" + ); + } } diff --git a/src/tools/mongodb/connect/connect.ts b/src/tools/mongodb/connect/connect.ts index 3fd6b48c3..d7ed16d26 100644 --- a/src/tools/mongodb/connect/connect.ts +++ b/src/tools/mongodb/connect/connect.ts @@ -1,11 +1,8 @@ import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { MongoDBToolBase } from "../mongodbTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; +import type { ToolArgs, OperationType, ToolConstructorParams } from "../../tool.js"; import assert from "assert"; -import type { UserConfig } from "../../../common/config.js"; -import type { Telemetry } from "../../../telemetry/telemetry.js"; -import type { Session } from "../../../common/session.js"; import type { Server } from "../../../server.js"; const disconnectedSchema = z @@ -44,8 +41,8 @@ export class ConnectTool extends MongoDBToolBase { public operationType: OperationType = "connect"; - constructor(session: Session, config: UserConfig, telemetry: Telemetry) { - super(session, config, telemetry); + constructor({ session, config, telemetry, elicitation }: ToolConstructorParams) { + super({ session, config, telemetry, elicitation }); session.on("connect", () => { this.updateMetadata(); }); diff --git a/src/tools/mongodb/delete/deleteMany.ts b/src/tools/mongodb/delete/deleteMany.ts index 3f769f3ab..77ce0a936 100644 --- a/src/tools/mongodb/delete/deleteMany.ts +++ b/src/tools/mongodb/delete/deleteMany.ts @@ -3,6 +3,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; import type { ToolArgs, OperationType } from "../../tool.js"; import { checkIndexUsage } from "../../../helpers/indexCheck.js"; +import { EJSON } from "bson"; export class DeleteManyTool extends MongoDBToolBase { public name = "delete-many"; @@ -55,4 +56,17 @@ export class DeleteManyTool extends MongoDBToolBase { ], }; } + + protected getConfirmationMessage({ database, collection, filter }: ToolArgs): string { + const filterDescription = + filter && Object.keys(filter).length > 0 ? EJSON.stringify(filter) : "All documents (No filter)"; + return ( + `You are about to delete documents from the \`${collection}\` collection in the \`${database}\` database:\n\n` + + "```json\n" + + `{ "filter": ${filterDescription} }\n` + + "```\n\n" + + "This operation will permanently remove all documents matching the filter.\n\n" + + "**Do you confirm the execution of the action?**" + ); + } } diff --git a/src/tools/mongodb/delete/dropCollection.ts b/src/tools/mongodb/delete/dropCollection.ts index ea46355ca..50bd008a7 100644 --- a/src/tools/mongodb/delete/dropCollection.ts +++ b/src/tools/mongodb/delete/dropCollection.ts @@ -24,4 +24,12 @@ export class DropCollectionTool extends MongoDBToolBase { ], }; } + + protected getConfirmationMessage({ database, collection }: ToolArgs): string { + return ( + `You are about to drop the \`${collection}\` collection from the \`${database}\` database:\n\n` + + "This operation will permanently remove the collection and all its data, including indexes.\n\n" + + "**Do you confirm the execution of the action?**" + ); + } } diff --git a/src/tools/mongodb/delete/dropDatabase.ts b/src/tools/mongodb/delete/dropDatabase.ts index b877bf67c..d33682ce3 100644 --- a/src/tools/mongodb/delete/dropDatabase.ts +++ b/src/tools/mongodb/delete/dropDatabase.ts @@ -23,4 +23,12 @@ export class DropDatabaseTool extends MongoDBToolBase { ], }; } + + protected getConfirmationMessage({ database }: ToolArgs): string { + return ( + `You are about to drop the \`${database}\` database:\n\n` + + "This operation will permanently remove the database and ALL its collections, documents, and indexes.\n\n" + + "**Do you confirm the execution of the action?**" + ); + } } diff --git a/src/tools/tool.ts b/src/tools/tool.ts index 0115feb05..d802c2d97 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -8,8 +8,10 @@ import type { Telemetry } from "../telemetry/telemetry.js"; import { type ToolEvent } from "../telemetry/types.js"; import type { UserConfig } from "../common/config.js"; import type { Server } from "../server.js"; +import type { Elicitation } from "../elicitation.js"; export type ToolArgs = z.objectOutputType; +export type ToolCallbackArgs = Parameters>; export type OperationType = "metadata" | "read" | "create" | "delete" | "update" | "connect"; export type ToolCategory = "mongodb" | "atlas"; @@ -18,6 +20,13 @@ export type TelemetryToolMetadata = { orgId?: string; }; +export type ToolConstructorParams = { + session: Session; + config: UserConfig; + telemetry: Telemetry; + elicitation: Elicitation; +}; + export abstract class ToolBase { public abstract name: string; @@ -58,13 +67,35 @@ export abstract class ToolBase { return annotations; } - protected abstract execute(...args: Parameters>): Promise; + protected abstract execute(...args: ToolCallbackArgs): Promise; + + /** Get the confirmation message for the tool. Can be overridden to provide a more specific message. */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getConfirmationMessage(...args: ToolCallbackArgs): string { + return `You are about to execute the \`${this.name}\` tool which requires additional confirmation. Would you like to proceed?`; + } + + /** Check if the user has confirmed the tool execution, if required by the configuration. + * Always returns true if confirmation is not required. + */ + public async verifyConfirmed(args: ToolCallbackArgs): Promise { + if (!this.config.confirmationRequiredTools.includes(this.name)) { + return true; + } - constructor( - protected readonly session: Session, - protected readonly config: UserConfig, - protected readonly telemetry: Telemetry - ) {} + return this.elicitation.requestConfirmation(this.getConfirmationMessage(...args)); + } + + protected readonly session: Session; + protected readonly config: UserConfig; + protected readonly telemetry: Telemetry; + protected readonly elicitation: Elicitation; + constructor({ session, config, telemetry, elicitation }: ToolConstructorParams) { + this.session = session; + this.config = config; + this.telemetry = telemetry; + this.elicitation = elicitation; + } public register(server: Server): boolean { if (!this.verifyAllowed()) { @@ -74,6 +105,22 @@ export abstract class ToolBase { const callback: ToolCallback = async (...args) => { const startTime = Date.now(); try { + if (!(await this.verifyConfirmed(args))) { + this.session.logger.debug({ + id: LogId.toolExecute, + context: "tool", + message: `User did not confirm the execution of the \`${this.name}\` tool so the operation was not performed.`, + noRedaction: true, + }); + return { + content: [ + { + type: "text", + text: `User did not confirm the execution of the \`${this.name}\` tool so the operation was not performed.`, + }, + ], + }; + } this.session.logger.debug({ id: LogId.toolExecute, context: "tool", @@ -237,7 +284,7 @@ export abstract class ToolBase { event.properties.project_id = metadata.projectId; } - this.telemetry.emitEvents([event]); + void this.telemetry.emitEvents([event]); } } diff --git a/src/transports/base.ts b/src/transports/base.ts index 7de433ae2..a70d23a2c 100644 --- a/src/transports/base.ts +++ b/src/transports/base.ts @@ -15,6 +15,7 @@ import { connectionErrorHandler as defaultConnectionErrorHandler, } from "../common/connectionErrorHandler.js"; import type { CommonProperties } from "../telemetry/types.js"; +import { Elicitation } from "../elicitation.js"; export type TransportRunnerConfig = { userConfig: UserConfig; @@ -94,12 +95,15 @@ export abstract class TransportRunnerBase { commonProperties: this.telemetryProperties, }); + const elicitation = new Elicitation({ server: mcpServer.server }); + const result = new Server({ mcpServer, session, telemetry, userConfig: this.userConfig, connectionErrorHandler: this.connectionErrorHandler, + elicitation, }); // We need to create the MCP logger after the server is constructed diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts new file mode 100644 index 000000000..3fa9a343f --- /dev/null +++ b/tests/integration/elicitation.test.ts @@ -0,0 +1,451 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Server } from "../../src/server.js"; +import { Session } from "../../src/common/session.js"; +import { CompositeLogger } from "../../src/common/logger.js"; +import { MCPConnectionManager } from "../../src/common/connectionManager.js"; +import { DeviceId } from "../../src/helpers/deviceId.js"; +import { ExportsManager } from "../../src/common/exportsManager.js"; +import { Telemetry } from "../../src/telemetry/telemetry.js"; +import { Keychain } from "../../src/common/keychain.js"; +import { InMemoryTransport } from "./inMemoryTransport.js"; +import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js"; +import { defaultDriverOptions, type UserConfig } from "../../src/common/config.js"; +import { defaultTestConfig } from "./helpers.js"; +import { Elicitation } from "../../src/elicitation.js"; +import { type MockClientCapabilities, createMockElicitInput } from "../utils/elicitationMocks.js"; + +describe("Elicitation Integration Tests", () => { + let mcpClient: Client; + let mcpServer: Server; + let deviceId: DeviceId; + let mockElicitInput: ReturnType; + + async function setupWithConfig( + config: Partial = {}, + clientCapabilities: MockClientCapabilities = {} + ): Promise { + const userConfig: UserConfig = { + ...defaultTestConfig, + telemetry: "disabled", + // Add fake API credentials so Atlas tools get registered + apiClientId: "test-client-id", + apiClientSecret: "test-client-secret", + ...config, + }; + + const driverOptions = defaultDriverOptions; + const logger = new CompositeLogger(); + const exportsManager = ExportsManager.init(userConfig, logger); + deviceId = DeviceId.create(logger); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const connectionManager = new MCPConnectionManager(userConfig, driverOptions, logger, deviceId); + const session = new Session({ + apiBaseUrl: userConfig.apiBaseUrl, + apiClientId: userConfig.apiClientId, + apiClientSecret: userConfig.apiClientSecret, + logger, + exportsManager, + connectionManager, + keychain: new Keychain(), + }); + // Mock API validation for tests + const mockFn = vi.fn().mockResolvedValue(true); + session.apiClient.validateAccessToken = mockFn; + + const telemetry = Telemetry.create(session, userConfig, deviceId); + + const clientTransport = new InMemoryTransport(); + const serverTransport = new InMemoryTransport(); + + await serverTransport.start(); + await clientTransport.start(); + + void clientTransport.output.pipeTo(serverTransport.input); + void serverTransport.output.pipeTo(clientTransport.input); + + mockElicitInput = createMockElicitInput(); + + mcpClient = new Client( + { + name: "test-client", + version: "1.2.3", + }, + { + capabilities: clientCapabilities, + } + ); + + const mockMcpServer = new McpServer({ + name: "test-server", + version: "5.2.3", + }); + + // Mock the elicitInput method on the server instance + Object.assign(mockMcpServer.server, { elicitInput: mockElicitInput.mock }); + + // Create elicitation instance + const elicitation = new Elicitation({ server: mockMcpServer.server }); + + mcpServer = new Server({ + session, + userConfig, + telemetry, + mcpServer: mockMcpServer, + connectionErrorHandler, + elicitation, + }); + + await mcpServer.connect(serverTransport); + await mcpClient.connect(clientTransport); + } + + async function cleanup(): Promise { + await mcpServer?.session.disconnect(); + await mcpClient?.close(); + deviceId?.close(); + } + + afterEach(async () => { + await cleanup(); + vi.clearAllMocks(); + }); + + describe("with elicitation support", () => { + beforeEach(async () => { + await setupWithConfig({}, { elicitation: {} }); + }); + + describe("tools requiring confirmation", () => { + it("should request confirmation for drop-database tool and proceed when confirmed", async () => { + mockElicitInput.confirmYes(); + + const result = await mcpClient.callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to drop the `test-db` database"), + requestedSchema: { + type: "object", + properties: { + confirmation: { + type: "string", + title: "Would you like to confirm?", + description: "Would you like to confirm?", + enum: ["Yes", "No"], + enumNames: ["Yes, I confirm", "No, I do not confirm"], + }, + }, + required: ["confirmation"], + }, + }); + + // Should attempt to execute (will fail due to no connection, but confirms flow worked) + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("You need to connect to a MongoDB instance"), + }), + ]) + ); + }); + + it("should not proceed when user declines confirmation", async () => { + mockElicitInput.confirmNo(); + + const result = await mcpClient.callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([ + { + type: "text", + text: "User did not confirm the execution of the `drop-database` tool so the operation was not performed.", + }, + ]); + }); + + it("should request confirmation for drop-collection tool", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "drop-collection", + arguments: { database: "test-db", collection: "test-collection" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to drop the `test-collection` collection"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should request confirmation for delete-many tool", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "delete-many", + arguments: { + database: "test-db", + collection: "test-collection", + filter: { status: "inactive" }, + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to delete documents"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should request confirmation for create-db-user tool", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "atlas-create-db-user", + arguments: { + projectId: "test-project", + username: "test-user", + roles: [{ roleName: "read", databaseName: "test-db" }], + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to create a database user"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should request confirmation for create-access-list tool", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "atlas-create-access-list", + arguments: { + projectId: "test-project", + ipAddresses: ["192.168.1.1"], + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringContaining("You are about to add the following entries to the access list"), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + }); + + describe("tools not requiring confirmation", () => { + it("should not request confirmation for read operations", async () => { + const result = await mcpClient.callTool({ + name: "list-databases", + arguments: {}, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected + expect(result.isError).toBe(true); + }); + + it("should not request confirmation for find operations", async () => { + const result = await mcpClient.callTool({ + name: "find", + arguments: { + database: "test-db", + collection: "test-collection", + }, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected + expect(result.isError).toBe(true); + }); + }); + }); + + describe("without elicitation support", () => { + beforeEach(async () => { + await setupWithConfig({}, {}); // No elicitation capability + }); + + it("should proceed without confirmation for destructive tools when client lacks elicitation support", async () => { + const result = await mcpClient.callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected, but confirms flow bypassed confirmation + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("You need to connect to a MongoDB instance"), + }), + ]) + ); + }); + }); + + describe("custom confirmation configuration", () => { + it("should respect custom confirmationRequiredTools configuration", async () => { + await setupWithConfig({ confirmationRequiredTools: ["list-databases"] }, { elicitation: {} }); + + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "list-databases", + arguments: {}, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + }); + + it("should not request confirmation when tool is removed from confirmationRequiredTools", async () => { + await setupWithConfig( + { confirmationRequiredTools: [] }, // Empty list + { elicitation: {} } + ); + + const result = await mcpClient.callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Should fail with connection error since we're not connected + expect(result.isError).toBe(true); + }); + + it("should work with partial confirmation lists", async () => { + await setupWithConfig( + { confirmationRequiredTools: ["drop-database"] }, // Only drop-database requires confirmation + { elicitation: {} } + ); + + mockElicitInput.confirmYes(); + + // This should require confirmation + await mcpClient.callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + + mockElicitInput.clear(); + + // This should not require confirmation + await mcpClient.callTool({ + name: "drop-collection", + arguments: { database: "test-db", collection: "test-collection" }, + }); + + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + }); + }); + + describe("confirmation message content validation", () => { + beforeEach(async () => { + await setupWithConfig({}, { elicitation: {} }); + }); + + it("should include specific details in create-db-user confirmation", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "atlas-create-db-user", + arguments: { + projectId: "my-project-123", + username: "myuser", + password: "mypassword", + roles: [ + { roleName: "readWrite", databaseName: "mydb" }, + { roleName: "read", databaseName: "logs", collectionName: "events" }, + ], + clusters: ["cluster1", "cluster2"], + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringMatching(/project.*my-project-123/), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should include filter details in delete-many confirmation", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "delete-many", + arguments: { + database: "mydb", + collection: "users", + filter: { status: "inactive", lastLogin: { $lt: "2023-01-01" } }, + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringMatching(/mydb.*database/), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + + it("should handle empty filter in delete-many confirmation", async () => { + mockElicitInput.confirmYes(); + + await mcpClient.callTool({ + name: "delete-many", + arguments: { + database: "mydb", + collection: "temp", + filter: {}, + }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringMatching(/mydb.*database/), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); + }); + }); + + describe("error handling in confirmation flow", () => { + beforeEach(async () => { + await setupWithConfig({}, { elicitation: {} }); + }); + + it("should handle confirmation errors gracefully", async () => { + mockElicitInput.rejectWith(new Error("Confirmation service unavailable")); + + const result = await mcpClient.callTool({ + name: "drop-database", + arguments: { database: "test-db" }, + }); + + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Error running drop-database"), + }), + ]) + ); + }); + }); +}); diff --git a/tests/unit/elicitation.test.ts b/tests/unit/elicitation.test.ts new file mode 100644 index 000000000..eeaa81ae3 --- /dev/null +++ b/tests/unit/elicitation.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Elicitation } from "../../src/elicitation.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMockElicitInput, createMockGetClientCapabilities } from "../utils/elicitationMocks.js"; + +describe("Elicitation", () => { + let elicitation: Elicitation; + let mockGetClientCapabilities: ReturnType; + let mockElicitInput: ReturnType; + + beforeEach(() => { + mockGetClientCapabilities = createMockGetClientCapabilities(); + mockElicitInput = createMockElicitInput(); + elicitation = new Elicitation({ + server: { + getClientCapabilities: mockGetClientCapabilities, + elicitInput: mockElicitInput.mock, + } as unknown as McpServer["server"], + }); + }); + + describe("supportsElicitation", () => { + it("should return true when client supports elicitation", () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + + const result = elicitation.supportsElicitation(); + + expect(result).toBe(true); + expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); + }); + + it("should return false when client does not support elicitation", () => { + mockGetClientCapabilities.mockReturnValue({}); + + const result = elicitation.supportsElicitation(); + + expect(result).toBe(false); + expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); + }); + + it("should return false when client capabilities are undefined", () => { + mockGetClientCapabilities.mockReturnValue(undefined); + + const result = elicitation.supportsElicitation(); + + expect(result).toBe(false); + expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); + }); + + it("should return false when elicitation capability is explicitly undefined", () => { + mockGetClientCapabilities.mockReturnValue(undefined); + + const result = elicitation.supportsElicitation(); + + expect(result).toBe(false); + expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); + }); + }); + + describe("requestConfirmation", () => { + const testMessage = "Are you sure you want to proceed?"; + + it("should return true when client does not support elicitation", async () => { + mockGetClientCapabilities.mockReturnValue({}); + + const result = await elicitation.requestConfirmation(testMessage); + + expect(result).toBe(true); + expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).not.toHaveBeenCalled(); + }); + + it("should return true when user confirms with 'Yes' and action is 'accept'", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.confirmYes(); + + const result = await elicitation.requestConfirmation(testMessage); + + expect(result).toBe(true); + expect(mockGetClientCapabilities).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: testMessage, + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + }); + }); + + it("should return false when user selects 'No' with action 'accept'", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.confirmNo(); + + const result = await elicitation.requestConfirmation(testMessage); + + expect(result).toBe(false); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + }); + + it("should return false when content is undefined", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.acceptWith(undefined); + + const result = await elicitation.requestConfirmation(testMessage); + + expect(result).toBe(false); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + }); + + it("should return false when confirmation field is missing", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.acceptWith({}); + + const result = await elicitation.requestConfirmation(testMessage); + + expect(result).toBe(false); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + }); + + it("should return false when user cancels", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + mockElicitInput.cancel(); + + const result = await elicitation.requestConfirmation(testMessage); + + expect(result).toBe(false); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + }); + + it("should handle elicitInput erroring", async () => { + mockGetClientCapabilities.mockReturnValue({ elicitation: {} }); + const error = new Error("Elicitation failed"); + mockElicitInput.rejectWith(error); + + await expect(elicitation.requestConfirmation(testMessage)).rejects.toThrow("Elicitation failed"); + expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/toolBase.test.ts b/tests/unit/toolBase.test.ts new file mode 100644 index 000000000..0e7d958c8 --- /dev/null +++ b/tests/unit/toolBase.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, type MockedFunction } from "vitest"; +import { z } from "zod"; +import { ToolBase, type OperationType, type ToolCategory, type ToolConstructorParams } from "../../src/tools/tool.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { Session } from "../../src/common/session.js"; +import type { UserConfig } from "../../src/common/config.js"; +import type { Telemetry } from "../../src/telemetry/telemetry.js"; +import type { Elicitation } from "../../src/elicitation.js"; +import type { CompositeLogger } from "../../src/common/logger.js"; +import type { TelemetryToolMetadata, ToolCallbackArgs } from "../../src/tools/tool.js"; + +describe("ToolBase", () => { + let mockSession: Session; + let mockLogger: CompositeLogger; + let mockConfig: UserConfig; + let mockTelemetry: Telemetry; + let mockElicitation: Elicitation; + let mockRequestConfirmation: MockedFunction<(message: string) => Promise>; + let testTool: TestTool; + + beforeEach(() => { + mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + } as unknown as CompositeLogger; + + mockSession = { + logger: mockLogger, + } as Session; + + mockConfig = { + confirmationRequiredTools: [], + } as unknown as UserConfig; + + mockTelemetry = {} as Telemetry; + + mockRequestConfirmation = vi.fn(); + mockElicitation = { + requestConfirmation: mockRequestConfirmation, + } as unknown as Elicitation; + + const constructorParams: ToolConstructorParams = { + session: mockSession, + config: mockConfig, + telemetry: mockTelemetry, + elicitation: mockElicitation, + }; + + testTool = new TestTool(constructorParams); + }); + + describe("verifyConfirmed", () => { + it("should return true when tool is not in confirmationRequiredTools list", async () => { + mockConfig.confirmationRequiredTools = ["other-tool", "another-tool"]; + + const args = [ + { param1: "test" }, + {} as ToolCallbackArgs<(typeof testTool)["argsShape"]>[1], + ] as ToolCallbackArgs<(typeof testTool)["argsShape"]>; + const result = await testTool.verifyConfirmed(args); + + expect(result).toBe(true); + expect(mockRequestConfirmation).not.toHaveBeenCalled(); + }); + + it("should return true when confirmationRequiredTools list is empty", async () => { + mockConfig.confirmationRequiredTools = []; + + const args = [{ param1: "test" }, {} as ToolCallbackArgs<(typeof testTool)["argsShape"]>[1]]; + const result = await testTool.verifyConfirmed(args as ToolCallbackArgs<(typeof testTool)["argsShape"]>); + + expect(result).toBe(true); + expect(mockRequestConfirmation).not.toHaveBeenCalled(); + }); + + it("should call requestConfirmation when tool is in confirmationRequiredTools list", async () => { + mockConfig.confirmationRequiredTools = ["test-tool"]; + mockRequestConfirmation.mockResolvedValue(true); + + const args = [{ param1: "test", param2: 42 }, {} as ToolCallbackArgs<(typeof testTool)["argsShape"]>[1]]; + const result = await testTool.verifyConfirmed(args as ToolCallbackArgs<(typeof testTool)["argsShape"]>); + + expect(result).toBe(true); + expect(mockRequestConfirmation).toHaveBeenCalledTimes(1); + expect(mockRequestConfirmation).toHaveBeenCalledWith( + "You are about to execute the `test-tool` tool which requires additional confirmation. Would you like to proceed?" + ); + }); + + it("should return false when user declines confirmation", async () => { + mockConfig.confirmationRequiredTools = ["test-tool"]; + mockRequestConfirmation.mockResolvedValue(false); + + const args = [{ param1: "test" }, {} as ToolCallbackArgs<(typeof testTool)["argsShape"]>[1]]; + const result = await testTool.verifyConfirmed(args as ToolCallbackArgs<(typeof testTool)["argsShape"]>); + + expect(result).toBe(false); + expect(mockRequestConfirmation).toHaveBeenCalledTimes(1); + }); + }); +}); + +class TestTool extends ToolBase { + public name = "test-tool"; + public category: ToolCategory = "mongodb"; + public operationType: OperationType = "delete"; + protected description = "A test tool for verification tests"; + protected argsShape = { + param1: z.string().describe("Test parameter 1"), + param2: z.number().optional().describe("Test parameter 2"), + }; + + protected async execute(): Promise { + return Promise.resolve({ + content: [ + { + type: "text", + text: "Test tool executed successfully", + }, + ], + }); + } + + protected resolveTelemetryMetadata(): TelemetryToolMetadata { + return {}; + } +} diff --git a/tests/utils/elicitationMocks.ts b/tests/utils/elicitationMocks.ts new file mode 100644 index 000000000..da128fd90 --- /dev/null +++ b/tests/utils/elicitationMocks.ts @@ -0,0 +1,67 @@ +import type { MockedFunction } from "vitest"; +import { vi } from "vitest"; + +/** + * Mock types based on the MCP SDK types, but simplified for testing + */ +export type MockClientCapabilities = { + [x: string]: unknown; + elicitation?: { [x: string]: unknown }; +}; + +export type MockElicitInput = { + message: string; + requestedSchema: unknown; +}; + +export type MockElicitResult = { + action: string; + content?: { + confirmation?: string; + }; +}; + +/** + * Creates mock functions for elicitation testing + */ +export function createMockElicitInput(): { + mock: MockedFunction<() => Promise>; + confirmYes: () => void; + confirmNo: () => void; + acceptWith: (content: { confirmation?: string } | undefined) => void; + cancel: () => void; + rejectWith: (error: Error) => void; + clear: () => void; +} { + const mockFn = vi.fn(); + + return { + mock: mockFn, + confirmYes: () => + mockFn.mockResolvedValue({ + action: "accept", + content: { confirmation: "Yes" }, + }), + confirmNo: () => + mockFn.mockResolvedValue({ + action: "accept", + content: { confirmation: "No" }, + }), + acceptWith: (content: { confirmation?: string } | undefined) => + mockFn.mockResolvedValue({ + action: "accept", + content, + }), + cancel: () => + mockFn.mockResolvedValue({ + action: "cancel", + content: undefined, + }), + rejectWith: (error: Error) => mockFn.mockRejectedValue(error), + clear: () => mockFn.mockClear(), + }; +} + +export function createMockGetClientCapabilities(): MockedFunction<() => MockClientCapabilities | undefined> { + return vi.fn(); +} From faa28244806cd18cac1130dd2f5129b179248dc7 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 13:08:28 +0200 Subject: [PATCH 2/9] chore: remove redundant test --- tests/integration/elicitation.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index 3fa9a343f..8d297900f 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -403,24 +403,6 @@ describe("Elicitation Integration Tests", () => { requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), }); }); - - it("should handle empty filter in delete-many confirmation", async () => { - mockElicitInput.confirmYes(); - - await mcpClient.callTool({ - name: "delete-many", - arguments: { - database: "mydb", - collection: "temp", - filter: {}, - }, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching(/mydb.*database/), - requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), - }); - }); }); describe("error handling in confirmation flow", () => { From 7cf9386b0152ee5592453b3c2200b160e0c58d41 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 13:20:35 +0200 Subject: [PATCH 3/9] chore: add elicitation to helpers --- tests/integration/helpers.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 1f28995dd..76e5be37f 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -15,6 +15,7 @@ import { MCPConnectionManager } from "../../src/common/connectionManager.js"; import { DeviceId } from "../../src/helpers/deviceId.js"; import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js"; import { Keychain } from "../../src/common/keychain.js"; +import { Elicitation } from "../../src/elicitation.js"; interface ParameterInfo { name: string; @@ -96,14 +97,19 @@ export function setupIntegrationTest( const telemetry = Telemetry.create(session, userConfig, deviceId); + const mcpServerInstance = new McpServer({ + name: "test-server", + version: "5.2.3", + }); + + const elicitation = new Elicitation({ server: mcpServerInstance.server }); + mcpServer = new Server({ session, userConfig, telemetry, - mcpServer: new McpServer({ - name: "test-server", - version: "5.2.3", - }), + mcpServer: mcpServerInstance, + elicitation, connectionErrorHandler, }); From 80258d4f427ccf1c37f525e5183800ea4098335e Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 13:47:44 +0200 Subject: [PATCH 4/9] chore(tests): use actual integration structure --- tests/integration/elicitation.test.ts | 220 +++++++------------------- tests/integration/helpers.ts | 20 ++- 2 files changed, 72 insertions(+), 168 deletions(-) diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index 8d297900f..11e65f748 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -1,33 +1,13 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Server } from "../../src/server.js"; -import { Session } from "../../src/common/session.js"; -import { CompositeLogger } from "../../src/common/logger.js"; -import { MCPConnectionManager } from "../../src/common/connectionManager.js"; -import { DeviceId } from "../../src/helpers/deviceId.js"; -import { ExportsManager } from "../../src/common/exportsManager.js"; -import { Telemetry } from "../../src/telemetry/telemetry.js"; -import { Keychain } from "../../src/common/keychain.js"; -import { InMemoryTransport } from "./inMemoryTransport.js"; -import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js"; +import { describe, it, expect } from "vitest"; import { defaultDriverOptions, type UserConfig } from "../../src/common/config.js"; -import { defaultTestConfig } from "./helpers.js"; +import { defaultTestConfig, setupIntegrationTest } from "./helpers.js"; import { Elicitation } from "../../src/elicitation.js"; -import { type MockClientCapabilities, createMockElicitInput } from "../utils/elicitationMocks.js"; +import { createMockElicitInput } from "../utils/elicitationMocks.js"; describe("Elicitation Integration Tests", () => { - let mcpClient: Client; - let mcpServer: Server; - let deviceId: DeviceId; - let mockElicitInput: ReturnType; - - async function setupWithConfig( - config: Partial = {}, - clientCapabilities: MockClientCapabilities = {} - ): Promise { - const userConfig: UserConfig = { + function createTestConfig(config: Partial = {}): UserConfig { + return { ...defaultTestConfig, telemetry: "disabled", // Add fake API credentials so Atlas tools get registered @@ -35,94 +15,21 @@ describe("Elicitation Integration Tests", () => { apiClientSecret: "test-client-secret", ...config, }; - - const driverOptions = defaultDriverOptions; - const logger = new CompositeLogger(); - const exportsManager = ExportsManager.init(userConfig, logger); - deviceId = DeviceId.create(logger); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const connectionManager = new MCPConnectionManager(userConfig, driverOptions, logger, deviceId); - const session = new Session({ - apiBaseUrl: userConfig.apiBaseUrl, - apiClientId: userConfig.apiClientId, - apiClientSecret: userConfig.apiClientSecret, - logger, - exportsManager, - connectionManager, - keychain: new Keychain(), - }); - // Mock API validation for tests - const mockFn = vi.fn().mockResolvedValue(true); - session.apiClient.validateAccessToken = mockFn; - - const telemetry = Telemetry.create(session, userConfig, deviceId); - - const clientTransport = new InMemoryTransport(); - const serverTransport = new InMemoryTransport(); - - await serverTransport.start(); - await clientTransport.start(); - - void clientTransport.output.pipeTo(serverTransport.input); - void serverTransport.output.pipeTo(clientTransport.input); - - mockElicitInput = createMockElicitInput(); - - mcpClient = new Client( - { - name: "test-client", - version: "1.2.3", - }, - { - capabilities: clientCapabilities, - } - ); - - const mockMcpServer = new McpServer({ - name: "test-server", - version: "5.2.3", - }); - - // Mock the elicitInput method on the server instance - Object.assign(mockMcpServer.server, { elicitInput: mockElicitInput.mock }); - - // Create elicitation instance - const elicitation = new Elicitation({ server: mockMcpServer.server }); - - mcpServer = new Server({ - session, - userConfig, - telemetry, - mcpServer: mockMcpServer, - connectionErrorHandler, - elicitation, - }); - - await mcpServer.connect(serverTransport); - await mcpClient.connect(clientTransport); } - async function cleanup(): Promise { - await mcpServer?.session.disconnect(); - await mcpClient?.close(); - deviceId?.close(); - } - - afterEach(async () => { - await cleanup(); - vi.clearAllMocks(); - }); - describe("with elicitation support", () => { - beforeEach(async () => { - await setupWithConfig({}, { elicitation: {} }); - }); + const mockElicitInput = createMockElicitInput(); + const integration = setupIntegrationTest( + () => createTestConfig(), + () => defaultDriverOptions, + { elicitInput: mockElicitInput } + ); describe("tools requiring confirmation", () => { it("should request confirmation for drop-database tool and proceed when confirmed", async () => { mockElicitInput.confirmYes(); - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, }); @@ -160,7 +67,7 @@ describe("Elicitation Integration Tests", () => { it("should not proceed when user declines confirmation", async () => { mockElicitInput.confirmNo(); - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, }); @@ -178,7 +85,7 @@ describe("Elicitation Integration Tests", () => { it("should request confirmation for drop-collection tool", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "drop-collection", arguments: { database: "test-db", collection: "test-collection" }, }); @@ -193,7 +100,7 @@ describe("Elicitation Integration Tests", () => { it("should request confirmation for delete-many tool", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "delete-many", arguments: { database: "test-db", @@ -212,10 +119,10 @@ describe("Elicitation Integration Tests", () => { it("should request confirmation for create-db-user tool", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "atlas-create-db-user", arguments: { - projectId: "test-project", + projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string username: "test-user", roles: [{ roleName: "read", databaseName: "test-db" }], }, @@ -231,10 +138,10 @@ describe("Elicitation Integration Tests", () => { it("should request confirmation for create-access-list tool", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "atlas-create-access-list", arguments: { - projectId: "test-project", + projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string ipAddresses: ["192.168.1.1"], }, }); @@ -249,7 +156,7 @@ describe("Elicitation Integration Tests", () => { describe("tools not requiring confirmation", () => { it("should not request confirmation for read operations", async () => { - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "list-databases", arguments: {}, }); @@ -260,7 +167,7 @@ describe("Elicitation Integration Tests", () => { }); it("should not request confirmation for find operations", async () => { - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "find", arguments: { database: "test-db", @@ -276,17 +183,19 @@ describe("Elicitation Integration Tests", () => { }); describe("without elicitation support", () => { - beforeEach(async () => { - await setupWithConfig({}, {}); // No elicitation capability - }); + const integration = setupIntegrationTest( + () => createTestConfig(), + () => defaultDriverOptions, + { getClientCapabilities: () => ({}) } + ); it("should proceed without confirmation for destructive tools when client lacks elicitation support", async () => { - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, }); - expect(mockElicitInput.mock).not.toHaveBeenCalled(); + // Note: No mock assertions needed since elicitation is disabled // Should fail with connection error since we're not connected, but confirms flow bypassed confirmation expect(result.isError).toBe(true); expect(result.content).toEqual( @@ -301,12 +210,17 @@ describe("Elicitation Integration Tests", () => { }); describe("custom confirmation configuration", () => { - it("should respect custom confirmationRequiredTools configuration", async () => { - await setupWithConfig({ confirmationRequiredTools: ["list-databases"] }, { elicitation: {} }); + const mockElicitInput = createMockElicitInput(); + const integration = setupIntegrationTest( + () => createTestConfig({ confirmationRequiredTools: ["list-databases"] }), + () => defaultDriverOptions, + { elicitInput: mockElicitInput } + ); + it("should respect custom confirmationRequiredTools configuration", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "list-databases", arguments: {}, }); @@ -315,12 +229,7 @@ describe("Elicitation Integration Tests", () => { }); it("should not request confirmation when tool is removed from confirmationRequiredTools", async () => { - await setupWithConfig( - { confirmationRequiredTools: [] }, // Empty list - { elicitation: {} } - ); - - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, }); @@ -329,47 +238,23 @@ describe("Elicitation Integration Tests", () => { // Should fail with connection error since we're not connected expect(result.isError).toBe(true); }); - - it("should work with partial confirmation lists", async () => { - await setupWithConfig( - { confirmationRequiredTools: ["drop-database"] }, // Only drop-database requires confirmation - { elicitation: {} } - ); - - mockElicitInput.confirmYes(); - - // This should require confirmation - await mcpClient.callTool({ - name: "drop-database", - arguments: { database: "test-db" }, - }); - - expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); - - mockElicitInput.clear(); - - // This should not require confirmation - await mcpClient.callTool({ - name: "drop-collection", - arguments: { database: "test-db", collection: "test-collection" }, - }); - - expect(mockElicitInput.mock).not.toHaveBeenCalled(); - }); }); describe("confirmation message content validation", () => { - beforeEach(async () => { - await setupWithConfig({}, { elicitation: {} }); - }); + const mockElicitInput = createMockElicitInput(); + const integration = setupIntegrationTest( + () => createTestConfig(), + () => defaultDriverOptions, + { elicitInput: mockElicitInput } + ); it("should include specific details in create-db-user confirmation", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "atlas-create-db-user", arguments: { - projectId: "my-project-123", + projectId: "507f1f77bcf86cd799439011", // Valid 24-char hex string username: "myuser", password: "mypassword", roles: [ @@ -381,7 +266,7 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledWith({ - message: expect.stringMatching(/project.*my-project-123/), + message: expect.stringMatching(/project.*507f1f77bcf86cd799439011/), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), }); }); @@ -389,7 +274,7 @@ describe("Elicitation Integration Tests", () => { it("should include filter details in delete-many confirmation", async () => { mockElicitInput.confirmYes(); - await mcpClient.callTool({ + await integration.mcpClient().callTool({ name: "delete-many", arguments: { database: "mydb", @@ -406,14 +291,17 @@ describe("Elicitation Integration Tests", () => { }); describe("error handling in confirmation flow", () => { - beforeEach(async () => { - await setupWithConfig({}, { elicitation: {} }); - }); + const mockElicitInput = createMockElicitInput(); + const integration = setupIntegrationTest( + () => createTestConfig(), + () => defaultDriverOptions, + { elicitInput: mockElicitInput } + ); it("should handle confirmation errors gracefully", async () => { mockElicitInput.rejectWith(new Error("Confirmation service unavailable")); - const result = await mcpClient.callTool({ + const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, }); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 76e5be37f..0a2ccfe84 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -16,6 +16,7 @@ import { DeviceId } from "../../src/helpers/deviceId.js"; import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js"; import { Keychain } from "../../src/common/keychain.js"; import { Elicitation } from "../../src/elicitation.js"; +import type { MockClientCapabilities, createMockElicitInput } from "../utils/elicitationMocks.js"; interface ParameterInfo { name: string; @@ -42,7 +43,14 @@ export const defaultDriverOptions: DriverOptions = { export function setupIntegrationTest( getUserConfig: () => UserConfig, - getDriverOptions: () => DriverOptions + getDriverOptions: () => DriverOptions, + { + elicitInput, + getClientCapabilities, + }: { + elicitInput?: ReturnType; + getClientCapabilities?: () => MockClientCapabilities; + } = {} ): IntegrationTest { let mcpClient: Client | undefined; let mcpServer: Server | undefined; @@ -51,6 +59,7 @@ export function setupIntegrationTest( beforeAll(async () => { const userConfig = getUserConfig(); const driverOptions = getDriverOptions(); + const clientCapabilities = getClientCapabilities?.() ?? (elicitInput ? { elicitation: {} } : {}); const clientTransport = new InMemoryTransport(); const serverTransport = new InMemoryTransport(); @@ -68,7 +77,7 @@ export function setupIntegrationTest( version: "1.2.3", }, { - capabilities: {}, + capabilities: clientCapabilities, } ); @@ -102,6 +111,11 @@ export function setupIntegrationTest( version: "5.2.3", }); + // Mock elicitation if provided + if (elicitInput) { + Object.assign(mcpServerInstance.server, { elicitInput: elicitInput.mock }); + } + const elicitation = new Elicitation({ server: mcpServerInstance.server }); mcpServer = new Server({ @@ -121,6 +135,8 @@ export function setupIntegrationTest( if (mcpServer) { await mcpServer.session.disconnect(); } + + vi.clearAllMocks(); }); afterAll(async () => { From 6c3e17a3a90d060a89b5f8b8cb000fd623b3c732 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 13:51:44 +0200 Subject: [PATCH 5/9] chore: fix MongoDB tests --- tests/integration/elicitation.test.ts | 2 +- tests/integration/tools/mongodb/mongodbTool.test.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index 11e65f748..a269e6cee 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -228,7 +228,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); }); - it("should not request confirmation when tool is removed from confirmationRequiredTools", async () => { + it("should not request confirmation when tool is removed from default confirmationRequiredTools", async () => { const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, diff --git a/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index 1759904e2..9e406332d 100644 --- a/tests/integration/tools/mongodb/mongodbTool.test.ts +++ b/tests/integration/tools/mongodb/mongodbTool.test.ts @@ -18,6 +18,7 @@ import { defaultTestConfig } from "../../helpers.js"; import { setupMongoDBIntegrationTest } from "./mongodbHelpers.js"; import { ErrorCodes } from "../../../../src/common/errors.js"; import { Keychain } from "../../../../src/common/keychain.js"; +import { Elicitation } from "../../../../src/elicitation.js"; const injectedErrorHandler: ConnectionErrorHandler = (error) => { switch (error.code) { @@ -123,7 +124,14 @@ describe("MongoDBTool implementations", () => { connectionErrorHandler: errorHandler, }); - tool = new RandomTool(session, userConfig, telemetry); + const elicitation = new Elicitation({ server: mcpServer.server }); + + tool = new RandomTool({ + session, + config: userConfig, + telemetry, + elicitation, + }); tool.register(mcpServer); await mcpServer.connect(serverTransport); From 3425bb680feecad292e225e42cd06f665d8363d7 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 13:56:10 +0200 Subject: [PATCH 6/9] chore: fixup --- .../integration/tools/mongodb/mongodbTool.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/integration/tools/mongodb/mongodbTool.test.ts b/tests/integration/tools/mongodb/mongodbTool.test.ts index 9e406332d..f2e4930a2 100644 --- a/tests/integration/tools/mongodb/mongodbTool.test.ts +++ b/tests/integration/tools/mongodb/mongodbTool.test.ts @@ -113,19 +113,21 @@ describe("MongoDBTool implementations", () => { } ); + const internalMcpServer = new McpServer({ + name: "test-server", + version: "5.2.3", + }); + const elicitation = new Elicitation({ server: internalMcpServer.server }); + mcpServer = new Server({ session, userConfig, telemetry, - mcpServer: new McpServer({ - name: "test-server", - version: "5.2.3", - }), + mcpServer: internalMcpServer, connectionErrorHandler: errorHandler, + elicitation, }); - const elicitation = new Elicitation({ server: mcpServer.server }); - tool = new RandomTool({ session, config: userConfig, From 896150c4ae4170aec658160bc7d31c7bb8e04b25 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 13:58:43 +0200 Subject: [PATCH 7/9] chore: remove voids --- src/server.ts | 2 +- src/tools/tool.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index dc2556ef7..2e6ac2c46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -198,7 +198,7 @@ export class Server { } } - void this.telemetry.emitEvents([event]); + this.telemetry.emitEvents([event]); } private registerTools(): void { diff --git a/src/tools/tool.ts b/src/tools/tool.ts index d802c2d97..8a9a0b9f5 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -284,7 +284,7 @@ export abstract class ToolBase { event.properties.project_id = metadata.projectId; } - void this.telemetry.emitEvents([event]); + this.telemetry.emitEvents([event]); } } From a19e2f0585b03a3c461b6087956ab84a375c32f1 Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 14:02:07 +0200 Subject: [PATCH 8/9] chore: reformat --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 46391b6cc..b91c1fbc4 100644 --- a/README.md +++ b/README.md @@ -338,27 +338,27 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow ### Configuration Options -| CLI Option | Environment Variable | Default | Description | -| -------------------------------------- | --------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apiClientId` | `MDB_MCP_API_CLIENT_ID` | | Atlas API client ID for authentication. Required for running Atlas tools. | -| `apiClientSecret` | `MDB_MCP_API_CLIENT_SECRET` | | Atlas API client secret for authentication. Required for running Atlas tools. | -| `connectionString` | `MDB_MCP_CONNECTION_STRING` | | MongoDB connection string for direct database connections. Optional, if not set, you'll need to call the `connect` tool before interacting with MongoDB data. | -| `loggers` | `MDB_MCP_LOGGERS` | disk,mcp | Comma separated values, possible values are `mcp`, `disk` and `stderr`. See [Logger Options](#logger-options) for details. | -| `logPath` | `MDB_MCP_LOG_PATH` | see note\* | Folder to store logs. | -| `disabledTools` | `MDB_MCP_DISABLED_TOOLS` | | An array of tool names, operation types, and/or categories of tools that will be disabled. | -| `confirmationRequiredTools` | `MDB_MCP_CONFIRMATION_REQUIRED_TOOLS` | create-access-list,create-db-user,drop-database,drop-collection,delete-many | An array of tool names that require user confirmation before execution. **Requires the client to support [elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation)**. | -| `readOnly` | `MDB_MCP_READ_ONLY` | false | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. | -| `indexCheck` | `MDB_MCP_INDEX_CHECK` | false | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. | -| `telemetry` | `MDB_MCP_TELEMETRY` | enabled | When set to disabled, disables telemetry collection. | -| `transport` | `MDB_MCP_TRANSPORT` | stdio | Either 'stdio' or 'http'. | -| `httpPort` | `MDB_MCP_HTTP_PORT` | 3000 | Port number. | -| `httpHost` | `MDB_MCP_HTTP_HOST` | 127.0.0.1 | Host to bind the http server. | -| `idleTimeoutMs` | `MDB_MCP_IDLE_TIMEOUT_MS` | 600000 | Idle timeout for a client to disconnect (only applies to http transport). | -| `notificationTimeoutMs` | `MDB_MCP_NOTIFICATION_TIMEOUT_MS` | 540000 | Notification timeout for a client to be aware of diconnect (only applies to http transport). | -| `exportsPath` | `MDB_MCP_EXPORTS_PATH` | see note\* | Folder to store exported data files. | -| `exportTimeoutMs` | `MDB_MCP_EXPORT_TIMEOUT_MS` | 300000 | Time in milliseconds after which an export is considered expired and eligible for cleanup. | -| `exportCleanupIntervalMs` | `MDB_MCP_EXPORT_CLEANUP_INTERVAL_MS` | 120000 | Time in milliseconds between export cleanup cycles that remove expired export files. | -| `atlasTemporaryDatabaseUserLifetimeMs` | `MDB_MCP_ATLAS_TEMPORARY_DATABASE_USER_LIFETIME_MS` | 14400000 | Time in milliseconds that temporary database users created when connecting to MongoDB Atlas clusters will remain active before being automatically deleted. | +| CLI Option | Environment Variable | Default | Description | +| -------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apiClientId` | `MDB_MCP_API_CLIENT_ID` | | Atlas API client ID for authentication. Required for running Atlas tools. | +| `apiClientSecret` | `MDB_MCP_API_CLIENT_SECRET` | | Atlas API client secret for authentication. Required for running Atlas tools. | +| `connectionString` | `MDB_MCP_CONNECTION_STRING` | | MongoDB connection string for direct database connections. Optional, if not set, you'll need to call the `connect` tool before interacting with MongoDB data. | +| `loggers` | `MDB_MCP_LOGGERS` | disk,mcp | Comma separated values, possible values are `mcp`, `disk` and `stderr`. See [Logger Options](#logger-options) for details. | +| `logPath` | `MDB_MCP_LOG_PATH` | see note\* | Folder to store logs. | +| `disabledTools` | `MDB_MCP_DISABLED_TOOLS` | | An array of tool names, operation types, and/or categories of tools that will be disabled. | +| `confirmationRequiredTools` | `MDB_MCP_CONFIRMATION_REQUIRED_TOOLS` | create-access-list,create-db-user,drop-database,drop-collection,delete-many | An array of tool names that require user confirmation before execution. **Requires the client to support [elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation)**. | +| `readOnly` | `MDB_MCP_READ_ONLY` | false | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. | +| `indexCheck` | `MDB_MCP_INDEX_CHECK` | false | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. | +| `telemetry` | `MDB_MCP_TELEMETRY` | enabled | When set to disabled, disables telemetry collection. | +| `transport` | `MDB_MCP_TRANSPORT` | stdio | Either 'stdio' or 'http'. | +| `httpPort` | `MDB_MCP_HTTP_PORT` | 3000 | Port number. | +| `httpHost` | `MDB_MCP_HTTP_HOST` | 127.0.0.1 | Host to bind the http server. | +| `idleTimeoutMs` | `MDB_MCP_IDLE_TIMEOUT_MS` | 600000 | Idle timeout for a client to disconnect (only applies to http transport). | +| `notificationTimeoutMs` | `MDB_MCP_NOTIFICATION_TIMEOUT_MS` | 540000 | Notification timeout for a client to be aware of diconnect (only applies to http transport). | +| `exportsPath` | `MDB_MCP_EXPORTS_PATH` | see note\* | Folder to store exported data files. | +| `exportTimeoutMs` | `MDB_MCP_EXPORT_TIMEOUT_MS` | 300000 | Time in milliseconds after which an export is considered expired and eligible for cleanup. | +| `exportCleanupIntervalMs` | `MDB_MCP_EXPORT_CLEANUP_INTERVAL_MS` | 120000 | Time in milliseconds between export cleanup cycles that remove expired export files. | +| `atlasTemporaryDatabaseUserLifetimeMs` | `MDB_MCP_ATLAS_TEMPORARY_DATABASE_USER_LIFETIME_MS` | 14400000 | Time in milliseconds that temporary database users created when connecting to MongoDB Atlas clusters will remain active before being automatically deleted. | #### Logger Options From f8832827957ca88bec794d2438160613c56c8f6e Mon Sep 17 00:00:00 2001 From: gagik Date: Fri, 12 Sep 2025 15:21:38 +0200 Subject: [PATCH 9/9] chore: check for default message, use MCP schema type, tweak formatting of delete docs --- src/elicitation.ts | 10 ++------- src/tools/mongodb/delete/deleteMany.ts | 8 ++++---- tests/integration/elicitation.test.ts | 28 ++++++++++---------------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/elicitation.ts b/src/elicitation.ts index 70f517851..c3d30d5b9 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -1,4 +1,4 @@ -import type { PrimitiveSchemaDefinition } from "@modelcontextprotocol/sdk/types.js"; +import type { ElicitRequest } from "@modelcontextprotocol/sdk/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; export class Elicitation { @@ -37,7 +37,7 @@ export class Elicitation { * The schema for the confirmation question. * TODO: In the future would be good to use Zod 4's toJSONSchema() to generate the schema. */ - public static CONFIRMATION_SCHEMA: MCPElicitationSchema = { + public static CONFIRMATION_SCHEMA: ElicitRequest["params"]["requestedSchema"] = { type: "object", properties: { confirmation: { @@ -51,9 +51,3 @@ export class Elicitation { required: ["confirmation"], }; } - -export type MCPElicitationSchema = { - type: "object"; - properties: Record; - required?: string[]; -}; diff --git a/src/tools/mongodb/delete/deleteMany.ts b/src/tools/mongodb/delete/deleteMany.ts index 77ce0a936..754b0381a 100644 --- a/src/tools/mongodb/delete/deleteMany.ts +++ b/src/tools/mongodb/delete/deleteMany.ts @@ -59,12 +59,12 @@ export class DeleteManyTool extends MongoDBToolBase { protected getConfirmationMessage({ database, collection, filter }: ToolArgs): string { const filterDescription = - filter && Object.keys(filter).length > 0 ? EJSON.stringify(filter) : "All documents (No filter)"; + filter && Object.keys(filter).length > 0 + ? "```json\n" + `{ "filter": ${EJSON.stringify(filter)} }\n` + "```\n\n" + : "- **All documents** (No filter)\n\n"; return ( `You are about to delete documents from the \`${collection}\` collection in the \`${database}\` database:\n\n` + - "```json\n" + - `{ "filter": ${filterDescription} }\n` + - "```\n\n" + + filterDescription + "This operation will permanently remove all documents matching the filter.\n\n" + "**Do you confirm the execution of the action?**" ); diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index a269e6cee..0626fd51a 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -25,7 +25,7 @@ describe("Elicitation Integration Tests", () => { { elicitInput: mockElicitInput } ); - describe("tools requiring confirmation", () => { + describe("tools requiring confirmation by default", () => { it("should request confirmation for drop-database tool and proceed when confirmed", async () => { mockElicitInput.confirmYes(); @@ -37,19 +37,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringContaining("You are about to drop the `test-db` database"), - requestedSchema: { - type: "object", - properties: { - confirmation: { - type: "string", - title: "Would you like to confirm?", - description: "Would you like to confirm?", - enum: ["Yes", "No"], - enumNames: ["Yes, I confirm", "No, I do not confirm"], - }, - }, - required: ["confirmation"], - }, + requestedSchema: Elicitation.CONFIRMATION_SCHEMA, }); // Should attempt to execute (will fail due to no connection, but confirms flow worked) @@ -154,7 +142,7 @@ describe("Elicitation Integration Tests", () => { }); }); - describe("tools not requiring confirmation", () => { + describe("tools not requiring confirmation by default", () => { it("should not request confirmation for read operations", async () => { const result = await integration.mcpClient().callTool({ name: "list-databases", @@ -189,7 +177,7 @@ describe("Elicitation Integration Tests", () => { { getClientCapabilities: () => ({}) } ); - it("should proceed without confirmation for destructive tools when client lacks elicitation support", async () => { + it("should proceed without confirmation for default confirmation-required tools when client lacks elicitation support", async () => { const result = await integration.mcpClient().callTool({ name: "drop-database", arguments: { database: "test-db" }, @@ -217,7 +205,7 @@ describe("Elicitation Integration Tests", () => { { elicitInput: mockElicitInput } ); - it("should respect custom confirmationRequiredTools configuration", async () => { + it("should confirm with a generic message with custom configurations for other tools", async () => { mockElicitInput.confirmYes(); await integration.mcpClient().callTool({ @@ -226,6 +214,12 @@ describe("Elicitation Integration Tests", () => { }); expect(mockElicitInput.mock).toHaveBeenCalledTimes(1); + expect(mockElicitInput.mock).toHaveBeenCalledWith({ + message: expect.stringMatching( + /You are about to execute the `list-databases` tool which requires additional confirmation. Would you like to proceed\?/ + ), + requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + }); }); it("should not request confirmation when tool is removed from default confirmationRequiredTools", async () => {