diff --git a/scripts/accuracy/runAccuracyTests.sh b/scripts/accuracy/runAccuracyTests.sh index 180bd96f..0b5aa015 100644 --- a/scripts/accuracy/runAccuracyTests.sh +++ b/scripts/accuracy/runAccuracyTests.sh @@ -8,6 +8,11 @@ export MDB_ACCURACY_RUN_ID=$(npx uuid v4) # export MDB_AZURE_OPEN_AI_API_KEY="" # export MDB_AZURE_OPEN_AI_API_URL="" +# For providing Atlas API credentials (required for Atlas tools) +# Set dummy values for testing (allows Atlas tools to be registered for mocking) +export MDB_MCP_API_CLIENT_ID=${MDB_MCP_API_CLIENT_ID:-"test-atlas-client-id"} +export MDB_MCP_API_CLIENT_SECRET=${MDB_MCP_API_CLIENT_SECRET:-"test-atlas-client-secret"} + # For providing a mongodb based storage to store accuracy result # export MDB_ACCURACY_MDB_URL="" # export MDB_ACCURACY_MDB_DB="" diff --git a/scripts/filter.ts b/scripts/filter.ts index 71421490..d56fb5b0 100755 --- a/scripts/filter.ts +++ b/scripts/filter.ts @@ -41,6 +41,10 @@ function filterOpenapi(openapi: OpenAPIV3_1.Document): OpenAPIV3_1.Document { "deleteProjectIpAccessList", "listOrganizationProjects", "listAlerts", + "listDropIndexes", + "listClusterSuggestedIndexes", + "listSchemaAdvice", + "listSlowQueries", ]; const filteredPaths = {}; diff --git a/src/common/atlas/apiClient.ts b/src/common/atlas/apiClient.ts index 671f0dd6..3d511d3c 100644 --- a/src/common/atlas/apiClient.ts +++ b/src/common/atlas/apiClient.ts @@ -429,6 +429,42 @@ export class ApiClient { return data; } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listDropIndexes(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/dropIndexSuggestions", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listSchemaAdvice(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/schemaAdvice", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listClusterSuggestedIndexes(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/suggestedIndexes", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async listDatabaseUsers(options: FetchOptions) { const { data, error, response } = await this.client.GET( @@ -508,6 +544,18 @@ export class ApiClient { return data; } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async listSlowQueries(options: FetchOptions) { + const { data, error, response } = await this.client.GET( + "/api/atlas/v2/groups/{groupId}/processes/{processId}/performanceAdvisor/slowQueryLogs", + options + ); + if (error) { + throw ApiClientError.fromError(response, error); + } + return data; + } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type async listOrganizations(options?: FetchOptions) { const { data, error, response } = await this.client.GET("/api/atlas/v2/orgs", options); diff --git a/src/common/atlas/cluster.ts b/src/common/atlas/cluster.ts index d0a7f635..1ea30286 100644 --- a/src/common/atlas/cluster.ts +++ b/src/common/atlas/cluster.ts @@ -1,7 +1,17 @@ import type { ClusterDescription20240805, FlexClusterDescription20241113 } from "./openapi.js"; import type { ApiClient } from "./apiClient.js"; import { LogId } from "../logger.js"; +import { ConnectionString } from "mongodb-connection-string-url"; +type AtlasProcessId = `${string}:${number}`; + +function extractProcessIds(connectionString: string): Array { + if (!connectionString) { + return []; + } + const connectionStringUrl = new ConnectionString(connectionString); + return connectionStringUrl.hosts as Array; +} export interface Cluster { name?: string; instanceType: "FREE" | "DEDICATED" | "FLEX"; @@ -9,16 +19,19 @@ export interface Cluster { state?: "IDLE" | "CREATING" | "UPDATING" | "DELETING" | "REPAIRING"; mongoDBVersion?: string; connectionString?: string; + processIds?: Array; } export function formatFlexCluster(cluster: FlexClusterDescription20241113): Cluster { + const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard; return { name: cluster.name, instanceType: "FLEX", instanceSize: undefined, state: cluster.stateName, mongoDBVersion: cluster.mongoDBVersion, - connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard, + connectionString, + processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""), }; } @@ -52,6 +65,7 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster { const instanceSize = regionConfigs[0]?.instanceSize ?? "UNKNOWN"; const clusterInstanceType = instanceSize === "M0" ? "FREE" : "DEDICATED"; + const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard; return { name: cluster.name, @@ -59,7 +73,8 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster { instanceSize: clusterInstanceType === "DEDICATED" ? instanceSize : undefined, state: cluster.stateName, mongoDBVersion: cluster.mongoDBVersion, - connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard, + connectionString, + processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""), }; } @@ -96,3 +111,18 @@ export async function inspectCluster(apiClient: ApiClient, projectId: string, cl } } } + +export async function getProcessIdsFromCluster( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise> { + try { + const cluster = await inspectCluster(apiClient, projectId, clusterName); + return cluster.processIds || []; + } catch (error) { + throw new Error( + `Failed to get processIds from cluster: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/src/common/atlas/openapi.d.ts b/src/common/atlas/openapi.d.ts index 890c45c7..686f45d4 100644 --- a/src/common/atlas/openapi.d.ts +++ b/src/common/atlas/openapi.d.ts @@ -194,6 +194,66 @@ export interface paths { patch?: never; trace?: never; }; + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/dropIndexSuggestions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return All Suggested Indexes to Drop + * @description Returns the indexes that the Performance Advisor suggests to drop. The Performance Advisor suggests dropping unused, redundant, and hidden indexes to improve write performance and increase storage space. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. + */ + get: operations["listDropIndexes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/schemaAdvice": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return Schema Advice + * @description Returns the schema suggestions that the Performance Advisor detects. The Performance Advisor provides holistic schema recommendations for your cluster by sampling documents in your most active collections and collections with slow-running queries. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. + */ + get: operations["listSchemaAdvice"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/performanceAdvisor/suggestedIndexes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return All Suggested Indexes + * @description Returns the indexes that the Performance Advisor suggests. The Performance Advisor monitors queries that MongoDB considers slow and suggests new indexes to improve query performance. To use this resource, the requesting Service Account or API Key must have the Project Read Only role. + */ + get: operations["listClusterSuggestedIndexes"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/atlas/v2/groups/{groupId}/databaseUsers": { parameters: { query?: never; @@ -286,6 +346,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/atlas/v2/groups/{groupId}/processes/{processId}/performanceAdvisor/slowQueryLogs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return Slow Queries + * @description Returns log lines for slow queries that the Performance Advisor and Query Profiler identified. The Performance Advisor monitors queries that MongoDB considers slow and suggests new indexes to improve query performance. MongoDB Cloud bases the threshold for slow queries on the average time of operations on your cluster. This enables workload-relevant recommendations. To use this resource, the requesting Service Account or API Key must have any Project Data Access role or the Project Observability Viewer role. + */ + get: operations["listSlowQueries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/atlas/v2/orgs": { parameters: { query?: never; @@ -1735,6 +1815,11 @@ export interface components { * @example 32b6e34b3d91647abb20e7b8 */ readonly roleId?: string; + /** + * @description Provision status of the service account. + * @enum {string} + */ + readonly status?: "IN_PROGRESS" | "COMPLETE" | "FAILED" | "NOT_INITIATED"; } & { /** * @description discriminator enum property added by openapi-typescript @@ -3113,6 +3198,39 @@ export interface components { /** @description Flag that indicates whether this cluster enables disk auto-scaling. The maximum memory allowed for the selected cluster tier and the oplog size can limit storage auto-scaling. */ enabled?: boolean; }; + DropIndexSuggestionsIndex: { + /** + * Format: int64 + * @description Usage count (since last restart) of index. + */ + accessCount?: number; + /** @description List that contains documents that specify a key in the index and its sort order. */ + index?: Record[]; + /** @description Name of index. */ + name?: string; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + namespace?: string; + /** @description List that contains strings that specifies the shards where the index is found. */ + shards?: string[]; + /** + * Format: date-time + * @description Date of most recent usage of index. This parameter expresses its value in the ISO 8601 timestamp format in UTC. + */ + since?: string; + /** + * Format: int64 + * @description Size of index. + */ + sizeBytes?: number; + }; + DropIndexSuggestionsResponse: { + /** @description List that contains the documents with information about the hidden indexes that the Performance Advisor suggests to remove. */ + readonly hiddenIndexes?: components["schemas"]["DropIndexSuggestionsIndex"][]; + /** @description List that contains the documents with information about the redundant indexes that the Performance Advisor suggests to remove. */ + readonly redundantIndexes?: components["schemas"]["DropIndexSuggestionsIndex"][]; + /** @description List that contains the documents with information about the unused indexes that the Performance Advisor suggests to remove. */ + readonly unusedIndexes?: components["schemas"]["DropIndexSuggestionsIndex"][]; + }; /** @description MongoDB employee granted access level and expiration for a cluster. */ EmployeeAccessGrantView: { /** @@ -4379,6 +4497,156 @@ export interface components { */ readonly totalCount?: number; }; + PerformanceAdvisorIndex: { + /** + * Format: double + * @description The average size of an object in the collection of this index. + */ + readonly avgObjSize?: number; + /** + * @description Unique 24-hexadecimal digit string that identifies this index. + * @example 32b6e34b3d91647abb20e7b8 + */ + readonly id?: string; + /** @description List that contains unique 24-hexadecimal character string that identifies the query shapes in this response that the Performance Advisor suggests. */ + readonly impact?: string[]; + /** @description List that contains documents that specify a key in the index and its sort order. */ + readonly index?: { + [key: string]: 1 | -1; + }[]; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + readonly namespace?: string; + /** + * Format: double + * @description Estimated performance improvement that the suggested index provides. This value corresponds to **Impact** in the Performance Advisor user interface. + */ + readonly weight?: number; + }; + /** @description Details that this resource returned about the specified query. */ + PerformanceAdvisorOpStats: { + /** + * Format: int64 + * @description Length of time expressed during which the query finds suggested indexes among the managed namespaces in the cluster. This parameter expresses its value in milliseconds. This parameter relates to the **duration** query parameter. + */ + readonly ms?: number; + /** + * Format: int64 + * @description Number of results that the query returns. + */ + readonly nReturned?: number; + /** + * Format: int64 + * @description Number of documents that the query read. + */ + readonly nScanned?: number; + /** + * Format: int64 + * @description Date and time from which the query retrieves the suggested indexes. This parameter expresses its value in the number of seconds that have elapsed since the UNIX epoch. This parameter relates to the **since** query parameter. + */ + readonly ts?: number; + }; + PerformanceAdvisorOperationView: { + /** @description List that contains the search criteria that the query uses. To use the values in key-value pairs in these predicates requires **Project Data Access Read Only** permissions or greater. Otherwise, MongoDB Cloud redacts these values. */ + readonly predicates?: Record[]; + stats?: components["schemas"]["PerformanceAdvisorOpStats"]; + }; + PerformanceAdvisorResponse: { + /** @description List of query predicates, sorts, and projections that the Performance Advisor suggests. */ + readonly shapes?: components["schemas"]["PerformanceAdvisorShape"][]; + /** @description List that contains the documents with information about the indexes that the Performance Advisor suggests. */ + readonly suggestedIndexes?: components["schemas"]["PerformanceAdvisorIndex"][]; + }; + PerformanceAdvisorShape: { + /** + * Format: int64 + * @description Average duration in milliseconds for the queries examined that match this shape. + */ + readonly avgMs?: number; + /** + * Format: int64 + * @description Number of queries examined that match this shape. + */ + readonly count?: number; + /** + * @description Unique 24-hexadecimal digit string that identifies this shape. This string exists only for the duration of this API request. + * @example 32b6e34b3d91647abb20e7b8 + */ + readonly id?: string; + /** + * Format: int64 + * @description Average number of documents read for every document that the query returns. + */ + readonly inefficiencyScore?: number; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + readonly namespace?: string; + /** @description List that contains specific about individual queries. */ + readonly operations?: components["schemas"]["PerformanceAdvisorOperationView"][]; + }; + /** @description Details of one slow query that the Performance Advisor detected. */ + PerformanceAdvisorSlowQuery: { + /** @description Text of the MongoDB log related to this slow query. */ + readonly line?: string; + metrics?: components["schemas"]["PerformanceAdvisorSlowQueryMetrics"]; + /** @description Human-readable label that identifies the namespace on the specified host. The resource expresses this parameter value as `.`. */ + readonly namespace?: string; + /** @description Operation type (read/write/command) associated with this slow query log. */ + readonly opType?: string; + /** @description Replica state associated with this slow query log. */ + readonly replicaState?: string; + }; + PerformanceAdvisorSlowQueryList: { + /** @description List of operations that the Performance Advisor detected that took longer to execute than a specified threshold. */ + readonly slowQueries?: components["schemas"]["PerformanceAdvisorSlowQuery"][]; + }; + /** @description Metrics from a slow query log. */ + PerformanceAdvisorSlowQueryMetrics: { + /** + * Format: int64 + * @description The number of documents in the collection that MongoDB scanned in order to carry out the operation. + */ + readonly docsExamined?: number; + /** + * Format: double + * @description Ratio of documents examined to documents returned. + */ + readonly docsExaminedReturnedRatio?: number; + /** + * Format: int64 + * @description The number of documents returned by the operation. + */ + readonly docsReturned?: number; + /** @description This boolean will be true when the server can identfiy the query source as non-server. This field is only available for MDB 8.0+. */ + readonly fromUserConnection?: boolean; + /** @description Indicates if the query has index coverage. */ + readonly hasIndexCoverage?: boolean; + /** @description This boolean will be true when a query cannot use the ordering in the index to return the requested sorted results; i.e. MongoDB must sort the documents after it receives the documents from a cursor. */ + readonly hasSort?: boolean; + /** + * Format: int64 + * @description The number of index keys that MongoDB scanned in order to carry out the operation. + */ + readonly keysExamined?: number; + /** + * Format: double + * @description Ratio of keys examined to documents returned. + */ + readonly keysExaminedReturnedRatio?: number; + /** + * Format: int64 + * @description The number of times the operation yielded to allow other operations to complete. + */ + readonly numYields?: number; + /** + * Format: int64 + * @description Total execution time of a query in milliseconds. + */ + readonly operationExecutionTime?: number; + /** + * Format: int64 + * @description The length in bytes of the operation's result document. + */ + readonly responseLength?: number; + }; /** * Periodic Cloud Provider Snapshot Source * @description Scheduled Cloud Provider Snapshot as Source for a Data Lake Pipeline. @@ -4658,6 +4926,36 @@ export interface components { /** @description Variable that belongs to the set of the tag. For example, `production` in the `environment : production` tag. */ value: string; }; + SchemaAdvisorItemRecommendation: { + /** @description List that contains the namespaces and information on why those namespaces triggered the recommendation. */ + readonly affectedNamespaces?: components["schemas"]["SchemaAdvisorNamespaceTriggers"][]; + /** @description Description of the specified recommendation. */ + readonly description?: string; + /** + * @description Type of recommendation. + * @enum {string} + */ + readonly recommendation?: "REDUCE_LOOKUP_OPS" | "AVOID_UNBOUNDED_ARRAY" | "REDUCE_DOCUMENT_SIZE" | "REMOVE_UNNECESSARY_INDEXES" | "REDUCE_NUMBER_OF_NAMESPACES" | "OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES" | "OPTIMIZE_TEXT_QUERIES"; + }; + SchemaAdvisorNamespaceTriggers: { + /** @description Namespace of the affected collection. Will be null for REDUCE_NUMBER_OF_NAMESPACE recommendation. */ + readonly namespace?: string | null; + /** @description List of triggers that specify why the collection activated the recommendation. */ + readonly triggers?: components["schemas"]["SchemaAdvisorTriggerDetails"][]; + }; + SchemaAdvisorResponse: { + /** @description List that contains the documents with information about the schema advice that Performance Advisor suggests. */ + readonly recommendations?: components["schemas"]["SchemaAdvisorItemRecommendation"][]; + }; + SchemaAdvisorTriggerDetails: { + /** @description Description of the trigger type. */ + readonly description?: string; + /** + * @description Type of trigger. + * @enum {string} + */ + readonly triggerType?: "PERCENT_QUERIES_USE_LOOKUP" | "NUMBER_OF_QUERIES_USE_LOOKUP" | "DOCS_CONTAIN_UNBOUNDED_ARRAY" | "NUMBER_OF_NAMESPACES" | "DOC_SIZE_TOO_LARGE" | "NUM_INDEXES" | "QUERIES_CONTAIN_CASE_INSENSITIVE_REGEX"; + }; /** Search Host Status Detail */ SearchHostStatusDetail: { /** @description Hostname that corresponds to the status detail. */ @@ -6368,6 +6666,21 @@ export interface components { "application/json": components["schemas"]["ApiError"]; }; }; + /** @description Too Many Requests. */ + tooManyRequests: { + headers: { + [name: string]: unknown; + }; + content: { + /** @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"]; + }; + }; /** @description Unauthorized. */ unauthorized: { headers: { @@ -6524,6 +6837,8 @@ export type DiskBackupSnapshotExportBucketResponse = components['schemas']['Disk export type DiskBackupSnapshotGcpExportBucketRequest = components['schemas']['DiskBackupSnapshotGCPExportBucketRequest']; export type DiskBackupSnapshotGcpExportBucketResponse = components['schemas']['DiskBackupSnapshotGCPExportBucketResponse']; export type DiskGbAutoScaling = components['schemas']['DiskGBAutoScaling']; +export type DropIndexSuggestionsIndex = components['schemas']['DropIndexSuggestionsIndex']; +export type DropIndexSuggestionsResponse = components['schemas']['DropIndexSuggestionsResponse']; export type EmployeeAccessGrantView = components['schemas']['EmployeeAccessGrantView']; export type FieldViolation = components['schemas']['FieldViolation']; export type Fields = components['schemas']['Fields']; @@ -6578,6 +6893,14 @@ export type PaginatedFlexClusters20241113 = components['schemas']['PaginatedFlex export type PaginatedNetworkAccessView = components['schemas']['PaginatedNetworkAccessView']; export type PaginatedOrgGroupView = components['schemas']['PaginatedOrgGroupView']; export type PaginatedOrganizationView = components['schemas']['PaginatedOrganizationView']; +export type PerformanceAdvisorIndex = components['schemas']['PerformanceAdvisorIndex']; +export type PerformanceAdvisorOpStats = components['schemas']['PerformanceAdvisorOpStats']; +export type PerformanceAdvisorOperationView = components['schemas']['PerformanceAdvisorOperationView']; +export type PerformanceAdvisorResponse = components['schemas']['PerformanceAdvisorResponse']; +export type PerformanceAdvisorShape = components['schemas']['PerformanceAdvisorShape']; +export type PerformanceAdvisorSlowQuery = components['schemas']['PerformanceAdvisorSlowQuery']; +export type PerformanceAdvisorSlowQueryList = components['schemas']['PerformanceAdvisorSlowQueryList']; +export type PerformanceAdvisorSlowQueryMetrics = components['schemas']['PerformanceAdvisorSlowQueryMetrics']; export type PeriodicCpsSnapshotSource = components['schemas']['PeriodicCpsSnapshotSource']; export type RawMetricAlertView = components['schemas']['RawMetricAlertView']; export type RawMetricUnits = components['schemas']['RawMetricUnits']; @@ -6586,6 +6909,10 @@ export type ReplicaSetAlertViewForNdsGroup = components['schemas']['ReplicaSetAl export type ReplicaSetEventTypeViewForNdsGroupAlertable = components['schemas']['ReplicaSetEventTypeViewForNdsGroupAlertable']; export type ReplicationSpec20240805 = components['schemas']['ReplicationSpec20240805']; export type ResourceTag = components['schemas']['ResourceTag']; +export type SchemaAdvisorItemRecommendation = components['schemas']['SchemaAdvisorItemRecommendation']; +export type SchemaAdvisorNamespaceTriggers = components['schemas']['SchemaAdvisorNamespaceTriggers']; +export type SchemaAdvisorResponse = components['schemas']['SchemaAdvisorResponse']; +export type SchemaAdvisorTriggerDetails = components['schemas']['SchemaAdvisorTriggerDetails']; export type SearchHostStatusDetail = components['schemas']['SearchHostStatusDetail']; export type SearchIndex = components['schemas']['SearchIndex']; export type SearchIndexCreateRequest = components['schemas']['SearchIndexCreateRequest']; @@ -6675,6 +7002,7 @@ export type ResponseForbidden = components['responses']['forbidden']; export type ResponseInternalServerError = components['responses']['internalServerError']; export type ResponseNotFound = components['responses']['notFound']; export type ResponsePaymentRequired = components['responses']['paymentRequired']; +export type ResponseTooManyRequests = components['responses']['tooManyRequests']; export type ResponseUnauthorized = components['responses']['unauthorized']; export type ParameterEnvelope = components['parameters']['envelope']; export type ParameterGroupId = components['parameters']['groupId']; @@ -7194,6 +7522,120 @@ export interface operations { 500: components["responses"]["internalServerError"]; }; }; + listDropIndexes: { + parameters: { + 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. + * + * **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; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2024-08-05+json": components["schemas"]["DropIndexSuggestionsResponse"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; + listSchemaAdvice: { + parameters: { + 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. + * + * **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; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2024-08-05+json": components["schemas"]["SchemaAdvisorResponse"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; + listClusterSuggestedIndexes: { + parameters: { + query?: { + /** @description ProcessIds from which to retrieve suggested indexes. A processId is a 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. To include multiple processIds, pass the parameter multiple times delimited with an ampersand (`&`) between each processId. */ + 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). + * + * - 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. */ + 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). + * + * - 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. */ + 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. + * + * **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; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2024-08-05+json": components["schemas"]["PerformanceAdvisorResponse"]; + }; + }; + 400: components["responses"]["badRequest"]; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; listDatabaseUsers: { parameters: { query?: { @@ -7485,6 +7927,63 @@ export interface operations { 500: components["responses"]["internalServerError"]; }; }; + listSlowQueries: { + parameters: { + query?: { + /** @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?: 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. + * + * - 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. */ + 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). + * + * - 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. */ + since?: number; + /** @description Whether or not to include metrics extracted from the slow query log as separate fields. */ + includeMetrics?: boolean; + /** @description Whether or not to include the replica state of the host when the slow query log was generated as a separate field. */ + includeReplicaState?: boolean; + /** @description Whether or not to include the operation type (read/write/command) extracted from the slow query log as a separate field. */ + includeOpType?: boolean; + }; + 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. + * + * **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; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/vnd.atlas.2023-01-01+json": components["schemas"]["PerformanceAdvisorSlowQueryList"]; + }; + }; + 401: components["responses"]["unauthorized"]; + 403: components["responses"]["forbidden"]; + 404: components["responses"]["notFound"]; + 429: components["responses"]["tooManyRequests"]; + 500: components["responses"]["internalServerError"]; + }; + }; listOrganizations: { parameters: { query?: { diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts new file mode 100644 index 00000000..11549039 --- /dev/null +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -0,0 +1,153 @@ +import { LogId } from "../logger.js"; +import type { ApiClient } from "./apiClient.js"; +import { getProcessIdsFromCluster } from "./cluster.js"; +import type { components } from "./openapi.js"; + +export type SuggestedIndex = components["schemas"]["PerformanceAdvisorIndex"]; +export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsIndex"]; +export type SlowQueryLog = components["schemas"]["PerformanceAdvisorSlowQuery"]; + +export const DEFAULT_SLOW_QUERY_LOGS_LIMIT = 50; + +interface SuggestedIndexesResponse { + content: components["schemas"]["PerformanceAdvisorResponse"]; +} +interface DropIndexesResponse { + content: components["schemas"]["DropIndexSuggestionsResponse"]; +} +interface SchemaAdviceResponse { + content: components["schemas"]["SchemaAdvisorResponse"]; +} +export type SchemaRecommendation = components["schemas"]["SchemaAdvisorItemRecommendation"]; + +export async function getSuggestedIndexes( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ suggestedIndexes: Array }> { + try { + const response = await apiClient.listClusterSuggestedIndexes({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return { + suggestedIndexes: (response as SuggestedIndexesResponse).content.suggestedIndexes ?? [], + }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaSuggestedIndexesFailure, + context: "performanceAdvisorUtils", + message: `Failed to list suggested indexes: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list suggested indexes: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getDropIndexSuggestions( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ + hiddenIndexes: Array; + redundantIndexes: Array; + unusedIndexes: Array; +}> { + try { + const response = await apiClient.listDropIndexes({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return { + hiddenIndexes: (response as DropIndexesResponse).content.hiddenIndexes ?? [], + redundantIndexes: (response as DropIndexesResponse).content.redundantIndexes ?? [], + unusedIndexes: (response as DropIndexesResponse).content.unusedIndexes ?? [], + }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaDropIndexSuggestionsFailure, + context: "performanceAdvisorUtils", + message: `Failed to list drop index suggestions: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list drop index suggestions: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getSchemaAdvice( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ recommendations: Array }> { + try { + const response = await apiClient.listSchemaAdvice({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return { recommendations: (response as SchemaAdviceResponse).content.recommendations ?? [] }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaSchemaAdviceFailure, + context: "performanceAdvisorUtils", + message: `Failed to list schema advice: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list schema advice: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getSlowQueries( + apiClient: ApiClient, + projectId: string, + clusterName: string, + since?: Date, + namespaces?: Array +): Promise<{ slowQueryLogs: Array }> { + try { + const processIds = await getProcessIdsFromCluster(apiClient, projectId, clusterName); + + if (processIds.length === 0) { + return { slowQueryLogs: [] }; + } + + const slowQueryPromises = processIds.map((processId) => + apiClient.listSlowQueries({ + params: { + path: { + groupId: projectId, + processId, + }, + query: { + ...(since && { since: since.getTime() }), + ...(namespaces && { namespaces: namespaces }), + nLogs: DEFAULT_SLOW_QUERY_LOGS_LIMIT, + }, + }, + }) + ); + + const responses = await Promise.allSettled(slowQueryPromises); + + const allSlowQueryLogs = responses.reduce((acc, response) => { + return acc.concat(response.status === "fulfilled" ? (response.value.slowQueries ?? []) : []); + }, [] as Array); + + return { slowQueryLogs: allSlowQueryLogs }; + } catch (err) { + apiClient.logger.debug({ + id: LogId.atlasPaSlowQueryLogsFailure, + context: "performanceAdvisorUtils", + message: `Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`, + }); + throw new Error(`Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/src/common/logger.ts b/src/common/logger.ts index 07b126aa..100191f9 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -70,6 +70,11 @@ export const LogId = { exportLockError: mongoLogId(1_007_008), oidcFlow: mongoLogId(1_008_001), + + atlasPaSuggestedIndexesFailure: mongoLogId(1_009_001), + atlasPaDropIndexSuggestionsFailure: mongoLogId(1_009_002), + atlasPaSchemaAdviceFailure: mongoLogId(1_009_003), + atlasPaSlowQueryLogsFailure: mongoLogId(1_009_004), } as const; export interface LogPayload { diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts index b68eeafd..8d8914d6 100644 --- a/src/tools/atlas/atlasTool.ts +++ b/src/tools/atlas/atlasTool.ts @@ -38,6 +38,21 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs }; } + if (statusCode === 402) { + return { + content: [ + { + type: "text", + text: `Received a Payment Required API Error: ${error.message} + +Payment setup is required to perform this action in MongoDB Atlas. +Please ensure that your payment method for your organization has been set up and is active. +For more information on setting up payment, visit: https://www.mongodb.com/docs/atlas/billing/`, + }, + ], + }; + } + if (statusCode === 403) { return { content: [ @@ -45,7 +60,7 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs type: "text", text: `Received a Forbidden API Error: ${error.message} -You don't have sufficient permissions to perform this action in MongoDB Atlas +You don't have sufficient permissions to perform this action in MongoDB Atlas. Please ensure your API key has the necessary roles assigned. For more information on Atlas API access roles, visit: https://www.mongodb.com/docs/atlas/api/service-accounts-overview/`, }, diff --git a/src/tools/atlas/read/getPerformanceAdvisor.ts b/src/tools/atlas/read/getPerformanceAdvisor.ts new file mode 100644 index 00000000..120c765a --- /dev/null +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import { AtlasToolBase } from "../atlasTool.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; +import { formatUntrustedData } from "../../tool.js"; +import { + getSuggestedIndexes, + getDropIndexSuggestions, + getSchemaAdvice, + getSlowQueries, + DEFAULT_SLOW_QUERY_LOGS_LIMIT, +} from "../../../common/atlas/performanceAdvisorUtils.js"; +import { AtlasArgs } from "../../args.js"; + +const PerformanceAdvisorOperationType = z.enum([ + "suggestedIndexes", + "dropIndexSuggestions", + "slowQueryLogs", + "schemaSuggestions", +]); + +export class GetPerformanceAdvisorTool extends AtlasToolBase { + public name = "atlas-get-performance-advisor"; + protected description = `Get MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, schema suggestions, and a sample of the most recent (max ${DEFAULT_SLOW_QUERY_LOGS_LIMIT}) slow query logs`; + public operationType: OperationType = "read"; + protected argsShape = { + projectId: AtlasArgs.projectId().describe("Atlas project ID to get performance advisor recommendations"), + clusterName: AtlasArgs.clusterName().describe("Atlas cluster name to get performance advisor recommendations"), + operations: z + .array(PerformanceAdvisorOperationType) + .default(PerformanceAdvisorOperationType.options) + .describe("Operations to get performance advisor recommendations"), + since: z + .string() + .datetime() + .describe( + "Date to get slow query logs since. Must be a string in ISO 8601 format. Only relevant for the slowQueryLogs operation." + ) + .optional(), + namespaces: z + .array(z.string()) + .describe("Namespaces to get slow query logs. Only relevant for the slowQueryLogs operation.") + .optional(), + }; + + protected async execute({ + projectId, + clusterName, + operations, + since, + namespaces, + }: ToolArgs): Promise { + try { + const [suggestedIndexesResult, dropIndexSuggestionsResult, slowQueryLogsResult, schemaSuggestionsResult] = + await Promise.allSettled([ + operations.includes("suggestedIndexes") + ? getSuggestedIndexes(this.session.apiClient, projectId, clusterName) + : Promise.resolve(undefined), + operations.includes("dropIndexSuggestions") + ? getDropIndexSuggestions(this.session.apiClient, projectId, clusterName) + : Promise.resolve(undefined), + operations.includes("slowQueryLogs") + ? getSlowQueries( + this.session.apiClient, + projectId, + clusterName, + since ? new Date(since) : undefined, + namespaces + ) + : Promise.resolve(undefined), + operations.includes("schemaSuggestions") + ? getSchemaAdvice(this.session.apiClient, projectId, clusterName) + : Promise.resolve(undefined), + ]); + + const hasSuggestedIndexes = + suggestedIndexesResult.status === "fulfilled" && + suggestedIndexesResult.value?.suggestedIndexes && + suggestedIndexesResult.value.suggestedIndexes.length > 0; + const hasDropIndexSuggestions = + dropIndexSuggestionsResult.status === "fulfilled" && + dropIndexSuggestionsResult.value?.hiddenIndexes && + dropIndexSuggestionsResult.value?.redundantIndexes && + dropIndexSuggestionsResult.value?.unusedIndexes && + (dropIndexSuggestionsResult.value.hiddenIndexes.length > 0 || + dropIndexSuggestionsResult.value.redundantIndexes.length > 0 || + dropIndexSuggestionsResult.value.unusedIndexes.length > 0); + const hasSlowQueryLogs = + slowQueryLogsResult.status === "fulfilled" && + slowQueryLogsResult.value?.slowQueryLogs && + slowQueryLogsResult.value.slowQueryLogs.length > 0; + const hasSchemaSuggestions = + schemaSuggestionsResult.status === "fulfilled" && + schemaSuggestionsResult.value?.recommendations && + schemaSuggestionsResult.value.recommendations.length > 0; + + // Inserts the performance advisor data with the relevant section header if it exists + const performanceAdvisorData = [ + `## Suggested Indexes\n${ + hasSuggestedIndexes + ? `Note: The "Weight" field is measured in bytes, and represents the estimated number of bytes saved in disk reads per executed read query that would be saved by implementing an index suggestion. Please convert this to MB or GB for easier readability.\n${JSON.stringify(suggestedIndexesResult.value?.suggestedIndexes)}` + : "No suggested indexes found." + }`, + `## Drop Index Suggestions\n${hasDropIndexSuggestions ? JSON.stringify(dropIndexSuggestionsResult.value) : "No drop index suggestions found."}`, + `## Slow Query Logs\n${hasSlowQueryLogs ? JSON.stringify(slowQueryLogsResult.value?.slowQueryLogs) : "No slow query logs found."}`, + `## Schema Suggestions\n${hasSchemaSuggestions ? JSON.stringify(schemaSuggestionsResult.value?.recommendations) : "No schema suggestions found."}`, + ]; + + if (performanceAdvisorData.length === 0) { + return { + content: [{ type: "text", text: "No performance advisor recommendations found." }], + }; + } + + return { + content: formatUntrustedData("Performance advisor data", performanceAdvisorData.join("\n\n")), + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error retrieving performance advisor data: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + } +} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index c43b88ef..c2822ec5 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -10,6 +10,7 @@ import { CreateProjectTool } from "./create/createProject.js"; import { ListOrganizationsTool } from "./read/listOrgs.js"; import { ConnectClusterTool } from "./connect/connectCluster.js"; import { ListAlertsTool } from "./read/listAlerts.js"; +import { GetPerformanceAdvisorTool } from "./read/getPerformanceAdvisor.js"; export const AtlasTools = [ ListClustersTool, @@ -24,4 +25,5 @@ export const AtlasTools = [ ListOrganizationsTool, ConnectClusterTool, ListAlertsTool, + GetPerformanceAdvisorTool, ]; diff --git a/tests/accuracy/getPerformanceAdvisor.test.ts b/tests/accuracy/getPerformanceAdvisor.test.ts new file mode 100644 index 00000000..62b570c1 --- /dev/null +++ b/tests/accuracy/getPerformanceAdvisor.test.ts @@ -0,0 +1,165 @@ +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +// Shared mock tool implementations +const mockedTools = { + "atlas-list-projects": (): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 1 project\n\n# | Name | ID\n---|------|----\n1 | mflix | mflix", + }, + ], + }; + }, + "atlas-list-clusters": (): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 1 cluster\n\n# | Name | Type | State\n---|------|------|-----\n1 | mflix-cluster | REPLICASET | IDLE", + }, + ], + }; + }, + "atlas-get-performance-advisor": (): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 2 performance advisor recommendations\n\n## Suggested Indexes\n# | Namespace | Weight | Avg Obj Size | Index Keys\n---|-----------|--------|--------------|------------\n1 | mflix.movies | 0.8 | 1024 | title, year\n2 | mflix.shows | 0.6 | 512 | genre, rating", + }, + ], + }; + }, +}; + +describeAccuracyTests([ + // Test for Suggested Indexes operation + { + prompt: "Can you give me index suggestions for the database 'mflix' in the project 'mflix' and cluster 'mflix-cluster'?", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["suggestedIndexes"], + }, + }, + ], + mockedTools, + }, + // Test for Drop Index Suggestions operation + { + prompt: "Show me drop index suggestions for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["dropIndexSuggestions"], + }, + }, + ], + mockedTools, + }, + // Test for Slow Query Logs operation + { + prompt: "Show me the slow query logs for the 'mflix' project and 'mflix-cluster' cluster for the namespaces 'mflix.movies' and 'mflix.shows' since January 1st, 2025.", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["slowQueryLogs"], + namespaces: ["mflix.movies", "mflix.shows"], + since: "2025-01-01T00:00:00Z", + }, + }, + ], + mockedTools, + }, + // Test for Schema Suggestions operation + { + prompt: "Give me schema suggestions for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: ["schemaSuggestions"], + }, + }, + ], + mockedTools, + }, + // Test for all operations + { + prompt: "Show me all performance advisor recommendations for the 'mflix' project and 'mflix-cluster' cluster", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-get-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + }, + }, + ], + mockedTools, + }, +]); diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 692694aa..130e8fb0 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -79,10 +79,22 @@ export class AccuracyTestingClient { this.llmToolCalls = []; } - static async initializeClient(mdbConnectionString: string): Promise { + static async initializeClient( + mdbConnectionString: string, + atlasApiClientId?: string, + atlasApiClientSecret?: string + ): Promise { + const args = [ + MCP_SERVER_CLI_SCRIPT, + "--connectionString", + mdbConnectionString, + ...(atlasApiClientId ? ["--apiClientId", atlasApiClientId] : []), + ...(atlasApiClientSecret ? ["--apiClientSecret", atlasApiClientSecret] : []), + ]; + const clientTransport = new StdioClientTransport({ command: process.execPath, - args: [MCP_SERVER_CLI_SCRIPT, "--connectionString", mdbConnectionString], + args, }); const client = await createMCPClient({ diff --git a/tests/accuracy/sdk/describeAccuracyTests.ts b/tests/accuracy/sdk/describeAccuracyTests.ts index 6617a84f..576fedbd 100644 --- a/tests/accuracy/sdk/describeAccuracyTests.ts +++ b/tests/accuracy/sdk/describeAccuracyTests.ts @@ -66,6 +66,9 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) const mdbIntegration = setupMongoDBIntegrationTest({}, []); const { populateTestData, cleanupTestDatabases } = prepareTestData(mdbIntegration); + const atlasApiClientId = process.env.MDB_MCP_API_CLIENT_ID; + const atlasApiClientSecret = process.env.MDB_MCP_API_CLIENT_SECRET; + let commitSHA: string; let accuracyResultStorage: AccuracyResultStorage; let testMCPClient: AccuracyTestingClient; @@ -79,7 +82,11 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) commitSHA = retrievedCommitSHA; accuracyResultStorage = getAccuracyResultStorage(); - testMCPClient = await AccuracyTestingClient.initializeClient(mdbIntegration.connectionString()); + testMCPClient = await AccuracyTestingClient.initializeClient( + mdbIntegration.connectionString(), + atlasApiClientId, + atlasApiClientSecret + ); agent = getVercelToolCallingAgent(); }); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index d62354a8..0ad913b9 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -48,6 +48,8 @@ export const defaultTestConfig: UserConfig = { loggers: ["stderr"], }; +export const DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS = 1_200_000; + export function setupIntegrationTest( getUserConfig: () => UserConfig, getDriverOptions: () => DriverOptions, diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index 13f160c7..a9f24cb4 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -1,10 +1,11 @@ import { ObjectId } from "mongodb"; -import type { Group } from "../../../../src/common/atlas/openapi.js"; +import type { ClusterDescription20240805, Group } from "../../../../src/common/atlas/openapi.js"; import type { ApiClient } from "../../../../src/common/atlas/apiClient.js"; import type { IntegrationTest } from "../../helpers.js"; import { setupIntegrationTest, defaultTestConfig, defaultDriverOptions } from "../../helpers.js"; import type { SuiteCollector } from "vitest"; import { afterAll, beforeAll, describe } from "vitest"; +import type { Session } from "../../../../src/common/session.js"; export type IntegrationTestFunction = (integration: IntegrationTest) => void; @@ -150,3 +151,91 @@ async function createProject(apiClient: ApiClient): Promise>; } + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function assertClusterIsAvailable( + session: Session, + projectId: string, + clusterName: string +): Promise { + try { + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return true; + } catch { + return false; + } +} + +export async function deleteCluster( + session: Session, + projectId: string, + clusterName: string, + shouldWaitTillClusterIsDeleted: boolean = false +): Promise { + await session.apiClient.deleteCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + + if (!shouldWaitTillClusterIsDeleted) { + return; + } + + while (true) { + try { + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + await sleep(1000); + } catch { + break; + } + } +} + +export async function waitCluster( + session: Session, + projectId: string, + clusterName: string, + check: (cluster: ClusterDescription20240805) => boolean | Promise, + pollingInterval: number = 1000, + maxPollingIterations: number = 300 +): Promise { + for (let i = 0; i < maxPollingIterations; i++) { + const cluster = await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + if (await check(cluster)) { + return; + } + await sleep(pollingInterval); + } + + throw new Error( + `Cluster wait timeout: ${clusterName} did not meet condition within ${maxPollingIterations} iterations` + ); +} diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 0e666be7..66d2d024 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,71 +1,12 @@ import type { Session } from "../../../../src/common/session.js"; import { expectDefined, getDataFromUntrustedContent, getResponseElements } from "../../helpers.js"; -import { describeWithAtlas, withProject, randomId, parseTable } from "./atlasHelpers.js"; -import type { ClusterDescription20240805 } from "../../../../src/common/atlas/openapi.js"; +import { describeWithAtlas, withProject, randomId, parseTable, deleteCluster, waitCluster } from "./atlasHelpers.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function deleteCluster( - session: Session, - projectId: string, - clusterName: string, - wait: boolean = false -): Promise { - await session.apiClient.deleteCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - - if (!wait) { - return; - } - - while (true) { - try { - await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - await sleep(1000); - } catch { - break; - } - } -} - -async function waitCluster( - session: Session, - projectId: string, - clusterName: string, - check: (cluster: ClusterDescription20240805) => boolean | Promise -): Promise { - while (true) { - const cluster = await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - if (await check(cluster)) { - return; - } - await sleep(1000); - } -} - describeWithAtlas("clusters", (integration) => { withProject(integration, ({ getProjectId, getIpAddress }) => { const clusterName = "ClusterTest-" + randomId; diff --git a/tests/integration/tools/atlas/performanceAdvisor.test.ts b/tests/integration/tools/atlas/performanceAdvisor.test.ts new file mode 100644 index 00000000..bb375348 --- /dev/null +++ b/tests/integration/tools/atlas/performanceAdvisor.test.ts @@ -0,0 +1,231 @@ +// This test file includes long running tests (>10 minutes) because we provision a real M10 cluster, which can take up to 10 minutes to provision. +// The timeouts for the beforeAll/afterAll hooks have been modified to account for longer running tests. + +import type { Session } from "../../../../src/common/session.js"; +import { DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS, expectDefined, getResponseElements } from "../../helpers.js"; +import { describeWithAtlas, withProject, randomId, waitCluster, deleteCluster } from "./atlasHelpers.js"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +describeWithAtlas("performanceAdvisor", (integration) => { + withProject(integration, ({ getProjectId }) => { + const clusterName = "ClusterTest-" + randomId; + + afterAll(async () => { + const projectId = getProjectId(); + if (projectId) { + const session: Session = integration.mcpServer().session; + await deleteCluster(session, projectId, clusterName); + } + }, DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS); + + describe("atlas-get-performance-advisor", () => { + beforeAll(async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + await session.apiClient.createCluster({ + params: { + path: { + groupId: projectId, + }, + }, + body: { + name: clusterName, + clusterType: "REPLICASET", + backupEnabled: true, + configServerManagementMode: "ATLAS_MANAGED", + diskWarmingMode: "FULLY_WARMED", + replicaSetScalingStrategy: "WORKLOAD_TYPE", + rootCertType: "ISRGROOTX1", + terminationProtectionEnabled: false, + versionReleaseSystem: "LTS", + replicationSpecs: [ + { + zoneName: "Zone 1", + regionConfigs: [ + { + providerName: "AWS", + regionName: "US_EAST_1", + electableSpecs: { instanceSize: "M10", nodeCount: 3 }, + priority: 7, + }, + ], + }, + ], + }, + }); + + await waitCluster( + session, + projectId, + clusterName, + (cluster) => { + return cluster.stateName === "IDLE"; + }, + 10000, + 120 + ); + }, DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const getPerformanceAdvisor = tools.find((tool) => tool.name === "atlas-get-performance-advisor"); + expectDefined(getPerformanceAdvisor); + expect(getPerformanceAdvisor.inputSchema.type).toBe("object"); + expectDefined(getPerformanceAdvisor.inputSchema.properties); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("projectId"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("clusterName"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("operations"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("since"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("namespaces"); + }); + + it("returns performance advisor data from a paid tier cluster", async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + + const response = await integration.mcpClient().callTool({ + name: "atlas-get-performance-advisor", + arguments: { + projectId, + clusterName, + operations: ["suggestedIndexes", "dropIndexSuggestions", "schemaSuggestions"], + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toContain("Performance advisor data"); + expect(elements[1]?.text).toContain(" { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + // Mock the API client methods since we can't guarantee performance advisor data + const mockSuggestedIndexes = vi.fn().mockResolvedValue({ + content: { + suggestedIndexes: [ + { + namespace: "testdb.testcollection", + index: { field: 1 }, + impact: ["queryShapeString"], + }, + ], + }, + }); + + const mockDropIndexSuggestions = vi.fn().mockResolvedValue({ + content: { + hiddenIndexes: [], + redundantIndexes: [ + { + accessCount: 100, + namespace: "testdb.testcollection", + index: { field: 1 }, + reason: "Redundant with compound index", + }, + ], + unusedIndexes: [], + }, + }); + + const mockSchemaAdvice = vi.fn().mockResolvedValue({ + content: { + recommendations: [ + { + description: "Consider adding an index on 'status' field", + recommendation: "REDUCE_LOOKUP_OPS", + affectedNamespaces: [ + { + namespace: "testdb.testcollection", + triggers: [ + { + triggerType: "PERCENT_QUERIES_USE_LOOKUP", + details: + "Queries filtering by status field are causing collection scans", + }, + ], + }, + ], + }, + ], + }, + }); + + const mockSlowQueries = vi.fn().mockResolvedValue({ + slowQueries: [ + { + namespace: "testdb.testcollection", + query: { find: "testcollection", filter: { status: "active" } }, + duration: 1500, + timestamp: "2024-01-15T10:30:00Z", + }, + ], + }); + + const mockGetCluster = vi.fn().mockResolvedValue({ + connectionStrings: { + standard: "mongodb://test-cluster.mongodb.net:27017", + }, + }); + + session.apiClient.listClusterSuggestedIndexes = mockSuggestedIndexes; + session.apiClient.listDropIndexes = mockDropIndexSuggestions; + session.apiClient.listSchemaAdvice = mockSchemaAdvice; + session.apiClient.listSlowQueries = mockSlowQueries; + session.apiClient.getCluster = mockGetCluster; + + const response = await integration.mcpClient().callTool({ + name: "atlas-get-performance-advisor", + arguments: { + projectId, + clusterName: "mockClusterName", + operations: ["suggestedIndexes", "dropIndexSuggestions", "slowQueryLogs", "schemaSuggestions"], + }, + }); + + if (response.isError) { + console.error("Performance advisor call failed:", response.content); + throw new Error("Performance advisor call failed - see console for details"); + } + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toContain("Performance advisor data"); + expect(elements[1]?.text).toContain("