diff --git a/.gitignore b/.gitignore index 1029194e2..d319e542f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,8 @@ coverage # Generated assets by accuracy runs .accuracy -.DS_Store \ No newline at end of file +.DS_Store + +# Development tool files +.yalc +yalc.lock \ No newline at end of file diff --git a/README.md b/README.md index 9b60e0114..7f30410b2 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow | `exportCleanupIntervalMs` | `MDB_MCP_EXPORT_CLEANUP_INTERVAL_MS` | `120000` | Time in milliseconds between export cleanup cycles that remove expired export files. | | `exportTimeoutMs` | `MDB_MCP_EXPORT_TIMEOUT_MS` | `300000` | Time in milliseconds after which an export is considered expired and eligible for cleanup. | | `exportsPath` | `MDB_MCP_EXPORTS_PATH` | see below\* | Folder to store exported data files. | +| `httpHeaders` | `MDB_MCP_HTTP_HEADERS` | `"{}"` | Header that the HTTP server will validate when making requests (only used when transport is 'http'). | | `httpHost` | `MDB_MCP_HTTP_HOST` | `"127.0.0.1"` | Host address to bind the HTTP server to (only used when transport is 'http'). | | `httpPort` | `MDB_MCP_HTTP_PORT` | `3000` | Port number for the HTTP server (only used when transport is 'http'). Use 0 for a random port. | | `idleTimeoutMs` | `MDB_MCP_IDLE_TIMEOUT_MS` | `600000` | Idle timeout for a client to disconnect (only applies to http transport). | @@ -371,6 +372,8 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow | `readOnly` | `MDB_MCP_READ_ONLY` | `false` | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. | | `telemetry` | `MDB_MCP_TELEMETRY` | `"enabled"` | When set to disabled, disables telemetry collection. | | `transport` | `MDB_MCP_TRANSPORT` | `"stdio"` | Either 'stdio' or 'http'. | +| `vectorSearchDimensions` | `MDB_MCP_VECTOR_SEARCH_DIMENSIONS` | `1024` | Default number of dimensions for vector search embeddings. | +| `vectorSearchSimilarityFunction` | `MDB_MCP_VECTOR_SEARCH_SIMILARITY_FUNCTION` | `"euclidean"` | Default similarity function for vector search: 'euclidean', 'cosine', or 'dotProduct'. | | `voyageApiKey` | `MDB_MCP_VOYAGE_API_KEY` | `""` | API key for Voyage AI embeddings service (required for vector search operations with text-to-embedding conversion). | #### Logger Options diff --git a/eslint-rules/enforce-zod-v4.js b/eslint-rules/enforce-zod-v4.js index 7afa38ecb..c0462cf10 100644 --- a/eslint-rules/enforce-zod-v4.js +++ b/eslint-rules/enforce-zod-v4.js @@ -2,7 +2,10 @@ import path from "path"; // The file that is allowed to import from zod/v4 -const configFilePath = path.resolve(import.meta.dirname, "../src/common/config/userConfig.ts"); +const allowedFilePaths = [ + path.resolve(import.meta.dirname, "../src/common/config/userConfig.ts"), + path.resolve(import.meta.dirname, "../src/common/config/createUserConfig.ts"), +]; // Ref: https://eslint.org/docs/latest/extend/custom-rules export default { @@ -23,7 +26,7 @@ export default { const currentFilePath = path.resolve(context.getFilename()); // Only allow zod v4 import in config.ts - if (currentFilePath === configFilePath) { + if (allowedFilePaths.includes(currentFilePath)) { return {}; } diff --git a/eslint.config.js b/eslint.config.js index 88dfd1467..ec5d21c98 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -88,6 +88,7 @@ export default defineConfig([ "src/types/*.d.ts", "tests/integration/fixtures/", "eslint-rules", + ".yalc", ]), eslintPluginPrettierRecommended, ]); diff --git a/package.json b/package.json index d8dd28fa1..057e767d1 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "@modelcontextprotocol/sdk": "^1.24.2", "@mongodb-js/device-id": "^0.3.1", "@mongodb-js/devtools-proxy-support": "^0.5.3", - "@mongosh/arg-parser": "^3.19.0", + "@mongosh/arg-parser": "^3.23.0", "@mongosh/service-provider-node-driver": "^3.17.5", "ai": "^5.0.72", "bson": "^6.10.4", @@ -134,7 +134,6 @@ "openapi-fetch": "^0.15.0", "ts-levenshtein": "^1.0.7", "voyage-ai-provider": "^2.0.0", - "yargs-parser": "21.1.1", "zod": "^3.25.76" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c614b9ea2..26b7bea0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: ^0.5.3 version: 0.5.5 '@mongosh/arg-parser': - specifier: ^3.19.0 - version: 3.22.0 + specifier: ^3.23.0 + version: 3.23.0(zod@3.25.76) '@mongosh/service-provider-node-driver': specifier: ^3.17.5 version: 3.17.5(mongodb-log-writer@2.4.4(bson@6.10.4)) @@ -69,9 +69,6 @@ importers: voyage-ai-provider: specifier: ^2.0.0 version: 2.0.0(zod@3.25.76) - yargs-parser: - specifier: 21.1.1 - version: 21.1.1 zod: specifier: ^3.25.76 version: 3.25.76 @@ -684,16 +681,22 @@ packages: resolution: {integrity: sha512-JDz2fLKsjMiSNUxKrCpGptsgu7DzsXfu4gnUQ3RhUaBS1d4YbLrt6HejpckAiHIAa+niBpZAeiUsoop0IihWsw==} engines: {node: '>=0.10.0'} - '@mongosh/arg-parser@3.22.0': - resolution: {integrity: sha512-VoQ3lzkMLF76rCZXONGto4zC0RQ74NG2hDXtR/oZGZjdpngR+E9anVGJjT7TymqcFRbXtqvR/Kau9F6a9zctEQ==} + '@mongosh/arg-parser@3.23.0': + resolution: {integrity: sha512-V9lr8LEHI9XKEgBEqPAVDi+CNdl6MNBSJ8A5LAk+vfBM+bdhhtSrANIqUhuEzyV4yNyEqNfYFtqLLhjZd7/doA==} engines: {node: '>=14.15.1'} + peerDependencies: + zod: ^3.25.76 '@mongosh/errors@2.4.4': resolution: {integrity: sha512-Z1z8VuYYgVjleo2N/GssECbc9ZXrKcLS83zMtflGoYujQ2B7CAMB0D9YnQZAvvWd68YQD4IU5HqJkmcrtWo0Dw==} engines: {node: '>=14.15.1'} - '@mongosh/i18n@2.19.0': - resolution: {integrity: sha512-evi+Ep+osBn2CSRX0KWxgthlIMly2qyi+5H2/+wmkQH/oJIobN0EXVz/pzrsLudjehCil/gLlF7Q/TbjTgB3QA==} + '@mongosh/errors@2.4.5': + resolution: {integrity: sha512-niqLgzPv6ZG9Bx0XRJP3NCA9zZM6LbRs/z05GRwJP0B3HShYDKWi7B0D4N/u6RoEt93Y6mMa9Ok72dNSDpIEwA==} + engines: {node: '>=14.15.1'} + + '@mongosh/i18n@2.20.0': + resolution: {integrity: sha512-g0zuKuZ5JhS/ASDizZlqLDzy1yqeMbs5tg40TxMl/55rM2EOPV1MSlfBZg7X0tyxUOATy1sLHWsUquzQSFjVjQ==} engines: {node: '>=14.15.1'} '@mongosh/service-provider-core@3.6.3': @@ -5201,17 +5204,21 @@ snapshots: dependencies: ip-address: 9.0.5 - '@mongosh/arg-parser@3.22.0': + '@mongosh/arg-parser@3.23.0(zod@3.25.76)': dependencies: - '@mongosh/errors': 2.4.4 - '@mongosh/i18n': 2.19.0 + '@mongosh/errors': 2.4.5 + '@mongosh/i18n': 2.20.0 mongodb-connection-string-url: 3.0.2 + yargs-parser: 20.2.9 + zod: 3.25.76 '@mongosh/errors@2.4.4': {} - '@mongosh/i18n@2.19.0': + '@mongosh/errors@2.4.5': {} + + '@mongosh/i18n@2.20.0': dependencies: - '@mongosh/errors': 2.4.4 + '@mongosh/errors': 2.4.5 '@mongosh/service-provider-core@3.6.3(kerberos@2.2.2)(mongodb-client-encryption@6.5.0)(socks@2.8.7)': dependencies: diff --git a/scripts/apply.ts b/scripts/apply.ts index c188965aa..462f8cb71 100755 --- a/scripts/apply.ts +++ b/scripts/apply.ts @@ -1,6 +1,7 @@ +import { parseArgs } from "@mongosh/arg-parser/arg-parser"; import fs from "fs/promises"; import type { OpenAPIV3_1 } from "openapi-types"; -import argv from "yargs-parser"; +import z4 from "zod/v4"; function findObjectFromRef(obj: T | OpenAPIV3_1.ReferenceObject, openapi: OpenAPIV3_1.Document): T { const ref = (obj as OpenAPIV3_1.ReferenceObject).$ref; @@ -23,14 +24,16 @@ function findObjectFromRef(obj: T | OpenAPIV3_1.ReferenceObject, openapi: Ope } async function main(): Promise { - const { spec, file } = argv(process.argv.slice(2)); + const { + parsed: { spec, file }, + } = parseArgs({ args: process.argv.slice(2), schema: z4.object({ spec: z4.string(), file: z4.string() }) }); if (!spec || !file) { console.error("Please provide both --spec and --file arguments."); process.exit(1); } - const specFile = await fs.readFile(spec as string, "utf8"); + const specFile = await fs.readFile(spec, "utf8"); const operations: { path: string; @@ -112,7 +115,7 @@ async ${methodName}(options${requiredParams ? "" : "?"}: FetchOptions { diff --git a/scripts/generateArguments.ts b/scripts/generateArguments.ts index f3b470a98..2fccd8c12 100644 --- a/scripts/generateArguments.ts +++ b/scripts/generateArguments.ts @@ -5,7 +5,7 @@ * - server.json arrays * - README.md configuration table * - * It uses the Zod schema and OPTIONS defined in src/common/config.ts + * It uses the UserConfig Zod Schema. */ import { readFileSync, writeFileSync } from "fs"; @@ -13,7 +13,7 @@ import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { UserConfigSchema, configRegistry } from "../src/common/config/userConfig.js"; import { execSync } from "child_process"; -import { OPTIONS } from "../src/common/config/argsParserOptions.js"; +import type { z as z4 } from "zod/v4"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -54,6 +54,54 @@ interface ConfigMetadata { defaultValue?: unknown; defaultValueDescription?: string; isSecret?: boolean; + type: "string" | "number" | "boolean" | "array"; +} + +/** + * Derives the primitive type from a Zod schema by unwrapping wrappers like default, optional, preprocess, etc. + */ +function deriveZodType(schema: z4.ZodType): "string" | "number" | "boolean" | "array" { + const def = schema.def as unknown as Record; + const typeName = def.type as string; + + // Handle wrapped types (default, optional, nullable, etc.) + if (typeName === "default" || typeName === "optional" || typeName === "nullable") { + const innerType = def.innerType as z4.ZodType; + return deriveZodType(innerType); + } + + // Handle preprocess - look at the schema being processed into + if (typeName === "pipe") { + const out = def.out as z4.ZodType; + return deriveZodType(out); + } + + // Handle coerce wrapper + if (typeName === "coerce") { + const innerType = def.innerType as z4.ZodType; + return deriveZodType(innerType); + } + + // Handle primitive types + if (typeName === "string" || typeName === "enum") { + return "string"; + } + if (typeName === "number" || typeName === "int") { + return "number"; + } + if (typeName === "boolean") { + return "boolean"; + } + if (typeName === "array") { + return "array"; + } + if (typeName === "object") { + // Objects are treated as strings (JSON strings) + return "string"; + } + + // Default fallback + return "string"; } function extractZodDescriptions(): Record { @@ -67,6 +115,8 @@ function extractZodDescriptions(): Record { // Extract description from Zod schema let description = schema.description || `Configuration option: ${key}`; + const derivedType = deriveZodType(schema); + if ("innerType" in schema.def) { // "pipe" is also used for our comma-separated arrays if (schema.def.innerType.def.type === "pipe") { @@ -93,31 +143,22 @@ function extractZodDescriptions(): Record { defaultValue, defaultValueDescription, isSecret, + type: derivedType, }; } return result; } -function getArgumentInfo(options: typeof OPTIONS, zodMetadata: Record): ArgumentInfo[] { +function getArgumentInfo(zodMetadata: Record): ArgumentInfo[] { const argumentInfos: ArgumentInfo[] = []; - const processedKeys = new Set(); - - // Helper to add env var - const addEnvVar = (key: string, type: "string" | "number" | "boolean" | "array"): void => { - if (processedKeys.has(key)) return; - processedKeys.add(key); + for (const [key, metadata] of Object.entries(zodMetadata)) { const envVarName = `MDB_MCP_${camelCaseToSnakeCase(key)}`; - // Get description and default value from Zod metadata - const metadata = zodMetadata[key] || { - description: `Configuration option: ${key}`, - }; - // Determine format based on type - let format = type; - if (type === "array") { + let format: string = metadata.type; + if (metadata.type === "array") { format = "string"; // Arrays are passed as comma-separated strings } @@ -131,26 +172,6 @@ function getArgumentInfo(options: typeof OPTIONS, zodMetadata: Record & { - /** @description IP addresses expressed in Classless Inter-Domain Routing (CIDR) notation that MongoDB Cloud uses for the network peering containers in your project. MongoDB Cloud assigns all of the project's clusters deployed to this cloud provider an IP address from this range. MongoDB Cloud locks this value if an M10 or greater cluster or a network peering connection exists in this project. + /** + * @description IP addresses expressed in Classless Inter-Domain Routing (CIDR) notation that MongoDB Cloud uses for the network peering containers in your project. MongoDB Cloud assigns all of the project's clusters deployed to this cloud provider an IP address from this range. MongoDB Cloud locks this value if an M10 or greater cluster or a network peering connection exists in this project. * * These CIDR blocks must fall within the ranges reserved per RFC 1918. AWS and Azure further limit the block to between the `/24` and `/21` ranges. * @@ -433,7 +434,8 @@ export interface components { * * You can also create a new project and create a network peering connection to set the desired MongoDB Cloud network peering container CIDR block for that project. MongoDB Cloud limits the number of MongoDB nodes per network peering connection based on the CIDR block and the region selected for the project. * - * **Example:** A project in an Amazon Web Services (AWS) region supporting three availability zones and an MongoDB CIDR network peering container block of limit of `/24` equals 27 three-node replica sets. */ + * **Example:** A project in an Amazon Web Services (AWS) region supporting three availability zones and an MongoDB CIDR network peering container block of limit of `/24` equals 27 three-node replica sets. + */ atlasCidrBlock?: string; /** * @description Geographic area that Amazon Web Services (AWS) defines to which MongoDB Cloud deployed this network peering container. @@ -718,10 +720,12 @@ export interface components { * @description Options that determine how this cluster handles CPU scaling. */ AdvancedComputeAutoScaling: { - /** @description Flag that indicates whether instance size reactive auto-scaling is enabled. + /** + * @description Flag that indicates whether instance size reactive auto-scaling is enabled. * * - Set to `true` to enable instance size reactive auto-scaling. If enabled, you must specify a value for **replicationSpecs[n].regionConfigs[m].autoScaling.compute.maxInstanceSize**. - * - Set to `false` to disable instance size reactive auto-scaling. */ + * - Set to `false` to disable instance size reactive auto-scaling. + */ enabled?: boolean; maxInstanceSize?: components["schemas"]["BaseCloudProviderInstanceSize"]; minInstanceSize?: components["schemas"]["BaseCloudProviderInstanceSize"]; @@ -755,16 +759,20 @@ export interface components { ApiAtlasFTSAnalyzersViewManual: { /** @description Filters that examine text one character at a time and perform filtering operations. */ charFilters?: (components["schemas"]["charFilterhtmlStrip"] | components["schemas"]["charFiltericuNormalize"] | components["schemas"]["charFiltermapping"] | components["schemas"]["charFilterpersian"])[]; - /** @description Human-readable name that identifies the custom analyzer. Names must be unique within an index, and must not start with any of the following strings: + /** + * @description Human-readable name that identifies the custom analyzer. Names must be unique within an index, and must not start with any of the following strings: * - `lucene.` * - `builtin.` - * - `mongodb.` */ + * - `mongodb.` + */ name: string; - /** @description Filter that performs operations such as: + /** + * @description Filter that performs operations such as: * * - Stemming, which reduces related words, such as "talking", "talked", and "talks" to their root word "talk". * - * - Redaction, the removal of sensitive information from public documents. */ + * - Redaction, the removal of sensitive information from public documents. + */ tokenFilters?: (components["schemas"]["tokenFilterasciiFolding"] | components["schemas"]["tokenFilterdaitchMokotoffSoundex"] | components["schemas"]["tokenFilteredgeGram"] | components["schemas"]["TokenFilterEnglishPossessive"] | components["schemas"]["TokenFilterFlattenGraph"] | components["schemas"]["tokenFiltericuFolding"] | components["schemas"]["tokenFiltericuNormalizer"] | components["schemas"]["TokenFilterkStemming"] | components["schemas"]["tokenFilterlength"] | components["schemas"]["tokenFilterlowercase"] | components["schemas"]["tokenFilternGram"] | components["schemas"]["TokenFilterPorterStemming"] | components["schemas"]["tokenFilterregex"] | components["schemas"]["tokenFilterreverse"] | components["schemas"]["tokenFiltershingle"] | components["schemas"]["tokenFiltersnowballStemming"] | components["schemas"]["TokenFilterSpanishPluralStemming"] | components["schemas"]["TokenFilterStempel"] | components["schemas"]["tokenFilterstopword"] | components["schemas"]["tokenFiltertrim"] | components["schemas"]["TokenFilterWordDelimiterGraph"])[]; /** @description Tokenizer that you want to use to create tokens. Tokens determine how Atlas Search splits up text into discrete chunks for indexing. */ tokenizer: components["schemas"]["tokenizeredgeGram"] | components["schemas"]["tokenizerkeyword"] | components["schemas"]["tokenizernGram"] | components["schemas"]["tokenizerregexCaptureGroup"] | components["schemas"]["tokenizerregexSplit"] | components["schemas"]["tokenizerstandard"] | components["schemas"]["tokenizeruaxUrlEmail"] | components["schemas"]["tokenizerwhitespace"]; @@ -904,16 +912,20 @@ export interface components { AtlasSearchAnalyzer: { /** @description Filters that examine text one character at a time and perform filtering operations. */ charFilters?: components["schemas"]["BasicDBObject"][]; - /** @description Name that identifies the custom analyzer. Names must be unique within an index, and must not start with any of the following strings: + /** + * @description Name that identifies the custom analyzer. Names must be unique within an index, and must not start with any of the following strings: * - `lucene.` * - `builtin.` - * - `mongodb.` */ + * - `mongodb.` + */ name: string; - /** @description Filter that performs operations such as: + /** + * @description Filter that performs operations such as: * * - Stemming, which reduces related words, such as "talking", "talked", and "talks" to their root word "talk". * - * - Redaction, which is the removal of sensitive information from public documents. */ + * - Redaction, which is the removal of sensitive information from public documents. + */ tokenFilters?: components["schemas"]["BasicDBObject"][]; /** @description Tokenizer that you want to use to create tokens. Tokens determine how Atlas Search splits up text into discrete chunks for indexing. */ tokenizer: { @@ -925,7 +937,8 @@ export interface components { * @description Collection of settings that configures the network container for a virtual private connection on Amazon Web Services. */ AzureCloudProviderContainer: Omit & { - /** @description IP addresses expressed in Classless Inter-Domain Routing (CIDR) notation that MongoDB Cloud uses for the network peering containers in your project. MongoDB Cloud assigns all of the project's clusters deployed to this cloud provider an IP address from this range. MongoDB Cloud locks this value if an M10 or greater cluster or a network peering connection exists in this project. + /** + * @description IP addresses expressed in Classless Inter-Domain Routing (CIDR) notation that MongoDB Cloud uses for the network peering containers in your project. MongoDB Cloud assigns all of the project's clusters deployed to this cloud provider an IP address from this range. MongoDB Cloud locks this value if an M10 or greater cluster or a network peering connection exists in this project. * * These CIDR blocks must fall within the ranges reserved per RFC 1918. AWS and Azure further limit the block to between the `/24` and `/21` ranges. * @@ -936,7 +949,8 @@ export interface components { * * You can also create a new project and create a network peering connection to set the desired MongoDB Cloud network peering container CIDR block for that project. MongoDB Cloud limits the number of MongoDB nodes per network peering connection based on the CIDR block and the region selected for the project. * - * **Example:** A project in an Amazon Web Services (AWS) region supporting three availability zones and an MongoDB CIDR network peering container block of limit of `/24` equals 27 three-node replica sets. */ + * **Example:** A project in an Amazon Web Services (AWS) region supporting three availability zones and an MongoDB CIDR network peering container block of limit of `/24` equals 27 three-node replica sets. + */ atlasCidrBlock: string; /** * @description Unique string that identifies the Azure subscription in which the MongoDB Cloud VNet resides. @@ -1509,7 +1523,8 @@ export interface components { roles?: components["schemas"]["DatabaseUserRole"][]; /** @description List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Workspaces that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Workspaces in the project. */ scopes?: components["schemas"]["UserScope"][]; - /** @description Human-readable label that represents the user that authenticates to MongoDB. The format of this label depends on the method of authentication: + /** + * @description Human-readable label that represents the user that authenticates to MongoDB. The format of this label depends on the method of authentication: * * | Authentication Method | Parameter Needed | Parameter Value | username Format | * |---|---|---|---| @@ -1522,7 +1537,7 @@ export interface components { * | OIDC Workforce | oidcAuthType | IDP_GROUP | Atlas OIDC IdP ID (found in federation settings), followed by a '/', followed by the IdP group name | * | OIDC Workload | oidcAuthType | USER | Atlas OIDC IdP ID (found in federation settings), followed by a '/', followed by the IdP user name | * | SCRAM-SHA | awsIAMType, x509Type, ldapAuthType, oidcAuthType | NONE | Alphanumeric string | - * */ + */ username: string; /** * @description X.509 method that MongoDB Cloud uses to authenticate the database user. @@ -2107,13 +2122,15 @@ export interface components { * @description Feature compatibility version expiration date. Will only appear if FCV is pinned. This parameter expresses its value in the ISO 8601 timestamp format in UTC. */ readonly featureCompatibilityVersionExpirationDate?: string; - /** @description Set this field to configure the Sharding Management Mode when creating a new Global Cluster. + /** + * @description Set this field to configure the Sharding Management Mode when creating a new Global Cluster. * * When set to false, the management mode is set to Atlas-Managed Sharding. This mode fully manages the sharding of your Global Cluster and is built to provide a seamless deployment experience. * * When set to true, the management mode is set to Self-Managed Sharding. This mode leaves the management of shards in your hands and is built to provide an advanced and flexible deployment experience. * - * This setting cannot be changed once the cluster is deployed. */ + * This setting cannot be changed once the cluster is deployed. + */ globalClusterSelfManagedSharding?: boolean; /** * @description Unique 24-hexadecimal character string that identifies the project. @@ -2140,11 +2157,13 @@ export interface components { /** @description List of one or more Uniform Resource Locators (URLs) that point to API sub-resources, related API resources, or both. RFC 5988 outlines these relationships. */ readonly links?: components["schemas"]["Link"][]; mongoDBEmployeeAccessGrant?: components["schemas"]["EmployeeAccessGrantView"]; - /** @description MongoDB major version of the cluster. Set to the binary major version. + /** + * @description MongoDB major version of the cluster. Set to the binary major version. * * On creation: Choose from the available versions of MongoDB, or leave unspecified for the current recommended default in the MongoDB Cloud platform. The recommended version is a recent Long Term Support version. The default is not guaranteed to be the most recently released version throughout the entire release cycle. For versions available in a specific project, see the linked documentation or use the API endpoint for [project LTS versions endpoint](#tag/Projects/operation/getProjectLtsVersions). * - * On update: Increase version only by 1 major version at a time. If the cluster is pinned to a MongoDB feature compatibility version exactly one major version below the current MongoDB version, the MongoDB version can be downgraded to the previous major version. */ + * On update: Increase version only by 1 major version at a time. If the cluster is pinned to a MongoDB feature compatibility version exactly one major version below the current MongoDB version, the MongoDB version can be downgraded to the previous major version. + */ mongoDBMajorVersion?: string; /** @description Version of MongoDB that the cluster runs. */ readonly mongoDBVersion?: string; @@ -2154,13 +2173,15 @@ export interface components { paused?: boolean; /** @description Flag that indicates whether the cluster uses continuous cloud backups. */ pitEnabled?: boolean; - /** @description Enable or disable log redaction. + /** + * @description Enable or disable log redaction. * * This setting configures the ``mongod`` or ``mongos`` to redact any document field contents from a message accompanying a given log event before logging. This prevents the program from writing potentially sensitive data stored on the database to the diagnostic log. Metadata such as error or operation codes, line numbers, and source file names are still visible in the logs. * * Use ``redactClientLogData`` in conjunction with Encryption at Rest and TLS/SSL (Transport Encryption) to assist compliance with regulatory requirements. * - * *Note*: changing this setting on a cluster will trigger a rolling restart as soon as the cluster is updated. */ + * *Note*: changing this setting on a cluster will trigger a rolling restart as soon as the cluster is updated. + */ redactClientLogData?: boolean; /** * @description Set this field to configure the replica set scaling mode for your cluster. @@ -2490,11 +2511,13 @@ export interface components { /** @description One Private Internet Protocol version 4 (IPv4) address to which this Google Cloud consumer forwarding rule resolves. */ ipAddress?: string; }; - /** @description Rules by which MongoDB Cloud archives data. + /** + * @description Rules by which MongoDB Cloud archives data. * * Use the **criteria.type** field to choose how MongoDB Cloud selects data to archive. Choose data using the age of the data or a MongoDB query. * **"criteria.type": "DATE"** selects documents to archive based on a date. - * **"criteria.type": "CUSTOM"** selects documents to archive based on a custom JSON query. MongoDB Cloud doesn't support **"criteria.type": "CUSTOM"** when **"collectionType": "TIMESERIES"**. */ + * **"criteria.type": "CUSTOM"** selects documents to archive based on a custom JSON query. MongoDB Cloud doesn't support **"criteria.type": "CUSTOM"** when **"collectionType": "TIMESERIES"**. + */ CriteriaView: { /** * @description Means by which MongoDB Cloud selects data to archive. Data can be chosen using the age of the data or a MongoDB query. @@ -3522,7 +3545,8 @@ export interface components { * @description Collection of settings that configures the network container for a virtual private connection on Amazon Web Services. */ GCPCloudProviderContainer: Omit & { - /** @description IP addresses expressed in Classless Inter-Domain Routing (CIDR) notation that MongoDB Cloud uses for the network peering containers in your project. MongoDB Cloud assigns all of the project's clusters deployed to this cloud provider an IP address from this range. MongoDB Cloud locks this value if an M10 or greater cluster or a network peering connection exists in this project. + /** + * @description IP addresses expressed in Classless Inter-Domain Routing (CIDR) notation that MongoDB Cloud uses for the network peering containers in your project. MongoDB Cloud assigns all of the project's clusters deployed to this cloud provider an IP address from this range. MongoDB Cloud locks this value if an M10 or greater cluster or a network peering connection exists in this project. * * These CIDR blocks must fall within the ranges reserved per RFC 1918. GCP further limits the block to a lower bound of the `/18` range. * @@ -3533,7 +3557,8 @@ export interface components { * * You can also create a new project and create a network peering connection to set the desired MongoDB Cloud network peering container CIDR block for that project. MongoDB Cloud limits the number of MongoDB nodes per network peering connection based on the CIDR block and the region selected for the project. * - * **Example:** A project in an Google Cloud (GCP) region supporting three availability zones and an MongoDB CIDR network peering container block of limit of `/24` equals 27 three-node replica sets. */ + * **Example:** A project in an Google Cloud (GCP) region supporting three availability zones and an MongoDB CIDR network peering container block of limit of `/24` equals 27 three-node replica sets. + */ atlasCidrBlock: string; /** @description Unique string that identifies the GCP project in which MongoDB Cloud clusters in this network peering container exist. The response returns **null** if no clusters exist in this network peering container. */ readonly gcpProjectId?: string; @@ -5004,11 +5029,13 @@ export interface components { * @example 32b6e34b3d91647abb20e7b8 */ readonly id?: string; - /** @description Hardware specifications for nodes set for a given region. Each **regionConfigs** object must be unique by region and cloud provider within the **replicationSpec**. Each **regionConfigs** object describes the region's priority in elections and the number and type of MongoDB nodes that MongoDB Cloud deploys to the region. Each **regionConfigs** object must have either an **analyticsSpecs** object, **electableSpecs** object, or **readOnlySpecs** object. Tenant clusters only require **electableSpecs. Dedicated** clusters can specify any of these specifications, but must have at least one **electableSpecs** object within a **replicationSpec**. + /** + * @description Hardware specifications for nodes set for a given region. Each **regionConfigs** object must be unique by region and cloud provider within the **replicationSpec**. Each **regionConfigs** object describes the region's priority in elections and the number and type of MongoDB nodes that MongoDB Cloud deploys to the region. Each **regionConfigs** object must have either an **analyticsSpecs** object, **electableSpecs** object, or **readOnlySpecs** object. Tenant clusters only require **electableSpecs. Dedicated** clusters can specify any of these specifications, but must have at least one **electableSpecs** object within a **replicationSpec**. * * **Example:** * - * If you set `"replicationSpecs[n].regionConfigs[m].analyticsSpecs.instanceSize" : "M30"`, set `"replicationSpecs[n].regionConfigs[m].electableSpecs.instanceSize" : `"M30"` if you have electable nodes and `"replicationSpecs[n].regionConfigs[m].readOnlySpecs.instanceSize" : `"M30"` if you have read-only nodes. */ + * If you set `"replicationSpecs[n].regionConfigs[m].analyticsSpecs.instanceSize" : "M30"`, set `"replicationSpecs[n].regionConfigs[m].electableSpecs.instanceSize" : `"M30"` if you have electable nodes and `"replicationSpecs[n].regionConfigs[m].readOnlySpecs.instanceSize" : `"M30"` if you have read-only nodes. + */ regionConfigs?: components["schemas"]["CloudRegionConfig20240805"][]; /** * @description Unique 24-hexadecimal digit string that identifies the zone in a Global Cluster. This value can be used to configure Global Cluster backup policies. @@ -6345,9 +6372,11 @@ export interface components { * @description Filter that applies normalization mappings that you specify to characters. */ charFiltermapping: { - /** @description Comma-separated list of mappings. A mapping indicates that one character or group of characters should be substituted for another, using the following format: + /** + * @description Comma-separated list of mappings. A mapping indicates that one character or group of characters should be substituted for another, using the following format: * - * ` : `. */ + * ` : `. + */ mappings: { [key: string]: string; }; @@ -6733,12 +6762,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint) No provider AWS exists.", * "error": 400, * "errorCode": "VALIDATION_ERROR", * "reason": "Bad Request" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6748,12 +6779,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint) Cannot delete organization link while there is active migration in following project ids: 60c4fd418ebe251047c50554", * "error": 409, * "errorCode": "CANNOT_DELETE_ORG_ACTIVE_LIVE_MIGRATION_ATLAS_ORG_LINK", * "reason": "Conflict" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6763,12 +6796,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint)", * "error": 403, * "errorCode": "CANNOT_CHANGE_GROUP_NAME", * "reason": "Forbidden" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6778,12 +6813,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint)", * "error": 500, * "errorCode": "UNEXPECTED_ERROR", * "reason": "Internal Server Error" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6793,12 +6830,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint) Cannot find resource AWS", * "error": 404, * "errorCode": "RESOURCE_NOT_FOUND", * "reason": "Not Found" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6808,12 +6847,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint)", * "error": 402, * "errorCode": "NO_PAYMENT_INFORMATION_FOUND", * "reason": "Payment Required" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6823,12 +6864,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint)", * "error": 429, * "errorCode": "RATE_LIMITED", * "reason": "Too Many Requests" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6838,12 +6881,14 @@ export interface components { [name: string]: unknown; }; content: { - /** @example { + /** + * @example { * "detail": "(This is just an example, the exception may not be related to this endpoint)", * "error": 401, * "errorCode": "NOT_ORG_GROUP_CREATOR", * "reason": "Unauthorized" - * } */ + * } + */ "application/json": components["schemas"]["ApiError"]; }; }; @@ -6851,9 +6896,11 @@ export interface components { parameters: { /** @description Flag that indicates whether Application wraps the response in an `envelope` JSON object. Some API clients cannot access the HTTP response headers or status code. To remediate this, set envelope=true in the query. Endpoints that return a list of results use the results object as an envelope. Application adds the status parameter to the response body. */ envelope: boolean; - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: string; /** @description Flag that indicates whether the response returns the total number of items (**totalCount**) in the response. */ includeCount: boolean; @@ -7289,9 +7336,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7324,9 +7373,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7366,9 +7417,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7406,9 +7459,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7446,15 +7501,19 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; - /** @description Access list entry that you want to remove from the project's IP access list. This value can use one of the following: one AWS security group ID, one IP address, or one CIDR block of addresses. For CIDR blocks that use a subnet mask, replace the forward slash (`/`) with its URL-encoded value (`%2F`). When you remove an entry from the IP access list, existing connections from the removed address or addresses may remain open for a variable amount of time. The amount of time it takes MongoDB Cloud to close the connection depends upon several factors, including: + /** + * @description Access list entry that you want to remove from the project's IP access list. This value can use one of the following: one AWS security group ID, one IP address, or one CIDR block of addresses. For CIDR blocks that use a subnet mask, replace the forward slash (`/`) with its URL-encoded value (`%2F`). When you remove an entry from the IP access list, existing connections from the removed address or addresses may remain open for a variable amount of time. The amount of time it takes MongoDB Cloud to close the connection depends upon several factors, including: * * - how your application established the connection, * - how MongoDB Cloud or the driver using the address behaves, and - * - which protocol (like TCP or UDP) the connection uses. */ + * - which protocol (like TCP or UDP) the connection uses. + */ entryValue: string; }; cookie?: never; @@ -7494,9 +7553,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7540,9 +7601,11 @@ export interface operations { "Use-Effective-Instance-Fields"?: boolean; }; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7577,9 +7640,11 @@ export interface operations { "Use-Effective-Instance-Fields"?: boolean; }; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7622,9 +7687,11 @@ export interface operations { "Use-Effective-Instance-Fields"?: boolean; }; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies this cluster. */ clusterName: string; @@ -7661,9 +7728,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies the cluster. */ clusterName: string; @@ -7694,9 +7763,11 @@ export interface operations { query?: never; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies the cluster. */ clusterName: string; @@ -7727,9 +7798,11 @@ export interface operations { query?: never; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies the cluster. */ clusterName: string; @@ -7762,22 +7835,28 @@ export interface operations { processIds?: string[]; /** @description Namespaces from which to retrieve suggested indexes. A namespace consists of one database and one collection resource written as `.`: `.`. To include multiple namespaces, pass the parameter multiple times delimited with an ampersand (`&`) between each namespace. Omit this parameter to return results for all namespaces. */ namespaces?: string[]; - /** @description Date and time from which the query retrieves the suggested indexes. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). + /** + * @description Date and time from which the query retrieves the suggested indexes. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). * * - If you don't specify the **until** parameter, the endpoint returns data covering from the **since** value and the current time. - * - If you specify neither the **since** nor the **until** parameters, the endpoint returns data from the previous 24 hours. */ + * - If you specify neither the **since** nor the **until** parameters, the endpoint returns data from the previous 24 hours. + */ since?: number; - /** @description Date and time up until which the query retrieves the suggested indexes. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). + /** + * @description Date and time up until which the query retrieves the suggested indexes. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). * * - If you specify the **until** parameter, you must specify the **since** parameter. - * - If you specify neither the **since** nor the **until** parameters, the endpoint returns data from the previous 24 hours. */ + * - If you specify neither the **since** nor the **until** parameters, the endpoint returns data from the previous 24 hours. + */ until?: number; }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies the cluster. */ clusterName: string; @@ -7819,9 +7898,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7853,9 +7934,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7894,13 +7977,16 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description The database against which the database user authenticates. Database users must provide both a username and authentication database to log into MongoDB. If the user authenticates with AWS IAM, x.509, LDAP, or OIDC Workload this value should be `$external`. If the user authenticates with SCRAM-SHA or OIDC Workforce, this value should be `admin`. */ databaseName: string; - /** @description Human-readable label that represents the user that authenticates to MongoDB. The format of this label depends on the method of authentication: + /** + * @description Human-readable label that represents the user that authenticates to MongoDB. The format of this label depends on the method of authentication: * * | Authentication Method | Parameter Needed | Parameter Value | username Format | * |---|---|---|---| @@ -7913,7 +7999,7 @@ export interface operations { * | OIDC Workforce | oidcAuthType | IDP_GROUP | Atlas OIDC IdP ID (found in federation settings), followed by a '/', followed by the IdP group name | * | OIDC Workload | oidcAuthType | USER | Atlas OIDC IdP ID (found in federation settings), followed by a '/', followed by the IdP user name | * | SCRAM-SHA | awsIAMType, x509Type, ldapAuthType, oidcAuthType | NONE | Alphanumeric string | - * */ + */ username: string; }; cookie?: never; @@ -7951,9 +8037,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -7986,9 +8074,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; }; cookie?: never; @@ -8028,9 +8118,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies the flex cluster. */ name: string; @@ -8066,9 +8158,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Human-readable label that identifies the flex cluster. */ name: string; @@ -8101,19 +8195,23 @@ export interface operations { envelope?: components["parameters"]["envelope"]; /** @description Flag that indicates whether the response body should be in the prettyprint format. */ pretty?: components["parameters"]["pretty"]; - /** @description Length of time expressed during which the query finds slow queries among the managed namespaces in the cluster. This parameter expresses its value in milliseconds. + /** + * @description Length of time expressed during which the query finds slow queries among the managed namespaces in the cluster. This parameter expresses its value in milliseconds. * * - If you don't specify the **since** parameter, the endpoint returns data covering the duration before the current time. - * - If you specify neither the **duration** nor **since** parameters, the endpoint returns data from the previous 24 hours. */ + * - If you specify neither the **duration** nor **since** parameters, the endpoint returns data from the previous 24 hours. + */ duration?: number; /** @description Namespaces from which to retrieve slow queries. A namespace consists of one database and one collection resource written as `.`: `.`. To include multiple namespaces, pass the parameter multiple times delimited with an ampersand (`&`) between each namespace. Omit this parameter to return results for all namespaces. */ namespaces?: string[]; /** @description Maximum number of lines from the log to return. */ nLogs?: number; - /** @description Date and time from which the query retrieves the slow queries. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). + /** + * @description Date and time from which the query retrieves the slow queries. This parameter expresses its value in the number of milliseconds that have elapsed since the [UNIX epoch](https://en.wikipedia.org/wiki/Unix_time). * * - If you don't specify the **duration** parameter, the endpoint returns data covering from the **since** value and the current time. - * - If you specify neither the **duration** nor the **since** parameters, the endpoint returns data from the previous 24 hours. */ + * - If you specify neither the **duration** nor the **since** parameters, the endpoint returns data from the previous 24 hours. + */ since?: number; /** @description Whether or not to include metrics extracted from the slow query log as separate fields. */ includeMetrics?: boolean; @@ -8124,9 +8222,11 @@ export interface operations { }; header?: never; path: { - /** @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. + /** + * @description Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access. * - * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. */ + * **NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups. + */ groupId: components["parameters"]["groupId"]; /** @description Combination of host and port that serves the MongoDB process. The host must be the hostname, FQDN, IPv4 address, or IPv6 address of the host that runs the MongoDB process (`mongod` or `mongos`). The port must be the IANA port on which the MongoDB process listens for requests. */ processId: string; diff --git a/src/common/config/argsParserOptions.ts b/src/common/config/argsParserOptions.ts deleted file mode 100644 index dbc1671e3..000000000 --- a/src/common/config/argsParserOptions.ts +++ /dev/null @@ -1,111 +0,0 @@ -type ArgsParserOptions = { - string: string[]; - number: string[]; - boolean: string[]; - array: string[]; - alias: Record; - configuration: Record; -}; - -// TODO: Export this from arg-parser or find a better way to do this -// From: https://github.com/mongodb-js/mongosh/blob/main/packages/cli-repl/src/arg-parser.ts -export const OPTIONS = { - number: ["maxDocumentsPerQuery", "maxBytesPerQuery"], - string: [ - "apiBaseUrl", - "apiClientId", - "apiClientSecret", - "connectionString", - "httpHost", - "httpPort", - "allowRequestOverrides", - "idleTimeoutMs", - "logPath", - "notificationTimeoutMs", - "telemetry", - "transport", - "apiVersion", - "authenticationDatabase", - "authenticationMechanism", - "browser", - "db", - "gssapiHostName", - "gssapiServiceName", - "host", - "oidcFlows", - "oidcRedirectUri", - "password", - "port", - "sslCAFile", - "sslCRLFile", - "sslCertificateSelector", - "sslDisabledProtocols", - "sslPEMKeyFile", - "sslPEMKeyPassword", - "sspiHostnameCanonicalization", - "sspiRealmOverride", - "tlsCAFile", - "tlsCRLFile", - "tlsCertificateKeyFile", - "tlsCertificateKeyFilePassword", - "tlsCertificateSelector", - "tlsDisabledProtocols", - "username", - "atlasTemporaryDatabaseUserLifetimeMs", - "exportsPath", - "exportTimeoutMs", - "exportCleanupIntervalMs", - "voyageApiKey", - ], - boolean: [ - "apiDeprecationErrors", - "apiStrict", - "dryRun", - "embeddingsValidation", - "help", - "indexCheck", - "ipv6", - "nodb", - "oidcIdTokenAsAccessToken", - "oidcNoNonce", - "oidcTrustedEndpoint", - "readOnly", - "retryWrites", - "ssl", - "sslAllowInvalidCertificates", - "sslAllowInvalidHostnames", - "sslFIPSMode", - "tls", - "tlsAllowInvalidCertificates", - "tlsAllowInvalidHostnames", - "tlsFIPSMode", - "version", - ], - array: ["disabledTools", "loggers", "confirmationRequiredTools", "previewFeatures"], - alias: { - h: "help", - p: "password", - u: "username", - "build-info": "buildInfo", - browser: "browser", - oidcDumpTokens: "oidcDumpTokens", - oidcRedirectUrl: "oidcRedirectUri", - oidcIDTokenAsAccessToken: "oidcIdTokenAsAccessToken", - }, - configuration: { - "camel-case-expansion": false, - "unknown-options-as-args": true, - "parse-positional-numbers": false, - "parse-numbers": false, - "greedy-arrays": true, - "short-option-groups": false, - }, -} as Readonly; - -export const ALL_CONFIG_KEYS = new Set( - (OPTIONS.string as readonly string[]) - .concat(OPTIONS.number) - .concat(OPTIONS.array) - .concat(OPTIONS.boolean) - .concat(Object.keys(OPTIONS.alias)) -); diff --git a/src/common/config/configOverrides.ts b/src/common/config/configOverrides.ts index 493882e29..f87e75b51 100644 --- a/src/common/config/configOverrides.ts +++ b/src/common/config/configOverrides.ts @@ -43,7 +43,7 @@ export function applyConfigOverrides({ assertValidConfigKey(key); const meta = getConfigMeta(key); const behavior = meta?.overrideBehavior || "not-allowed"; - const baseValue = baseConfig[key as keyof UserConfig]; + const baseValue = baseConfig[key]; const newValue = applyOverride(key, baseValue, overrideValue, behavior); (result as Record)[key] = newValue; } @@ -59,7 +59,7 @@ export function applyConfigOverrides({ } const behavior = meta?.overrideBehavior || "not-allowed"; - const baseValue = baseConfig[key as keyof UserConfig]; + const baseValue = baseConfig[key]; const newValue = applyOverride(key, baseValue, overrideValue, behavior); (result as Record)[key] = newValue; } diff --git a/src/common/config/configUtils.ts b/src/common/config/configUtils.ts index e32617d12..0b2d68760 100644 --- a/src/common/config/configUtils.ts +++ b/src/common/config/configUtils.ts @@ -1,6 +1,6 @@ import path from "path"; import os from "os"; -import { ALL_CONFIG_KEYS } from "./argsParserOptions.js"; +import { ALL_CONFIG_KEYS } from "./userConfig.js"; import * as levenshteinModule from "ts-levenshtein"; const levenshtein = levenshteinModule.default; @@ -58,18 +58,6 @@ export function matchingConfigKey(key: string): string | undefined { return suggestion; } -export function isConnectionSpecifier(arg: string | undefined): boolean { - return ( - arg !== undefined && - (arg.startsWith("mongodb://") || - arg.startsWith("mongodb+srv://") || - // Strings starting with double hyphens `--` are generally a sign of - // CLI flag so we exclude them from the possibility of being a - // connection specifier. - !(arg.endsWith(".js") || arg.endsWith(".mongodb") || arg.startsWith("--"))) - ); -} - export function getLocalDataPath(): string { return process.platform === "win32" ? path.join(process.env.LOCALAPPDATA || process.env.APPDATA || os.homedir(), "mongodb") diff --git a/src/common/config/createUserConfig.ts b/src/common/config/createUserConfig.ts index cbf06fb2d..8ed825e71 100644 --- a/src/common/config/createUserConfig.ts +++ b/src/common/config/createUserConfig.ts @@ -1,148 +1,123 @@ -import argv from "yargs-parser"; -import { generateConnectionInfoFromCliArgs } from "@mongosh/arg-parser"; +import { type CliOptions, generateConnectionInfoFromCliArgs } from "@mongosh/arg-parser"; import { Keychain } from "../keychain.js"; import type { Secret } from "../keychain.js"; -import { isConnectionSpecifier, matchingConfigKey } from "./configUtils.js"; -import { OPTIONS } from "./argsParserOptions.js"; +import { matchingConfigKey } from "./configUtils.js"; import { UserConfigSchema, type UserConfig } from "./userConfig.js"; +import { + defaultParserOptions, + parseArgsWithCliOptions, + CliOptionsSchema, + UnknownArgumentError, +} from "@mongosh/arg-parser/arg-parser"; +import type { z as z4 } from "zod/v4"; + +export function createUserConfig({ args }: { args: string[] }): { + warnings: string[]; + parsed: UserConfig | undefined; + error: string | undefined; +} { + const { error: parseError, warnings, parsed } = parseUserConfigSources(args); -export type CreateUserConfigHelpers = { - onWarning: (message: string) => void; - onError: (message: string) => void; - closeProcess: (exitCode: number) => never; - cliArguments: string[]; -}; - -export const defaultUserConfigHelpers: CreateUserConfigHelpers = { - onWarning(message) { - console.warn(message); - }, - onError(message) { - console.error(message); - }, - closeProcess(exitCode) { - process.exit(exitCode); - }, - cliArguments: process.argv.slice(2), -}; - -export function createUserConfig({ - onWarning = defaultUserConfigHelpers.onWarning, - onError = defaultUserConfigHelpers.onError, - closeProcess = defaultUserConfigHelpers.closeProcess, - cliArguments = defaultUserConfigHelpers.cliArguments, -}: Partial = defaultUserConfigHelpers): UserConfig { - const { unknownCliArgumentErrors, deprecatedCliArgumentWarning, userAndArgsParserConfig, connectionSpecifier } = - parseUserConfigSources(cliArguments); - - if (unknownCliArgumentErrors.length) { - const errorMessage = ` -${unknownCliArgumentErrors.join("\n")} -- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server. -`; - onError(errorMessage); - return closeProcess(1); + if (parseError) { + return { error: parseError, warnings, parsed: undefined }; } - if (deprecatedCliArgumentWarning) { - const deprecatedMessages = ` -${deprecatedCliArgumentWarning} -- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server. -`; - onWarning(deprecatedMessages); + if (parsed.nodb) { + return { + error: "Error: The --nodb argument is not supported in the MCP Server. Please remove it from your configuration.", + warnings, + parsed: undefined, + }; } // If we have a connectionSpecifier, which can only appear as the positional // argument, then that has to be used on priority to construct the // connection string. In this case, if there is a connection string provided // by the env variable or config file, that will be overridden. + const { connectionSpecifier } = parsed; if (connectionSpecifier) { - const connectionInfo = generateConnectionInfoFromCliArgs({ ...userAndArgsParserConfig, connectionSpecifier }); - userAndArgsParserConfig.connectionString = connectionInfo.connectionString; + const connectionInfo = generateConnectionInfoFromCliArgs({ ...parsed, connectionSpecifier }); + parsed.connectionString = connectionInfo.connectionString; } - const configParseResult = UserConfigSchema.safeParse(userAndArgsParserConfig); - if (configParseResult.error) { - onError( - `Invalid configuration for the following fields:\n${configParseResult.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`).join("\n")}` - ); - return closeProcess(1); + const configParseResult = UserConfigSchema.safeParse(parsed); + const mongoshArguments = CliOptionsSchema.safeParse(parsed); + const error = configParseResult.error || mongoshArguments.error; + if (error) { + return { + error: `Invalid configuration for the following fields:\n${error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`).join("\n")}`, + warnings, + parsed: undefined, + }; } // TODO: Separate correctly parsed user config from all other valid // arguments relevant to mongosh's args-parser. - const userConfig: UserConfig = { ...userAndArgsParserConfig, ...configParseResult.data }; - warnIfVectorSearchNotEnabledCorrectly(userConfig, onWarning); + const userConfig: UserConfig = { ...parsed, ...configParseResult.data }; registerKnownSecretsInRootKeychain(userConfig); - return userConfig; + return { + parsed: userConfig, + warnings, + error: undefined, + }; } function parseUserConfigSources(cliArguments: string[]): { - unknownCliArgumentErrors: string[]; - deprecatedCliArgumentWarning: string | undefined; - userAndArgsParserConfig: Record; - connectionSpecifier: string | undefined; + error: string | undefined; + warnings: string[]; + parsed: Partial>; } { - const { - _: positionalAndUnknownArguments, - // We don't make use of end of flag arguments but also don't want them to - // end up alongside unknown arguments so we are extracting them and having a - // no-op statement so ESLint does not complain. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - "--": _endOfFlagArguments, - ...parsedUserAndArgsParserConfig - } = argv(cliArguments, { - ...OPTIONS, - // This helps parse the relevant environment variables. - envPrefix: "MDB_MCP_", - configuration: { - ...OPTIONS.configuration, - // Setting this to true will populate `_` variable which is - // originally used for positional arguments, now with the unknown - // arguments as well. The order of arguments are maintained. - "unknown-options-as-args": true, - // To avoid populating `_` with end-of-flag arguments we explicitly - // populate `--` variable and altogether ignore them later. - "populate--": true, - }, - }); - - // A connectionSpecifier can be one of: - // - database name - // - host name - // - ip address - // - replica set specifier - // - complete connection string - let connectionSpecifier: string | undefined = undefined; - const [maybeConnectionSpecifier, ...unknownArguments] = positionalAndUnknownArguments; - - if (typeof maybeConnectionSpecifier === "string" && isConnectionSpecifier(maybeConnectionSpecifier)) { - connectionSpecifier = maybeConnectionSpecifier; - } else if (maybeConnectionSpecifier !== undefined) { - // If the extracted connection specifier is not a connection specifier - // indeed, then we push it back to the unknown arguments list. This might - // happen for example when an unknown argument is provided without ever - // specifying a positional argument. - unknownArguments.unshift(maybeConnectionSpecifier); + let parsed: Partial>; + let deprecated: Record; + try { + const { parsed: parsedResult, deprecated: deprecatedResult } = parseArgsWithCliOptions({ + args: cliArguments, + schema: UserConfigSchema, + parserOptions: { + // This helps parse the relevant environment variables. + envPrefix: "MDB_MCP_", + configuration: { + ...defaultParserOptions.configuration, + // To avoid populating `_` with end-of-flag arguments we explicitly + // populate `--` variable and altogether ignore them later. + "populate--": true, + }, + }, + }); + parsed = parsedResult; + deprecated = deprecatedResult; + + // Delete fileNames - this is a field populated by mongosh but not used by us. + delete parsed.fileNames; + } catch (error) { + let errorMessage: string | undefined; + if (error instanceof UnknownArgumentError) { + const matchingKey = matchingConfigKey(error.argument.replace(/^(--)/, "")); + if (matchingKey) { + errorMessage = `Error: Invalid command line argument '${error.argument}'. Did you mean '--${matchingKey}'?`; + } else { + errorMessage = `Error: Invalid command line argument '${error.argument}'.`; + } + } + + return { + error: errorMessage, + warnings: [], + parsed: {}, + }; } + const deprecationWarnings = [ + ...getWarnings(parsed, cliArguments), + ...Object.entries(deprecated).map(([deprecated, replacement]) => { + return `Warning: The --${deprecated} argument is deprecated. Use --${replacement} instead.`; + }), + ]; + return { - unknownCliArgumentErrors: unknownArguments - .filter((argument): argument is string => typeof argument === "string" && argument.startsWith("--")) - .map((argument) => { - const argumentKey = argument.replace(/^(--)/, ""); - const matchingKey = matchingConfigKey(argumentKey); - if (matchingKey) { - return `Error: Invalid command line argument '${argument}'. Did you mean '--${matchingKey}'?`; - } - - return `Error: Invalid command line argument '${argument}'.`; - }), - deprecatedCliArgumentWarning: cliArguments.find((argument) => argument.startsWith("--connectionString")) - ? "Warning: The --connectionString argument is deprecated. Prefer using the MDB_MCP_CONNECTION_STRING environment variable or the first positional argument for the connection string." - : undefined, - userAndArgsParserConfig: parsedUserAndArgsParserConfig, - connectionSpecifier, + error: undefined, + warnings: deprecationWarnings, + parsed, }; } @@ -169,20 +144,29 @@ function registerKnownSecretsInRootKeychain(userConfig: Partial): vo maybeRegister(userConfig.username, "user"); } -function warnIfVectorSearchNotEnabledCorrectly(config: UserConfig, warn: (message: string) => void): void { - const searchEnabled = config.previewFeatures.includes("search"); +function getWarnings(config: Partial, cliArguments: string[]): string[] { + const warnings = []; + + if (cliArguments.find((argument: string) => argument.startsWith("--connectionString"))) { + warnings.push( + "Warning: The --connectionString argument is deprecated. Prefer using the MDB_MCP_CONNECTION_STRING environment variable or the first positional argument for the connection string." + ); + } + + const searchEnabled = config.previewFeatures?.includes("search"); const embeddingsProviderConfigured = !!config.voyageApiKey; if (searchEnabled && !embeddingsProviderConfigured) { - warn(`\ + warnings.push(`\ Warning: Vector search is enabled but no embeddings provider is configured. - Set an embeddings provider configuration option to enable auto-embeddings during document insertion and text-based queries with $vectorSearch.\ `); } if (!searchEnabled && embeddingsProviderConfigured) { - warn(`\ + warnings.push(`\ Warning: An embeddings provider is configured but the 'search' preview feature is not enabled. - Enable vector search by adding 'search' to the 'previewFeatures' configuration option, or remove the embeddings provider configuration if not needed.\ `); } + return warnings; } diff --git a/src/common/config/userConfig.ts b/src/common/config/userConfig.ts index bb52e1ead..3b672c5c0 100644 --- a/src/common/config/userConfig.ts +++ b/src/common/config/userConfig.ts @@ -1,5 +1,4 @@ import { z as z4 } from "zod/v4"; -import { type CliOptions } from "@mongosh/arg-parser"; import { type ConfigFieldMeta, commaSeparatedToArray, @@ -11,15 +10,11 @@ import { parseBoolean, } from "./configUtils.js"; import { previewFeatureValues, similarityValues } from "../schemas.js"; - -// TODO: UserConfig should only be UserConfigSchema and not an intersection with -// CliOptions. When we pull apart these two interfaces, we should fix this type -// as well. -export type UserConfig = z4.infer & CliOptions; +import { CliOptionsSchema as MongoshCliOptionsSchema } from "@mongosh/arg-parser/arg-parser"; export const configRegistry = z4.registry(); -export const UserConfigSchema = z4.object({ +const ServerConfigSchema = z4.object({ apiBaseUrl: z4 .string() .default("https://cloud.mongodb.com/") @@ -222,3 +217,12 @@ export const UserConfigSchema = z4.object({ ) .register(configRegistry, { overrideBehavior: "not-allowed" }), }); + +export const UserConfigSchema = z4.object({ + ...MongoshCliOptionsSchema.shape, + ...ServerConfigSchema.shape, +}); + +export type UserConfig = z4.infer; + +export const ALL_CONFIG_KEYS: (keyof UserConfig)[] = Object.keys(UserConfigSchema.shape) as (keyof UserConfig)[]; diff --git a/src/elicitation.ts b/src/elicitation.ts index 32707c6b4..b38f1d24f 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -1,4 +1,4 @@ -import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js"; +import type { ElicitRequestParams } from "@modelcontextprotocol/sdk/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; export class Elicitation { @@ -27,6 +27,7 @@ export class Elicitation { } const result = await this.server.elicitInput({ + mode: "form", message, requestedSchema: Elicitation.CONFIRMATION_SCHEMA, }); @@ -37,11 +38,11 @@ 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: ElicitRequestFormParams["requestedSchema"] = { - type: "object", + public static CONFIRMATION_SCHEMA = { + type: "object" as const, properties: { confirmation: { - type: "string", + type: "string" as const, title: "Would you like to confirm?", description: "Would you like to confirm?", enum: ["Yes", "No"], @@ -49,5 +50,5 @@ export class Elicitation { }, }, required: ["confirmation"], - }; + } satisfies ElicitRequestParams["requestedSchema"]; } diff --git a/src/index.ts b/src/index.ts index 2da924be5..c1f7fc531 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,7 +49,25 @@ import { DryRunModeRunner } from "./transports/dryModeRunner.js"; async function main(): Promise { systemCA().catch(() => undefined); // load system CA asynchronously as in mongosh - const config = createUserConfig(); + const { + error, + warnings, + parsed: config, + } = createUserConfig({ + args: process.argv.slice(2), + }); + + if (!config || (error && error.length)) { + console.error(`${error} +- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server.`); + process.exit(1); + } + + if (warnings && warnings.length) { + console.warn(`${warnings.join("\n")} +- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server.`); + } + if (config.help) { handleHelpRequest(); } diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index 7a9cf1cea..6af40e759 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -1,30 +1,142 @@ -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"); +import "./e2eUtils.js"; +import { useCliRunner } from "./e2eUtils.js"; describe("CLI entrypoint", () => { + const { runServer } = useCliRunner(); + it("should handle version request", async () => { - const { stdout, stderr } = await execFileAsync(process.execPath, [CLI_PATH, "--version"]); + const { stdout, stderr } = await runServer({ args: ["--version"], dryRun: false }); expect(stdout).toContain(packageJson.version); expect(stderr).toEqual(""); }); it("should handle help request", async () => { - const { stdout, stderr } = await execFileAsync(process.execPath, [CLI_PATH, "--help"]); + const { stdout, stderr } = await runServer({ args: ["--help"], dryRun: false }); 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"]); + const { stdout } = await runServer({ args: ["--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. }); + + it("should handle complex configuration", async () => { + const { stdout } = await runServer({ + args: [ + "--connectionString", + "mongodb://localhost:1000", + "--readOnly", + "--httpPort", + "8080", + "--httpHeaders", + '{"test": "3"}', + ], + stripWhitespace: true, + }); + expect(stdout).toContain('"connectionString":"mongodb://localhost:1000"'); + expect(stdout).toContain('"httpPort":8080'); + expect(stdout).toContain('"httpHeaders":{"test":"3"}'); + expect(stdout).toContain('"readOnly":true'); + }); + + describe("warnings and error messages", () => { + const referDocMessage = + "- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."; + + describe("Deprecated CLI arguments", () => { + const testCases: { readonly cliArg: string; readonly value?: string; readonly warning: string }[] = [ + { + cliArg: "--connectionString", + value: "mongodb://localhost:27017", + warning: + "Warning: The --connectionString argument is deprecated. Prefer using the MDB_MCP_CONNECTION_STRING environment variable or the first positional argument for the connection string.", + }, + ] as const; + + for (const { cliArg, value, warning } of testCases) { + describe(`deprecation behaviour of ${cliArg}`, () => { + it(`warns the usage of ${cliArg} as it is deprecated`, async () => { + const { stderr } = await runServer({ args: [cliArg, ...(value ? [value] : [])] }); + expect(stderr).toContain(warning); + }); + + it(`shows the reference message when ${cliArg} was passed`, async () => { + const { stderr } = await runServer({ args: [cliArg, ...(value ? [value] : [])] }); + expect(stderr).toContain(referDocMessage); + }); + }); + } + }); + + describe("invalid arguments", () => { + const invalidArgumentTestCases = [ + { + description: "should show an error when an argument is not known", + args: ["--wakanda", "forever"], + expectedError: "Error: Invalid command line argument '--wakanda'.", + }, + { + description: "should show an error when nodb is used", + args: ["--nodb"], + expectedError: + "Error: The --nodb argument is not supported in the MCP Server. Please remove it from your configuration.", + }, + { + description: "should show a suggestion when is a simple typo", + args: ["--readonli", ""], + expectedError: "Error: Invalid command line argument '--readonli'. Did you mean '--readOnly'?", + }, + { + description: "should show a suggestion when the only change is on the case", + args: ["--readonly", ""], + expectedError: "Error: Invalid command line argument '--readonly'. Did you mean '--readOnly'?", + }, + ]; + + for (const { description, args, expectedError } of invalidArgumentTestCases) { + it(description, async () => { + try { + await runServer({ args }); + expect.fail("Expected process to exit with error"); + } catch (error: unknown) { + const execError = error as { stderr?: string; code?: number }; + expect(execError.code).toBe(1); + expect(execError.stderr).toContain(expectedError); + expect(execError.stderr).toContain(referDocMessage); + } + }); + } + }); + + describe("vector search misconfiguration", () => { + it("should warn if vectorSearch is enabled but embeddings provider is not configured", async () => { + const { stderr } = await runServer({ args: ["--previewFeatures", "search"] }); + expect(stderr).toContain("Vector search is enabled but no embeddings provider is configured"); + }); + + it("should warn if vectorSearch is not enabled but embeddings provider is configured", async () => { + const { stderr } = await runServer({ args: ["--voyageApiKey", "1FOO"] }); + + expect(stderr).toContain( + "An embeddings provider is configured but the 'search' preview feature is not enabled" + ); + }); + + it("should not warn if vectorSearch is enabled correctly", async () => { + const { stderr } = await runServer({ + args: ["--voyageApiKey", "1FOO", "--previewFeatures", "search", "--dryRun"], + }); + expect(stderr).not.toContain("Vector search is enabled but no embeddings provider is configured"); + expect(stderr).not.toContain( + "An embeddings provider is configured but the 'search' preview feature is not enabled" + ); + }); + }); + }); }); diff --git a/tests/e2e/e2eUtils.ts b/tests/e2e/e2eUtils.ts new file mode 100644 index 000000000..d83343639 --- /dev/null +++ b/tests/e2e/e2eUtils.ts @@ -0,0 +1,59 @@ +import { execFile, type ChildProcess } from "child_process"; +import { afterEach } from "vitest"; +import path from "path"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +const CLI_PATH = path.join(import.meta.dirname, "..", "..", "dist", "esm", "index.js"); + +type RunServerFunction = ({ + args, + dryRun, + stripWhitespace, +}: { + args: string[]; + /** `true` by default so no server is started unnecessarily */ + dryRun?: boolean; + /** `true` by default so whitespace is stripped from the output */ + stripWhitespace?: boolean; +}) => Promise>; + +export function useCliRunner(): { runServer: RunServerFunction } { + /** + * Tracks spawned processes that need to be killed after tests + */ + const trackedProcesses = new Set(); + + async function runServer({ + args, + dryRun = true, + stripWhitespace = false, + }: { + args: string[]; + /** `true` by default so no server is started unnecessarily */ + dryRun?: boolean; + /** `true` by default so whitespace is stripped from the output */ + stripWhitespace?: boolean; + }): ReturnType { + const result = await execFileAsync(process.execPath, [CLI_PATH, ...args, ...(dryRun ? ["--dryRun"] : [])]); + if (stripWhitespace) { + result.stdout = result.stdout.replace(/\s/g, ""); + } + return result; + } + + // Clean up all processes after tests complete + afterEach(() => { + for (const proc of trackedProcesses) { + if (proc.pid && !proc.killed) { + proc.kill("SIGKILL"); + } + } + trackedProcesses.clear(); + }); + + return { + runServer, + }; +} diff --git a/tests/integration/elicitation.test.ts b/tests/integration/elicitation.test.ts index ca3e364d5..8024a1b03 100644 --- a/tests/integration/elicitation.test.ts +++ b/tests/integration/elicitation.test.ts @@ -38,6 +38,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringContaining("You are about to drop the `test-db` database"), requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + mode: "form", }); // Should attempt to execute (will fail due to no connection, but confirms flow worked) @@ -82,6 +83,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringContaining("You are about to drop the `test-collection` collection"), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); @@ -101,6 +103,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringContaining("You are about to delete documents"), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); @@ -120,6 +123,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringContaining("You are about to create a database user"), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); @@ -140,6 +144,7 @@ describe("Elicitation Integration Tests", () => { "You are about to add the following entries to the access list" ), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); }); @@ -222,6 +227,7 @@ describe("Elicitation Integration Tests", () => { /You are about to execute the `list-databases` tool which requires additional confirmation. Would you like to proceed\?/ ), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); @@ -265,6 +271,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringMatching(/project.*507f1f77bcf86cd799439011/), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); @@ -283,6 +290,7 @@ describe("Elicitation Integration Tests", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: expect.stringMatching(/mydb.*database/), requestedSchema: expect.objectContaining(Elicitation.CONFIRMATION_SCHEMA), + mode: "form", }); }); }, diff --git a/tests/integration/tools/mongodb/delete/dropIndex.test.ts b/tests/integration/tools/mongodb/delete/dropIndex.test.ts index 931b2d9f4..5ea9eb148 100644 --- a/tests/integration/tools/mongodb/delete/dropIndex.test.ts +++ b/tests/integration/tools/mongodb/delete/dropIndex.test.ts @@ -291,6 +291,7 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( message: expect.stringContaining( "You are about to drop the index named `year_1` from the `mflix.movies` namespace" ), + mode: "form", requestedSchema: Elicitation.CONFIRMATION_SCHEMA, }); expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(1); @@ -316,6 +317,7 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( message: expect.stringContaining( "You are about to drop the index named `year_1` from the `mflix.movies` namespace" ), + mode: "form", requestedSchema: Elicitation.CONFIRMATION_SCHEMA, }); expect(await getMoviesCollection().listIndexes().toArray()).toHaveLength(2); @@ -474,6 +476,7 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( message: expect.stringContaining( "You are about to drop the search index named `searchIdx` from the `mflix.movies` namespace" ), + mode: "form", requestedSchema: Elicitation.CONFIRMATION_SCHEMA, }); @@ -501,6 +504,7 @@ describe.each([{ vectorSearchEnabled: false }, { vectorSearchEnabled: true }])( message: expect.stringContaining( "You are about to drop the search index named `searchIdx` from the `mflix.movies` namespace" ), + mode: "form", requestedSchema: Elicitation.CONFIRMATION_SCHEMA, }); expect(dropSearchIndexSpy).not.toHaveBeenCalled(); diff --git a/tests/unit/common/config.test.ts b/tests/unit/common/config.test.ts index a8cd5f189..fc6f25324 100644 --- a/tests/unit/common/config.test.ts +++ b/tests/unit/common/config.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { type UserConfig, UserConfigSchema } from "../../../src/common/config/userConfig.js"; -import { type CreateUserConfigHelpers, createUserConfig } from "../../../src/common/config/createUserConfig.js"; +import { createUserConfig } from "../../../src/common/config/createUserConfig.js"; import { getLogPath, getExportsPath, @@ -9,26 +9,7 @@ import { } from "../../../src/common/config/configUtils.js"; import { Keychain } from "../../../src/common/keychain.js"; import type { Secret } from "../../../src/common/keychain.js"; - -function createEnvironment(): { - setVariable: (this: void, variable: string, value: unknown) => void; - clearVariables(this: void): void; -} { - const registeredEnvVariables: string[] = []; - - return { - setVariable(variable: string, value: unknown): void { - (process.env as Record)[variable] = value; - registeredEnvVariables.push(variable); - }, - - clearVariables(): void { - for (const variable of registeredEnvVariables) { - delete (process.env as Record)[variable]; - } - }, - }; -} +import { createEnvironment } from "../../utils/index.js"; // Expected hardcoded values (what we had before) const expectedDefaults = { @@ -41,6 +22,7 @@ const expectedDefaults = { telemetry: "enabled", readOnly: false, indexCheck: false, + deepInspect: true, confirmationRequiredTools: [ "atlas-create-access-list", "atlas-create-db-user", @@ -74,7 +56,11 @@ describe("config", () => { }); it("should generate defaults when no config sources are populated", () => { - expect(createUserConfig()).toStrictEqual(expectedDefaults); + expect(createUserConfig({ args: [] })).toStrictEqual({ + parsed: expectedDefaults, + warnings: [], + error: undefined, + }); }); describe("env var parsing", () => { @@ -87,8 +73,8 @@ describe("config", () => { describe("mongodb urls", () => { it("should not try to parse a multiple-host urls", () => { setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://user:password@host1,host2,host3/"); - const actual = createUserConfig(); - expect(actual.connectionString).toEqual("mongodb://user:password@host1,host2,host3/"); + const { parsed: actual } = createUserConfig({ args: [] }); + expect(actual?.connectionString).toEqual("mongodb://user:password@host1,host2,host3/"); }); }); @@ -131,8 +117,8 @@ describe("config", () => { for (const { envVar, property, value, expectedValue } of testCases) { it(`should map ${envVar} to ${property} with value "${String(value)}" to "${String(expectedValue ?? value)}"`, () => { setVariable(envVar, value); - const actual = createUserConfig(); - expect(actual[property]).toBe(expectedValue ?? value); + const { parsed: actual } = createUserConfig({ args: [] }); + expect(actual?.[property]).toBe(expectedValue ?? value); }); } }); @@ -146,8 +132,8 @@ describe("config", () => { for (const { envVar, property, value } of testCases) { it(`should map ${envVar} to ${property}`, () => { setVariable(envVar, value); - const actual = createUserConfig(); - expect(actual[property]).toEqual(value.split(",")); + const { parsed: actual } = createUserConfig({ args: [] }); + expect(actual?.[property]).toEqual(value.split(",")); }); } }); @@ -155,20 +141,20 @@ describe("config", () => { describe("cli parsing", () => { it("should not try to parse a multiple-host urls", () => { - const actual = createUserConfig({ - cliArguments: ["--connectionString", "mongodb://user:password@host1,host2,host3/"], + const { parsed: actual } = createUserConfig({ + args: ["--connectionString", "mongodb://user:password@host1,host2,host3/"], }); - expect(actual.connectionString).toEqual("mongodb://user:password@host1,host2,host3/"); + expect(actual?.connectionString).toEqual("mongodb://user:password@host1,host2,host3/"); }); it("positional connection specifier gets accounted for even without other connection sources", () => { // Note that neither connectionString argument nor env variable is // provided. - const actual = createUserConfig({ - cliArguments: ["mongodb://host1:27017"], + const { parsed: actual } = createUserConfig({ + args: ["mongodb://host1:27017"], }); - expect(actual.connectionString).toEqual("mongodb://host1:27017/?directConnection=true"); + expect(actual?.connectionString).toEqual("mongodb://host1:27017/?directConnection=true"); }); describe("string use cases", () => { @@ -241,10 +227,6 @@ describe("config", () => { cli: ["--db", "test"], expected: { db: "test" }, }, - { - cli: ["--gssapiHostName", "localhost"], - expected: { gssapiHostName: "localhost" }, - }, { cli: ["--gssapiServiceName", "SERVICE"], expected: { gssapiServiceName: "SERVICE" }, @@ -279,27 +261,27 @@ describe("config", () => { }, { cli: ["--sslCAFile", "/var/file"], - expected: { sslCAFile: "/var/file" }, + expected: { tlsCAFile: "/var/file" }, }, { cli: ["--sslCRLFile", "/var/file"], - expected: { sslCRLFile: "/var/file" }, + expected: { tlsCRLFile: "/var/file" }, }, { cli: ["--sslCertificateSelector", "pem=pom"], - expected: { sslCertificateSelector: "pem=pom" }, + expected: { tlsCertificateSelector: "pem=pom" }, }, { cli: ["--sslDisabledProtocols", "tls1"], - expected: { sslDisabledProtocols: "tls1" }, + expected: { tlsDisabledProtocols: "tls1" }, }, { cli: ["--sslPEMKeyFile", "/var/pem"], - expected: { sslPEMKeyFile: "/var/pem" }, + expected: { tlsCertificateKeyFile: "/var/pem" }, }, { cli: ["--sslPEMKeyPassword", "654321"], - expected: { sslPEMKeyPassword: "654321" }, + expected: { tlsCertificateKeyFilePassword: "654321" }, }, { cli: ["--sspiHostnameCanonicalization", "true"], @@ -345,10 +327,11 @@ describe("config", () => { for (const { cli, expected } of testCases) { it(`should parse '${cli.join(" ")}' to ${JSON.stringify(expected)}`, () => { - const actual = createUserConfig({ - cliArguments: cli, + const { parsed, error } = createUserConfig({ + args: cli, }); - expect(actual).toStrictEqual({ + expect(error).toBeUndefined(); + expect(parsed).toStrictEqual({ ...UserConfigSchema.parse({}), ...expected, }); @@ -356,6 +339,39 @@ describe("config", () => { } }); + describe("object fields", () => { + const testCases = [ + { + cli: ["--httpHeaders", '{"fieldA": "3", "fieldB": "4"}'], + expected: { httpHeaders: { fieldA: "3", fieldB: "4" } }, + }, + { + cli: ["--httpHeaders.fieldA", "3", "--httpHeaders.fieldB", "4"], + expected: { httpHeaders: { fieldA: "3", fieldB: "4" } }, + }, + ] as { cli: string[]; expected: Partial }[]; + for (const { cli, expected } of testCases) { + it(`should parse '${cli.join(" ")}' to ${JSON.stringify(expected)}`, () => { + const { parsed } = createUserConfig({ + args: cli, + }); + expect(parsed?.httpHeaders).toStrictEqual(expected.httpHeaders); + }); + } + + it("cannot mix --httpHeaders and --httpHeaders.fieldX", () => { + expect( + createUserConfig({ + args: ["--httpHeaders", '{"fieldA": "3", "fieldB": "4"}', "--httpHeaders.fieldA", "5"], + }) + ).toStrictEqual({ + error: "Invalid configuration for the following fields:\nhttpHeaders - Invalid input: expected object, received array", + warnings: [], + parsed: undefined, + }); + }); + }); + describe("boolean use cases", () => { const testCases = [ { @@ -378,10 +394,7 @@ describe("config", () => { cli: ["--ipv6"], expected: { ipv6: true }, }, - { - cli: ["--nodb"], - expected: { nodb: true }, - }, + { cli: ["--oidcIdTokenAsAccessToken"], expected: { oidcIdTokenAsAccessToken: true }, @@ -404,19 +417,19 @@ describe("config", () => { }, { cli: ["--ssl"], - expected: { ssl: true }, + expected: { tls: true }, }, { cli: ["--sslAllowInvalidCertificates"], - expected: { sslAllowInvalidCertificates: true }, + expected: { tlsAllowInvalidCertificates: true }, }, { cli: ["--sslAllowInvalidHostnames"], - expected: { sslAllowInvalidHostnames: true }, + expected: { tlsAllowInvalidHostnames: true }, }, { - cli: ["--sslFIPSMode"], - expected: { sslFIPSMode: true }, + cli: ["--tlsFIPSMode"], + expected: { tlsFIPSMode: true }, }, { cli: ["--tls"], @@ -480,11 +493,11 @@ describe("config", () => { for (const { cli, expected } of testCases) { it(`should parse '${cli.join(" ")}' to ${JSON.stringify(expected)}`, () => { - const actual = createUserConfig({ - cliArguments: cli, + const { parsed: actual } = createUserConfig({ + args: cli, }); for (const [key, value] of Object.entries(expected)) { - expect(actual[key as keyof UserConfig]).toBe(value); + expect(actual?.[key as keyof UserConfig]).toBe(value); } }); } @@ -504,11 +517,11 @@ describe("config", () => { for (const { cli, expected } of testCases) { it(`should parse '${cli.join(" ")}' to ${JSON.stringify(expected)}`, () => { - const actual = createUserConfig({ - cliArguments: cli, + const { parsed: actual } = createUserConfig({ + args: cli, }); for (const [key, value] of Object.entries(expected)) { - expect(actual[key as keyof UserConfig]).toEqual(value); + expect(actual?.[key as keyof UserConfig]).toEqual(value); } }); } @@ -524,51 +537,51 @@ describe("config", () => { it("positional argument takes precedence over all", () => { setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://crazyhost1"); - const actual = createUserConfig({ - cliArguments: ["mongodb://crazyhost2", "--connectionString", "mongodb://localhost"], + const { parsed: actual } = createUserConfig({ + args: ["mongodb://crazyhost2", "--connectionString", "mongodb://localhost"], }); - expect(actual.connectionString).toBe("mongodb://crazyhost2/?directConnection=true"); + expect(actual?.connectionString).toBe("mongodb://crazyhost2/?directConnection=true"); }); it("cli arguments take precedence over env vars", () => { setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://crazyhost"); - const actual = createUserConfig({ - cliArguments: ["--connectionString", "mongodb://localhost"], + const { parsed: actual } = createUserConfig({ + args: ["--connectionString", "mongodb://localhost"], }); - expect(actual.connectionString).toBe("mongodb://localhost"); + expect(actual?.connectionString).toBe("mongodb://localhost"); }); it("any cli argument takes precedence over defaults", () => { - const actual = createUserConfig({ - cliArguments: ["--connectionString", "mongodb://localhost"], + const { parsed: actual } = createUserConfig({ + args: ["--connectionString", "mongodb://localhost"], }); - expect(actual.connectionString).toBe("mongodb://localhost"); + expect(actual?.connectionString).toBe("mongodb://localhost"); }); it("any env var takes precedence over defaults", () => { setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://localhost"); - const actual = createUserConfig(); - expect(actual.connectionString).toBe("mongodb://localhost"); + const { parsed: actual } = createUserConfig({ args: [] }); + expect(actual?.connectionString).toBe("mongodb://localhost"); }); }); describe("consolidation", () => { it("positional argument for url has precedence over --connectionString", () => { - const actual = createUserConfig({ - cliArguments: ["mongodb://localhost", "--connectionString", "mongodb://toRemoveHost"], + const { parsed: actual } = createUserConfig({ + args: ["mongodb://localhost", "--connectionString", "mongodb://toRemoveHost"], }); // the shell specifies directConnection=true and serverSelectionTimeoutMS=2000 by default - expect(actual.connectionString).toBe( + expect(actual?.connectionString).toBe( "mongodb://localhost/?directConnection=true&serverSelectionTimeoutMS=2000" ); }); it("positional argument is always considered", () => { - const actual = createUserConfig({ - cliArguments: ["mongodb://localhost"], + const { parsed: actual } = createUserConfig({ + args: ["mongodb://localhost"], }); // the shell specifies directConnection=true and serverSelectionTimeoutMS=2000 by default - expect(actual.connectionString).toBe( + expect(actual?.connectionString).toBe( "mongodb://localhost/?directConnection=true&serverSelectionTimeoutMS=2000" ); }); @@ -577,362 +590,170 @@ describe("config", () => { describe("validation", () => { describe("transport", () => { it("should support http", () => { - const actual = createUserConfig({ - cliArguments: ["--transport", "http"], + const { parsed: actual } = createUserConfig({ + args: ["--transport", "http"], }); - expect(actual.transport).toEqual("http"); + expect(actual?.transport).toEqual("http"); }); it("should support stdio", () => { - const actual = createUserConfig({ - cliArguments: ["--transport", "stdio"], + const { parsed: actual } = createUserConfig({ + args: ["--transport", "stdio"], }); - expect(actual.transport).toEqual("stdio"); + expect(actual?.transport).toEqual("stdio"); }); it("should not support sse", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--transport", "sse"], + const { error } = createUserConfig({ + args: ["--transport", "sse"], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( 'Invalid configuration for the following fields:\ntransport - Invalid option: expected one of "stdio"|"http"' ) ); - expect(onExitFn).toBeCalledWith(1); }); it("should not support arbitrary values", () => { const value = Math.random() + "transport"; - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--transport", value], + const { error } = createUserConfig({ + args: ["--transport", value], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( 'Invalid configuration for the following fields:\ntransport - Invalid option: expected one of "stdio"|"http"' ) ); - expect(onExitFn).toBeCalledWith(1); }); }); describe("telemetry", () => { it("can be enabled", () => { - const actual = createUserConfig({ - cliArguments: ["--telemetry", "enabled"], + const { parsed: actual } = createUserConfig({ + args: ["--telemetry", "enabled"], }); - expect(actual.telemetry).toEqual("enabled"); + expect(actual?.telemetry).toEqual("enabled"); }); it("can be disabled", () => { - const actual = createUserConfig({ - cliArguments: ["--telemetry", "disabled"], + const { parsed: actual } = createUserConfig({ + args: ["--telemetry", "disabled"], }); - expect(actual.telemetry).toEqual("disabled"); + expect(actual?.telemetry).toEqual("disabled"); }); it("should not support the boolean true value", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--telemetry", "true"], + const { error } = createUserConfig({ + args: ["--telemetry", "true"], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( 'Invalid configuration for the following fields:\ntelemetry - Invalid option: expected one of "enabled"|"disabled"' ) ); - expect(onExitFn).toBeCalledWith(1); }); it("should not support the boolean false value", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--telemetry", "false"], + const { error } = createUserConfig({ + args: ["--telemetry", "false"], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( 'Invalid configuration for the following fields:\ntelemetry - Invalid option: expected one of "enabled"|"disabled"' ) ); - expect(onExitFn).toBeCalledWith(1); }); it("should not support arbitrary values", () => { const value = Math.random() + "telemetry"; - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--telemetry", value], + const { error } = createUserConfig({ + args: ["--telemetry", value], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( 'Invalid configuration for the following fields:\ntelemetry - Invalid option: expected one of "enabled"|"disabled"' ) ); - expect(onExitFn).toBeCalledWith(1); }); }); describe("httpPort", () => { it("must be above 0", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--httpPort", "-1"], + const { error } = createUserConfig({ + args: ["--httpPort", "-1"], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( "Invalid configuration for the following fields:\nhttpPort - Invalid httpPort: must be at least 0" ) ); - expect(onExitFn).toBeCalledWith(1); }); it("must be below 65535 (OS limit)", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--httpPort", "89527345"], + const { error } = createUserConfig({ + args: ["--httpPort", "89527345"], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( "Invalid configuration for the following fields:\nhttpPort - Invalid httpPort: must be at most 65535" ) ); - expect(onExitFn).toBeCalledWith(1); }); it("should not support non numeric values", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--httpPort", "portAventura"], + const { error } = createUserConfig({ + args: ["--httpPort", "portAventura"], }); - expect(onErrorFn).toBeCalledWith( + expect(error).toEqual( expect.stringContaining( "Invalid configuration for the following fields:\nhttpPort - Invalid input: expected number, received NaN" ) ); - expect(onExitFn).toBeCalledWith(1); }); it("should support numeric values", () => { - const actual = createUserConfig({ cliArguments: ["--httpPort", "8888"] }); - expect(actual.httpPort).toEqual(8888); + const { parsed: actual } = createUserConfig({ args: ["--httpPort", "8888"] }); + expect(actual?.httpPort).toEqual(8888); }); }); describe("loggers", () => { - it("must not be empty", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--loggers", ""], - }); - expect(onErrorFn).toBeCalledWith( - expect.stringContaining( - "Invalid configuration for the following fields:\nloggers - Cannot be an empty array" - ) - ); - expect(onExitFn).toBeCalledWith(1); - }); + const invalidLoggerTestCases = [ + { + description: "must not be empty", + args: ["--loggers", ""], + expectedError: + "Invalid configuration for the following fields:\nloggers - Cannot be an empty array", + }, + { + description: "must not allow duplicates", + args: ["--loggers", "disk,disk,disk"], + expectedError: + "Invalid configuration for the following fields:\nloggers - Duplicate loggers found in config", + }, + ]; - it("must not allow duplicates", () => { - const onErrorFn = vi.fn(); - const onExitFn = vi.fn(); - createUserConfig({ - onError: onErrorFn, - closeProcess: onExitFn, - cliArguments: ["--loggers", "disk,disk,disk"], + for (const { description, args, expectedError } of invalidLoggerTestCases) { + it(description, () => { + const { error } = createUserConfig({ args }); + expect(error).toEqual(expect.stringContaining(expectedError)); }); - expect(onErrorFn).toBeCalledWith( - expect.stringContaining( - "Invalid configuration for the following fields:\nloggers - Duplicate loggers found in config" - ) - ); - expect(onExitFn).toBeCalledWith(1); - }); + } it("allows mcp logger", () => { - const actual = createUserConfig({ cliArguments: ["--loggers", "mcp"] }); - expect(actual.loggers).toEqual(["mcp"]); + const { parsed: actual } = createUserConfig({ args: ["--loggers", "mcp"] }); + expect(actual?.loggers).toEqual(["mcp"]); }); it("allows disk logger", () => { - const actual = createUserConfig({ cliArguments: ["--loggers", "disk"] }); - expect(actual.loggers).toEqual(["disk"]); + const { parsed: actual } = createUserConfig({ args: ["--loggers", "disk"] }); + expect(actual?.loggers).toEqual(["disk"]); }); it("allows stderr logger", () => { - const actual = createUserConfig({ cliArguments: ["--loggers", "stderr"] }); - expect(actual.loggers).toEqual(["stderr"]); - }); - }); - }); -}); - -describe("Warning and Error messages", () => { - let warn: MockedFunction; - let error: MockedFunction; - let exit: MockedFunction; - const referDocMessage = - "- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server."; - - beforeEach(() => { - warn = vi.fn(); - error = vi.fn(); - exit = vi.fn(); - }); - - describe("Deprecated CLI arguments", () => { - const testCases = [ - { - cliArg: "--connectionString", - value: "mongodb://localhost:27017", - warning: - "Warning: The --connectionString argument is deprecated. Prefer using the MDB_MCP_CONNECTION_STRING environment variable or the first positional argument for the connection string.", - }, - ] as const; - - for (const { cliArg, value, warning } of testCases) { - describe(`deprecation behaviour of ${cliArg}`, () => { - beforeEach(() => { - createUserConfig({ onWarning: warn, closeProcess: exit, cliArguments: [cliArg, value] }); - }); - - it(`warns the usage of ${cliArg} as it is deprecated`, () => { - expect(warn).toHaveBeenCalledWith(expect.stringContaining(warning)); - }); - - it(`shows the reference message when ${cliArg} was passed`, () => { - expect(warn).toHaveBeenCalledWith(expect.stringContaining(referDocMessage)); - }); - - it(`should not exit the process`, () => { - expect(exit).not.toHaveBeenCalled(); - }); - }); - } - }); - - describe("invalid arguments", () => { - it("should show an error when an argument is not known and exit the process", () => { - createUserConfig({ - cliArguments: ["--wakanda", "forever"], - onWarning: warn, - onError: error, - closeProcess: exit, - }); - - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Error: Invalid command line argument '--wakanda'.") - ); - expect(error).toHaveBeenCalledWith( - expect.stringContaining( - "- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server." - ) - ); - expect(exit).toHaveBeenCalledWith(1); - }); - - it("should show a suggestion when is a simple typo", () => { - createUserConfig({ - cliArguments: ["--readonli", ""], - onWarning: warn, - onError: error, - closeProcess: exit, - }); - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Error: Invalid command line argument '--readonli'. Did you mean '--readOnly'?") - ); - expect(error).toHaveBeenCalledWith( - expect.stringContaining( - "- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server." - ) - ); - expect(exit).toHaveBeenCalledWith(1); - }); - - it("should show a suggestion when the only change is on the case", () => { - createUserConfig({ - cliArguments: ["--readonly", ""], - onWarning: warn, - onError: error, - closeProcess: exit, - }); - - expect(error).toHaveBeenCalledWith( - expect.stringContaining("Error: Invalid command line argument '--readonly'. Did you mean '--readOnly'?") - ); - expect(error).toHaveBeenCalledWith( - expect.stringContaining( - "- Refer to https://www.mongodb.com/docs/mcp-server/get-started/ for setting up the MCP Server." - ) - ); - expect(exit).toHaveBeenCalledWith(1); - }); - }); - - describe("vector search misconfiguration", () => { - it("should warn if vectorSearch is enabled but embeddings provider is not configured", () => { - createUserConfig({ - cliArguments: ["--previewFeatures", "search"], - onWarning: warn, - onError: error, - closeProcess: exit, - }); - expect(warn).toBeCalledWith(`\ -Warning: Vector search is enabled but no embeddings provider is configured. -- Set an embeddings provider configuration option to enable auto-embeddings during document insertion and text-based queries with $vectorSearch.\ -`); - }); - - it("should warn if vectorSearch is not enabled but embeddings provider is configured", () => { - createUserConfig({ - cliArguments: ["--voyageApiKey", "1FOO"], - onWarning: warn, - onError: error, - closeProcess: exit, - }); - - expect(warn).toBeCalledWith(`\ -Warning: An embeddings provider is configured but the 'search' preview feature is not enabled. -- Enable vector search by adding 'search' to the 'previewFeatures' configuration option, or remove the embeddings provider configuration if not needed.\ -`); - }); - - it("should not warn if vectorSearch is enabled correctly", () => { - createUserConfig({ - cliArguments: ["--voyageApiKey", "1FOO", "--previewFeatures", "search"], - onWarning: warn, - onError: error, - closeProcess: exit, + const { parsed: actual } = createUserConfig({ args: ["--loggers", "stderr"] }); + expect(actual?.loggers).toEqual(["stderr"]); }); - expect(warn).not.toBeCalled(); }); }); }); @@ -977,7 +798,7 @@ describe("keychain management", () => { for (const { cliArg, secretKind } of testCases) { it(`should register ${cliArg} as a secret of kind ${secretKind} in the root keychain`, () => { - createUserConfig({ cliArguments: [`--${cliArg}`, cliArg], onError: console.error }); + createUserConfig({ args: [`--${cliArg}`, cliArg] }); expect(keychain.allSecrets).toEqual([{ value: cliArg, kind: secretKind }]); }); } diff --git a/tests/unit/elicitation.test.ts b/tests/unit/elicitation.test.ts index eeaa81ae3..5a1dc43c0 100644 --- a/tests/unit/elicitation.test.ts +++ b/tests/unit/elicitation.test.ts @@ -82,6 +82,7 @@ describe("Elicitation", () => { expect(mockElicitInput.mock).toHaveBeenCalledWith({ message: testMessage, requestedSchema: Elicitation.CONFIRMATION_SCHEMA, + mode: "form", }); }); diff --git a/tests/utils/index.ts b/tests/utils/index.ts index 5933cd4bd..e11d7948d 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -2,6 +2,26 @@ import { type ConnectionManagerEvents } from "../../src/common/connectionManager import { LoggerBase, type LoggerType } from "../../src/common/logger.js"; import { type ConnectionManager } from "../../src/lib.js"; +export function createEnvironment(): { + setVariable: (this: void, variable: string, value: unknown) => void; + clearVariables(this: void): void; +} { + const registeredEnvVariables: string[] = []; + + return { + setVariable(variable: string, value: unknown): void { + (process.env as Record)[variable] = value; + registeredEnvVariables.push(variable); + }, + + clearVariables(): void { + for (const variable of registeredEnvVariables) { + delete (process.env as Record)[variable]; + } + }, + }; +} + export class NullLogger extends LoggerBase { protected type?: LoggerType; diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index ad8b38322..6ab6b1c79 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -3,6 +3,15 @@ "compilerOptions": { "module": "commonjs", "moduleResolution": "node", - "outDir": "./dist/cjs" + "outDir": "./dist/cjs", + "paths": { + "mongodb-connection-string-url": [ + "./node_modules/mongodb-connection-string-url/lib/index.d.ts" + ], + "ts-levenshtein": ["./node_modules/ts-levenshtein/dist/index.d.mts"], + "@mongosh/arg-parser/arg-parser": [ + "./node_modules/@mongosh/arg-parser/lib/arg-parser" + ] + } } }