From bd69450a89bbe6c4038e9e5617b5cfc03f428835 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Thu, 4 Sep 2025 11:18:51 -0400 Subject: [PATCH 01/17] Add base atlas performance advisor MCP server tool --- scripts/filter.ts | 4 + src/common/atlas/apiClient.ts | 48 ++ src/common/atlas/cluster.ts | 22 + src/common/atlas/openapi.d.ts | 499 ++++++++++++++++++ src/common/atlas/performanceAdvisorUtils.ts | 228 ++++++++ src/common/logger.ts | 5 + .../atlas/read/listPerformanceAdvisor.ts | 83 +++ src/tools/atlas/tools.ts | 2 + 8 files changed, 891 insertions(+) create mode 100644 src/common/atlas/performanceAdvisorUtils.ts create mode 100644 src/tools/atlas/read/listPerformanceAdvisor.ts diff --git a/scripts/filter.ts b/scripts/filter.ts index 3368a5062..2115c9d98 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 2bb954f53..8a55faea6 100644 --- a/src/common/atlas/apiClient.ts +++ b/src/common/atlas/apiClient.ts @@ -428,6 +428,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( @@ -507,6 +543,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 b9a1dc1c4..2d11cccfb 100644 --- a/src/common/atlas/cluster.ts +++ b/src/common/atlas/cluster.ts @@ -96,3 +96,25 @@ export async function inspectCluster(apiClient: ApiClient, projectId: string, cl } } } + +export async function getProcessIdFromCluster( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise { + try { + // Reuse existing inspectCluster method + const cluster = await inspectCluster(apiClient, projectId, clusterName); + if (!cluster.connectionString) { + throw new Error("No connection string available for cluster"); + } + // Extract host:port from connection string + const url = new URL(cluster.connectionString); + const processId = `${url.hostname}:${url.port || "27017"}`; + return processId; + } catch (error) { + throw new Error( + `Failed to get processId 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 890c45c7c..686f45d48 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 000000000..60bd88e78 --- /dev/null +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -0,0 +1,228 @@ +import { LogId } from "../logger.js"; +import { ApiClient } from "./apiClient.js"; +import { getProcessIdFromCluster } from "./cluster.js"; + +export enum PerformanceAdvisorOperation { + SUGGESTED_INDEXES = "suggestedIndexes", + DROP_INDEX_SUGGESTIONS = "dropIndexSuggestions", + SLOW_QUERY_LOGS = "slowQueryLogs", + SCHEMA_SUGGESTIONS = "schemaSuggestions", +} + +interface SuggestedIndex { + avgObjSize?: number; + id?: string; + impact?: Array; + index?: Array<{ [key: string]: 1 | -1 }>; + namespace?: string; + weight?: number; +} + +interface DropIndexSuggestion { + accessCount?: number; + index?: Array<{ [key: string]: 1 | -1 }>; + name?: string; + namespace?: string; + shards?: Array; + since?: string; + sizeBytes?: number; +} + +type SchemaTriggerType = + | "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"; + +type SchemaRecommedationType = + | "REDUCE_LOOKUP_OPS" + | "AVOID_UNBOUNDED_ARRAY" + | "REDUCE_DOCUMENT_SIZE" + | "REMOVE_UNNECESSARY_INDEXES" + | "REDUCE_NUMBER_OF_NAMESPACES" + | "OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES" + | "OPTIMIZE_TEXT_QUERIES"; + +interface SchemaRecommendation { + affectedNamespaces?: Array<{ + namespace?: string | null; + triggers?: Array<{ + description?: string; + triggerType?: SchemaTriggerType; + }>; + }>; + description?: string; + recommendation?: SchemaRecommedationType; +} + +interface SlowQueryLogMetrics { + docsExamined?: number; + docsExaminedReturnedRatio?: number; + docsReturned?: number; + fromUserConnection?: boolean; + hasIndexCoverage?: boolean; + hasSort?: boolean; + keysExamined?: number; + keysExaminedReturnedRatio?: number; + numYields?: number; + operationExecutionTime?: number; + responseLength?: number; +} + +interface SlowQueryLog { + line?: string; + metrics?: SlowQueryLogMetrics; + namespace?: string; + opType?: string; + replicaState?: string; +} + +export interface PerformanceAdvisorData { + suggestedIndexes: Array; + dropIndexSuggestions: { + hiddenIndexes: Array; + redundantIndexes: Array; + unusedIndexes: Array; + }; + slowQueryLogs: Array; + schemaSuggestions: Array; +} + +export async function getSuggestedIndexes( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ suggestedIndexes: Array }> { + try { + const response = await apiClient.listClusterSuggestedIndexes({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + if (!response?.suggestedIndexes?.length) { + throw new Error("No suggested indexes found."); + } + return { suggestedIndexes: response.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.`); + } +} + +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, + }, + }, + }); + if ( + !response?.hiddenIndexes?.length && + !response?.redundantIndexes?.length && + !response?.unusedIndexes?.length + ) { + throw new Error("No drop index suggestions found."); + } + return { + hiddenIndexes: response?.hiddenIndexes ?? [], + redundantIndexes: response?.redundantIndexes ?? [], + unusedIndexes: response?.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.`); + } +} + +export async function getSchemaAdvice( + apiClient: ApiClient, + projectId: string, + clusterName: string +): Promise<{ recommendations: Array }> { + try { + const response = await apiClient.listSchemaAdvice({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + if (!response?.recommendations?.length) { + throw new Error("No schema advice found."); + } + return { recommendations: response.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.`); + } +} + +export async function getSlowQueries( + apiClient: ApiClient, + projectId: string, + clusterName: string, + since: string, + processId?: string // Optional parameter for direct processId +): Promise<{ slowQueryLogs: Array }> { + try { + // If processId is not provided, try to get it from cluster info + let actualProcessId = processId; + if (!actualProcessId) { + actualProcessId = await getProcessIdFromCluster(apiClient, projectId, clusterName); + } + + const response = await apiClient.listSlowQueries({ + params: { + path: { + groupId: projectId, + processId: actualProcessId, + }, + query: { + since: Number(since), + }, + }, + }); + + if (!response?.slowQueries?.length) { + throw new Error("No slow query logs found."); + } + return { slowQueryLogs: response.slowQueries }; + } 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.`); + } +} diff --git a/src/common/logger.ts b/src/common/logger.ts index 0add105c2..1d3573b09 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -58,6 +58,11 @@ export const LogId = { exportedDataListError: mongoLogId(1_007_006), exportedDataAutoCompleteError: mongoLogId(1_007_007), exportLockError: mongoLogId(1_007_008), + + atlasPaSuggestedIndexesFailure: mongoLogId(1_008_001), + atlasPaDropIndexSuggestionsFailure: mongoLogId(1_008_002), + atlasPaSchemaAdviceFailure: mongoLogId(1_008_003), + atlasPaSlowQueryLogsFailure: mongoLogId(1_008_004), } as const; interface LogPayload { diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts new file mode 100644 index 000000000..754dd8563 --- /dev/null +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { AtlasToolBase } from "../atlasTool.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { OperationType, ToolArgs } from "../../tool.js"; +import { + getSuggestedIndexes, + getDropIndexSuggestions, + getSchemaAdvice, + getSlowQueries, + PerformanceAdvisorOperation, + PerformanceAdvisorData, +} from "../../../common/atlas/performanceAdvisorUtils.js"; + +export class ListPerformanceAdvisorTool extends AtlasToolBase { + public name = "atlas-list-performance-advisor"; + protected description = "List MongoDB Atlas performance advisor recommendations"; + public operationType: OperationType = "read"; + protected argsShape = { + projectId: z.string().describe("Atlas project ID to list performance advisor recommendations"), + clusterName: z.string().describe("Atlas cluster name to list performance advisor recommendations"), + operations: z + .array(z.nativeEnum(PerformanceAdvisorOperation)) + .describe("Operations to list performance advisor recommendations"), + since: z.string().describe("Date to list performance advisor recommendations since"), + }; + + protected async execute({ + projectId, + clusterName, + operations, + since, + }: ToolArgs): Promise { + const data: PerformanceAdvisorData = { + suggestedIndexes: [], + dropIndexSuggestions: { hiddenIndexes: [], redundantIndexes: [], unusedIndexes: [] }, + slowQueryLogs: [], + schemaSuggestions: [], + }; + + // If operations is empty, get all performance advisor recommendations + // Otherwise, get only the specified operations + const operationsToExecute = operations.length === 0 ? Object.values(PerformanceAdvisorOperation) : operations; + + try { + if (operationsToExecute.includes(PerformanceAdvisorOperation.SUGGESTED_INDEXES)) { + const { suggestedIndexes } = await getSuggestedIndexes(this.session.apiClient, projectId, clusterName); + data.suggestedIndexes = suggestedIndexes; + } + + if (operationsToExecute.includes(PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS)) { + const { hiddenIndexes, redundantIndexes, unusedIndexes } = await getDropIndexSuggestions( + this.session.apiClient, + projectId, + clusterName + ); + data.dropIndexSuggestions = { hiddenIndexes, redundantIndexes, unusedIndexes }; + } + + if (operationsToExecute.includes(PerformanceAdvisorOperation.SLOW_QUERY_LOGS)) { + const { slowQueryLogs } = await getSlowQueries(this.session.apiClient, projectId, clusterName, since); + data.slowQueryLogs = slowQueryLogs; + } + + if (operationsToExecute.includes(PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS)) { + const { recommendations } = await getSchemaAdvice(this.session.apiClient, projectId, clusterName); + data.schemaSuggestions = recommendations; + } + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error retrieving performance advisor data: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } + + return { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; + } +} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index c43b88ef7..a488660a7 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 { ListPerformanceAdvisorTool } from "./read/listPerformanceAdvisor.js"; export const AtlasTools = [ ListClustersTool, @@ -24,4 +25,5 @@ export const AtlasTools = [ ListOrganizationsTool, ConnectClusterTool, ListAlertsTool, + ListPerformanceAdvisorTool, ]; From f331bac87c92d3c5a86158f9298d2baec2d115f0 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Thu, 4 Sep 2025 12:13:37 -0400 Subject: [PATCH 02/17] Cleanup comments --- src/common/atlas/cluster.ts | 5 ++--- src/common/atlas/performanceAdvisorUtils.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/common/atlas/cluster.ts b/src/common/atlas/cluster.ts index 2d11cccfb..e27e6495f 100644 --- a/src/common/atlas/cluster.ts +++ b/src/common/atlas/cluster.ts @@ -2,6 +2,7 @@ import { ClusterDescription20240805, FlexClusterDescription20241113 } from "./op import { ApiClient } from "./apiClient.js"; import { LogId } from "../logger.js"; +const DEFAULT_PORT = "27017"; export interface Cluster { name?: string; instanceType: "FREE" | "DEDICATED" | "FLEX"; @@ -103,14 +104,12 @@ export async function getProcessIdFromCluster( clusterName: string ): Promise { try { - // Reuse existing inspectCluster method const cluster = await inspectCluster(apiClient, projectId, clusterName); if (!cluster.connectionString) { throw new Error("No connection string available for cluster"); } - // Extract host:port from connection string const url = new URL(cluster.connectionString); - const processId = `${url.hostname}:${url.port || "27017"}`; + const processId = `${url.hostname}:${url.port || DEFAULT_PORT}`; return processId; } catch (error) { throw new Error( diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 60bd88e78..3f3cf3b58 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -192,10 +192,10 @@ export async function getSlowQueries( projectId: string, clusterName: string, since: string, - processId?: string // Optional parameter for direct processId + processId?: string ): Promise<{ slowQueryLogs: Array }> { try { - // If processId is not provided, try to get it from cluster info + // If processId is not provided, get it from inspecting the cluster let actualProcessId = processId; if (!actualProcessId) { actualProcessId = await getProcessIdFromCluster(apiClient, projectId, clusterName); From 2f023ebb37d501d595e1417ec89deaea3e49cfaa Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Fri, 5 Sep 2025 17:23:34 -0400 Subject: [PATCH 03/17] Fix API return type --- src/common/atlas/performanceAdvisorUtils.ts | 38 ++++++------------- .../atlas/read/listPerformanceAdvisor.ts | 6 +-- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 3f3cf3b58..43236c0c6 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -1,5 +1,5 @@ import { LogId } from "../logger.js"; -import { ApiClient } from "./apiClient.js"; +import type { ApiClient } from "./apiClient.js"; import { getProcessIdFromCluster } from "./cluster.js"; export enum PerformanceAdvisorOperation { @@ -105,17 +105,14 @@ export async function getSuggestedIndexes( }, }, }); - if (!response?.suggestedIndexes?.length) { - throw new Error("No suggested indexes found."); - } - return { suggestedIndexes: response.suggestedIndexes }; + return { suggestedIndexes: response?.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.`); + throw new Error(`Failed to list suggested indexes: ${err instanceof Error ? err.message : String(err)}`); } } @@ -137,17 +134,10 @@ export async function getDropIndexSuggestions( }, }, }); - if ( - !response?.hiddenIndexes?.length && - !response?.redundantIndexes?.length && - !response?.unusedIndexes?.length - ) { - throw new Error("No drop index suggestions found."); - } return { - hiddenIndexes: response?.hiddenIndexes ?? [], - redundantIndexes: response?.redundantIndexes ?? [], - unusedIndexes: response?.unusedIndexes ?? [], + hiddenIndexes: response?.content?.hiddenIndexes ?? [], + redundantIndexes: response?.content?.redundantIndexes ?? [], + unusedIndexes: response?.content?.unusedIndexes ?? [], }; } catch (err) { apiClient.logger.debug({ @@ -155,7 +145,7 @@ export async function getDropIndexSuggestions( 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.`); + throw new Error(`Failed to list drop index suggestions: ${err instanceof Error ? err.message : String(err)}`); } } @@ -173,17 +163,14 @@ export async function getSchemaAdvice( }, }, }); - if (!response?.recommendations?.length) { - throw new Error("No schema advice found."); - } - return { recommendations: response.recommendations }; + return { recommendations: response?.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.`); + throw new Error(`Failed to list schema advice: ${err instanceof Error ? err.message : String(err)}`); } } @@ -213,16 +200,13 @@ export async function getSlowQueries( }, }); - if (!response?.slowQueries?.length) { - throw new Error("No slow query logs found."); - } - return { slowQueryLogs: response.slowQueries }; + return { slowQueryLogs: response?.content?.slowQueries ?? [] }; } 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.`); + throw new Error(`Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index 754dd8563..6226ff765 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -1,14 +1,14 @@ import { z } from "zod"; import { AtlasToolBase } from "../atlasTool.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { OperationType, ToolArgs } from "../../tool.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { OperationType, ToolArgs } from "../../tool.js"; import { getSuggestedIndexes, getDropIndexSuggestions, getSchemaAdvice, getSlowQueries, PerformanceAdvisorOperation, - PerformanceAdvisorData, + type PerformanceAdvisorData, } from "../../../common/atlas/performanceAdvisorUtils.js"; export class ListPerformanceAdvisorTool extends AtlasToolBase { From a4cbc8b6bc4833b62dceb5e1f73f5e5dbc3cb71e Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Sun, 7 Sep 2025 21:37:18 -0400 Subject: [PATCH 04/17] Clean up getting slow query logs from atlas admin api --- src/common/atlas/performanceAdvisorUtils.ts | 10 ++++++---- src/tools/atlas/read/listPerformanceAdvisor.ts | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 43236c0c6..86df01a9c 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -178,8 +178,9 @@ export async function getSlowQueries( apiClient: ApiClient, projectId: string, clusterName: string, - since: string, - processId?: string + since?: number, + processId?: string, + namespaces?: string[] ): Promise<{ slowQueryLogs: Array }> { try { // If processId is not provided, get it from inspecting the cluster @@ -195,12 +196,13 @@ export async function getSlowQueries( processId: actualProcessId, }, query: { - since: Number(since), + ...(since && { since: Number(since) }), + ...(namespaces && { namespaces: namespaces }), }, }, }); - return { slowQueryLogs: response?.content?.slowQueries ?? [] }; + return { slowQueryLogs: response?.slowQueries ?? [] }; } catch (err) { apiClient.logger.debug({ id: LogId.atlasPaSlowQueryLogsFailure, diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index 6226ff765..48f99a67d 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -21,7 +21,9 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { operations: z .array(z.nativeEnum(PerformanceAdvisorOperation)) .describe("Operations to list performance advisor recommendations"), - since: z.string().describe("Date to list performance advisor recommendations since"), + since: z.number().describe("Date to list slow query logs since").optional(), + processId: z.string().describe("Process ID to list slow query logs").optional(), + namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), }; protected async execute({ @@ -29,6 +31,8 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { clusterName, operations, since, + processId, + namespaces, }: ToolArgs): Promise { const data: PerformanceAdvisorData = { suggestedIndexes: [], @@ -57,7 +61,14 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { } if (operationsToExecute.includes(PerformanceAdvisorOperation.SLOW_QUERY_LOGS)) { - const { slowQueryLogs } = await getSlowQueries(this.session.apiClient, projectId, clusterName, since); + const { slowQueryLogs } = await getSlowQueries( + this.session.apiClient, + projectId, + clusterName, + since, + processId, + namespaces + ); data.slowQueryLogs = slowQueryLogs; } From 2ef0ded2139ab1f2afaf37191242a07f8876a419 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Mon, 8 Sep 2025 15:41:12 -0400 Subject: [PATCH 05/17] Fix types for performance advisor api response --- src/common/atlas/performanceAdvisorUtils.ts | 38 +++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 86df01a9c..d638259ed 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -18,6 +18,30 @@ interface SuggestedIndex { weight?: number; } +interface SuggestedIndexesResponse { + content: { + suggestedIndexes?: SuggestedIndex[]; + }; +} + +interface DropIndexesResponse { + content: { + hiddenIndexes?: DropIndexSuggestion[]; + redundantIndexes?: DropIndexSuggestion[]; + unusedIndexes?: DropIndexSuggestion[]; + }; +} + +interface SchemaAdviceResponse { + content: { + recommendations?: SchemaRecommendation[]; + }; +} + +interface SlowQueriesResponse { + slowQueries?: SlowQueryLog[]; +} + interface DropIndexSuggestion { accessCount?: number; index?: Array<{ [key: string]: 1 | -1 }>; @@ -105,7 +129,9 @@ export async function getSuggestedIndexes( }, }, }); - return { suggestedIndexes: response?.content?.suggestedIndexes ?? [] }; + return { + suggestedIndexes: (response as SuggestedIndexesResponse).content.suggestedIndexes ?? [], + }; } catch (err) { apiClient.logger.debug({ id: LogId.atlasPaSuggestedIndexesFailure, @@ -135,9 +161,9 @@ export async function getDropIndexSuggestions( }, }); return { - hiddenIndexes: response?.content?.hiddenIndexes ?? [], - redundantIndexes: response?.content?.redundantIndexes ?? [], - unusedIndexes: response?.content?.unusedIndexes ?? [], + hiddenIndexes: (response as DropIndexesResponse).content.hiddenIndexes ?? [], + redundantIndexes: (response as DropIndexesResponse).content.redundantIndexes ?? [], + unusedIndexes: (response as DropIndexesResponse).content.unusedIndexes ?? [], }; } catch (err) { apiClient.logger.debug({ @@ -163,7 +189,7 @@ export async function getSchemaAdvice( }, }, }); - return { recommendations: response?.content?.recommendations ?? [] }; + return { recommendations: (response as SchemaAdviceResponse).content.recommendations ?? [] }; } catch (err) { apiClient.logger.debug({ id: LogId.atlasPaSchemaAdviceFailure, @@ -202,7 +228,7 @@ export async function getSlowQueries( }, }); - return { slowQueryLogs: response?.slowQueries ?? [] }; + return { slowQueryLogs: (response as SlowQueriesResponse).slowQueries ?? [] }; } catch (err) { apiClient.logger.debug({ id: LogId.atlasPaSlowQueryLogsFailure, From 923263e7a3d00919ea96b9d172f3528faf67e678 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Fri, 12 Sep 2025 21:39:04 -0400 Subject: [PATCH 06/17] Address comments --- src/common/atlas/performanceAdvisorUtils.ts | 48 ++-- .../atlas/read/listPerformanceAdvisor.ts | 216 +++++++++++++++--- 2 files changed, 223 insertions(+), 41 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index d638259ed..e8dd7a562 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -9,7 +9,7 @@ export enum PerformanceAdvisorOperation { SCHEMA_SUGGESTIONS = "schemaSuggestions", } -interface SuggestedIndex { +export interface SuggestedIndex { avgObjSize?: number; id?: string; impact?: Array; @@ -20,29 +20,29 @@ interface SuggestedIndex { interface SuggestedIndexesResponse { content: { - suggestedIndexes?: SuggestedIndex[]; + suggestedIndexes?: Array; }; } interface DropIndexesResponse { content: { - hiddenIndexes?: DropIndexSuggestion[]; - redundantIndexes?: DropIndexSuggestion[]; - unusedIndexes?: DropIndexSuggestion[]; + hiddenIndexes?: Array; + redundantIndexes?: Array; + unusedIndexes?: Array; }; } interface SchemaAdviceResponse { content: { - recommendations?: SchemaRecommendation[]; + recommendations?: Array; }; } interface SlowQueriesResponse { - slowQueries?: SlowQueryLog[]; + slowQueries?: Array; } -interface DropIndexSuggestion { +export interface DropIndexSuggestion { accessCount?: number; index?: Array<{ [key: string]: 1 | -1 }>; name?: string; @@ -52,7 +52,7 @@ interface DropIndexSuggestion { sizeBytes?: number; } -type SchemaTriggerType = +export type SchemaTriggerType = | "PERCENT_QUERIES_USE_LOOKUP" | "NUMBER_OF_QUERIES_USE_LOOKUP" | "DOCS_CONTAIN_UNBOUNDED_ARRAY" @@ -61,6 +61,16 @@ type SchemaTriggerType = | "NUM_INDEXES" | "QUERIES_CONTAIN_CASE_INSENSITIVE_REGEX"; +export const SCHEMA_TRIGGER_DESCRIPTIONS: Record = { + PERCENT_QUERIES_USE_LOOKUP: "High percentage of queries (>50%) use $lookup operations", + NUMBER_OF_QUERIES_USE_LOOKUP: "High number of queries (>100) use $lookup operations", + DOCS_CONTAIN_UNBOUNDED_ARRAY: "Arrays with over 10000 entries detected in the collection(s)", + NUMBER_OF_NAMESPACES: "Too many namespaces (collections) in the database (>100)", + DOC_SIZE_TOO_LARGE: "Documents larger than 2 MB found in the collection(s)", + NUM_INDEXES: "More than 30 indexes detected in the collection(s) scanned", + QUERIES_CONTAIN_CASE_INSENSITIVE_REGEX: "Queries use case-insensitive regular expressions", +}; + type SchemaRecommedationType = | "REDUCE_LOOKUP_OPS" | "AVOID_UNBOUNDED_ARRAY" @@ -70,7 +80,17 @@ type SchemaRecommedationType = | "OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES" | "OPTIMIZE_TEXT_QUERIES"; -interface SchemaRecommendation { +export const SCHEMA_RECOMMENDATION_DESCRIPTIONS: Record = { + REDUCE_LOOKUP_OPS: "Reduce the use of $lookup operations", + AVOID_UNBOUNDED_ARRAY: "Avoid using unbounded arrays in documents", + REDUCE_DOCUMENT_SIZE: "Reduce the size of documents", + REMOVE_UNNECESSARY_INDEXES: "Remove unnecessary indexes", + REDUCE_NUMBER_OF_NAMESPACES: "Reduce the number of collections in the database", + OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES: "Optimize case-insensitive regex queries", + OPTIMIZE_TEXT_QUERIES: "Optimize text search queries", +}; + +export interface SchemaRecommendation { affectedNamespaces?: Array<{ namespace?: string | null; triggers?: Array<{ @@ -96,7 +116,7 @@ interface SlowQueryLogMetrics { responseLength?: number; } -interface SlowQueryLog { +export interface SlowQueryLog { line?: string; metrics?: SlowQueryLogMetrics; namespace?: string; @@ -204,9 +224,9 @@ export async function getSlowQueries( apiClient: ApiClient, projectId: string, clusterName: string, - since?: number, + since?: Date, processId?: string, - namespaces?: string[] + namespaces?: Array ): Promise<{ slowQueryLogs: Array }> { try { // If processId is not provided, get it from inspecting the cluster @@ -222,7 +242,7 @@ export async function getSlowQueries( processId: actualProcessId, }, query: { - ...(since && { since: Number(since) }), + ...(since && { since: since.getTime() }), ...(namespaces && { namespaces: namespaces }), }, }, diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index 48f99a67d..3141ee38f 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -2,6 +2,7 @@ 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, @@ -9,6 +10,13 @@ import { getSlowQueries, PerformanceAdvisorOperation, type PerformanceAdvisorData, + type SuggestedIndex, + type DropIndexSuggestion, + type SlowQueryLog, + type SchemaRecommendation, + SCHEMA_RECOMMENDATION_DESCRIPTIONS, + SCHEMA_TRIGGER_DESCRIPTIONS, + type SchemaTriggerType, } from "../../../common/atlas/performanceAdvisorUtils.js"; export class ListPerformanceAdvisorTool extends AtlasToolBase { @@ -20,12 +28,120 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { clusterName: z.string().describe("Atlas cluster name to list performance advisor recommendations"), operations: z .array(z.nativeEnum(PerformanceAdvisorOperation)) + .default(Object.values(PerformanceAdvisorOperation)) .describe("Operations to list performance advisor recommendations"), - since: z.number().describe("Date to list slow query logs since").optional(), + since: z.date().describe("Date to list slow query logs since").optional(), processId: z.string().describe("Process ID to list slow query logs").optional(), namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), }; + private formatSuggestedIndexesTable(suggestedIndexes: Array): string { + if (suggestedIndexes.length === 0) return "No suggested indexes found."; + + const rows = suggestedIndexes + .map((index, i) => { + const namespace = index.namespace ?? "N/A"; + const weight = index.weight ?? "N/A"; + const avgObjSize = index.avgObjSize ?? "N/A"; + const indexKeys = index.index ? index.index.map((key) => Object.keys(key)[0]).join(", ") : "N/A"; + return `${i + 1} | ${namespace} | ${weight} | ${avgObjSize} | ${indexKeys}`; + }) + .join("\n"); + + return `# | Namespace | Weight | Avg Obj Size | Index Keys +---|-----------|--------|--------------|------------ +${rows}`; + } + + private formatDropIndexesTable(dropIndexSuggestions: { + hiddenIndexes: Array; + redundantIndexes: Array; + unusedIndexes: Array; + }): string { + const allIndexes = [ + ...dropIndexSuggestions.hiddenIndexes.map((idx) => ({ ...idx, type: "Hidden" })), + ...dropIndexSuggestions.redundantIndexes.map((idx) => ({ ...idx, type: "Redundant" })), + ...dropIndexSuggestions.unusedIndexes.map((idx) => ({ ...idx, type: "Unused" })), + ]; + + if (allIndexes.length === 0) return "No drop index suggestions found."; + + const rows = allIndexes + .map((index, i) => { + const name = index.name ?? "N/A"; + const namespace = index.namespace ?? "N/A"; + const type = index.type ?? "N/A"; + const sizeBytes = index.sizeBytes ?? "N/A"; + const accessCount = index.accessCount ?? "N/A"; + return `${i + 1} | ${name} | ${namespace} | ${type} | ${sizeBytes} | ${accessCount}`; + }) + .join("\n"); + + return `# | Index Name | Namespace | Type | Size (bytes) | Access Count +---|------------|-----------|------|--------------|------------- +${rows}`; + } + + private formatSlowQueriesTable(slowQueryLogs: Array): string { + if (slowQueryLogs.length === 0) return "No slow query logs found."; + + const rows = slowQueryLogs + .map((log, i) => { + const namespace = log.namespace ?? "N/A"; + const opType = log.opType ?? "N/A"; + const executionTime = log.metrics?.operationExecutionTime ?? "N/A"; + const docsExamined = log.metrics?.docsExamined ?? "N/A"; + const docsReturned = log.metrics?.docsReturned ?? "N/A"; + return `${i + 1} | ${namespace} | ${opType} | ${executionTime}ms | ${docsExamined} | ${docsReturned}`; + }) + .join("\n"); + + return `# | Namespace | Operation | Execution Time | Docs Examined | Docs Returned +---|-----------|-----------|---------------|---------------|--------------- +${rows}`; + } + + private getTriggerDescription(triggerType: SchemaTriggerType | undefined): string { + if (!triggerType) return "N/A"; + return SCHEMA_TRIGGER_DESCRIPTIONS[triggerType] ?? triggerType; + } + + private getNamespaceTriggerDescriptions(namespace: { + triggers?: Array<{ triggerType?: SchemaTriggerType }>; + }): string { + if (!namespace.triggers) return "N/A"; + + return namespace.triggers.map((trigger) => this.getTriggerDescription(trigger.triggerType)).join(", "); + } + + private getTriggerDescriptions(suggestion: SchemaRecommendation): string { + if (!suggestion.affectedNamespaces) return "N/A"; + + return suggestion.affectedNamespaces + .map((namespace) => this.getNamespaceTriggerDescriptions(namespace)) + .join(", "); + } + + private formatSchemaSuggestionsTable(schemaSuggestions: Array): string { + if (schemaSuggestions.length === 0) return "No schema suggestions found."; + + const rows = schemaSuggestions + .map((suggestion: SchemaRecommendation, i) => { + const recommendation = suggestion.recommendation + ? (SCHEMA_RECOMMENDATION_DESCRIPTIONS[suggestion.recommendation] ?? suggestion.recommendation) + : "N/A"; + const description = suggestion.description ?? "N/A"; + const triggeredBy = this.getTriggerDescriptions(suggestion); + const affectedNamespaces = suggestion.affectedNamespaces?.length ?? 0; + return `${i + 1} | ${recommendation} | ${description} | ${triggeredBy} | ${affectedNamespaces} namespaces`; + }) + .join("\n"); + + return `# | Recommendation | Description | Triggered By | Affected Namespaces +---|---------------|-------------|----------|------------------- +${rows}`; + } + protected async execute({ projectId, clusterName, @@ -41,41 +157,46 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { schemaSuggestions: [], }; - // If operations is empty, get all performance advisor recommendations - // Otherwise, get only the specified operations - const operationsToExecute = operations.length === 0 ? Object.values(PerformanceAdvisorOperation) : operations; - try { - if (operationsToExecute.includes(PerformanceAdvisorOperation.SUGGESTED_INDEXES)) { - const { suggestedIndexes } = await getSuggestedIndexes(this.session.apiClient, projectId, clusterName); - data.suggestedIndexes = suggestedIndexes; + const performanceAdvisorPromises = []; + + if (operations.includes(PerformanceAdvisorOperation.SUGGESTED_INDEXES)) { + performanceAdvisorPromises.push( + getSuggestedIndexes(this.session.apiClient, projectId, clusterName).then(({ suggestedIndexes }) => { + data.suggestedIndexes = suggestedIndexes; + }) + ); } - if (operationsToExecute.includes(PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS)) { - const { hiddenIndexes, redundantIndexes, unusedIndexes } = await getDropIndexSuggestions( - this.session.apiClient, - projectId, - clusterName + if (operations.includes(PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS)) { + performanceAdvisorPromises.push( + getDropIndexSuggestions(this.session.apiClient, projectId, clusterName).then( + ({ hiddenIndexes, redundantIndexes, unusedIndexes }) => { + data.dropIndexSuggestions = { hiddenIndexes, redundantIndexes, unusedIndexes }; + } + ) ); - data.dropIndexSuggestions = { hiddenIndexes, redundantIndexes, unusedIndexes }; } - if (operationsToExecute.includes(PerformanceAdvisorOperation.SLOW_QUERY_LOGS)) { - const { slowQueryLogs } = await getSlowQueries( - this.session.apiClient, - projectId, - clusterName, - since, - processId, - namespaces + if (operations.includes(PerformanceAdvisorOperation.SLOW_QUERY_LOGS)) { + performanceAdvisorPromises.push( + getSlowQueries(this.session.apiClient, projectId, clusterName, since, processId, namespaces).then( + ({ slowQueryLogs }) => { + data.slowQueryLogs = slowQueryLogs; + } + ) ); - data.slowQueryLogs = slowQueryLogs; } - if (operationsToExecute.includes(PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS)) { - const { recommendations } = await getSchemaAdvice(this.session.apiClient, projectId, clusterName); - data.schemaSuggestions = recommendations; + if (operations.includes(PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS)) { + performanceAdvisorPromises.push( + getSchemaAdvice(this.session.apiClient, projectId, clusterName).then(({ recommendations }) => { + data.schemaSuggestions = recommendations; + }) + ); } + + await Promise.all(performanceAdvisorPromises); } catch (error) { return { content: [ @@ -87,8 +208,49 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { }; } + // Format the data as tables + let formattedOutput = ""; + let totalItems = 0; + + if (data.suggestedIndexes.length > 0) { + const suggestedIndexesTable = this.formatSuggestedIndexesTable(data.suggestedIndexes); + formattedOutput += `\n## Suggested Indexes\n${suggestedIndexesTable}\n`; + totalItems += data.suggestedIndexes.length; + } + + if ( + data.dropIndexSuggestions.hiddenIndexes.length > 0 || + data.dropIndexSuggestions.redundantIndexes.length > 0 || + data.dropIndexSuggestions.unusedIndexes.length > 0 + ) { + const dropIndexesTable = this.formatDropIndexesTable(data.dropIndexSuggestions); + formattedOutput += `\n## Drop Index Suggestions\n${dropIndexesTable}\n`; + totalItems += + data.dropIndexSuggestions.hiddenIndexes.length + + data.dropIndexSuggestions.redundantIndexes.length + + data.dropIndexSuggestions.unusedIndexes.length; + } + + if (data.slowQueryLogs.length > 0) { + const slowQueriesTable = this.formatSlowQueriesTable(data.slowQueryLogs); + formattedOutput += `\n## Slow Query Logs\n${slowQueriesTable}\n`; + totalItems += data.slowQueryLogs.length; + } + + if (data.schemaSuggestions.length > 0) { + const schemaTable = this.formatSchemaSuggestionsTable(data.schemaSuggestions); + formattedOutput += `\n## Schema Suggestions\n${schemaTable}\n`; + totalItems += data.schemaSuggestions.length; + } + + if (totalItems === 0) { + return { + content: [{ type: "text", text: "No performance advisor recommendations found." }], + }; + } + return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + content: formatUntrustedData(`Found ${totalItems} performance advisor recommendations`, formattedOutput), }; } } From 8272719c1b98efdefefab303f7ac92327b797714 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Fri, 12 Sep 2025 21:56:20 -0400 Subject: [PATCH 07/17] Move utils to util file --- src/common/atlas/performanceAdvisorUtils.ts | 103 ++++++++++++++ .../atlas/read/listPerformanceAdvisor.ts | 126 ++---------------- 2 files changed, 111 insertions(+), 118 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index e8dd7a562..16d12345d 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -258,3 +258,106 @@ export async function getSlowQueries( throw new Error(`Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`); } } + +export function formatSuggestedIndexesTable(suggestedIndexes: Array): string { + if (suggestedIndexes.length === 0) return "No suggested indexes found."; + + const rows = suggestedIndexes + .map((index, i) => { + const namespace = index.namespace ?? "N/A"; + const weight = index.weight ?? "N/A"; + const avgObjSize = index.avgObjSize ?? "N/A"; + const indexKeys = index.index ? index.index.map((key) => Object.keys(key)[0]).join(", ") : "N/A"; + return `${i + 1} | ${namespace} | ${weight} | ${avgObjSize} | ${indexKeys}`; + }) + .join("\n"); + + return `# | Namespace | Weight | Avg Obj Size | Index Keys +---|-----------|--------|--------------|------------ +${rows}`; +} + +export function formatDropIndexesTable(dropIndexSuggestions: { + hiddenIndexes: Array; + redundantIndexes: Array; + unusedIndexes: Array; +}): string { + const allIndexes = [ + ...dropIndexSuggestions.hiddenIndexes.map((idx) => ({ ...idx, type: "Hidden" })), + ...dropIndexSuggestions.redundantIndexes.map((idx) => ({ ...idx, type: "Redundant" })), + ...dropIndexSuggestions.unusedIndexes.map((idx) => ({ ...idx, type: "Unused" })), + ]; + + if (allIndexes.length === 0) return "No drop index suggestions found."; + + const rows = allIndexes + .map((index, i) => { + const name = index.name ?? "N/A"; + const namespace = index.namespace ?? "N/A"; + const type = index.type ?? "N/A"; + const sizeBytes = index.sizeBytes ?? "N/A"; + const accessCount = index.accessCount ?? "N/A"; + return `${i + 1} | ${name} | ${namespace} | ${type} | ${sizeBytes} | ${accessCount}`; + }) + .join("\n"); + + return `# | Index Name | Namespace | Type | Size (bytes) | Access Count +---|------------|-----------|------|--------------|------------- +${rows}`; +} + +export function formatSlowQueriesTable(slowQueryLogs: Array): string { + if (slowQueryLogs.length === 0) return "No slow query logs found."; + + const rows = slowQueryLogs + .map((log, i) => { + const namespace = log.namespace ?? "N/A"; + const opType = log.opType ?? "N/A"; + const executionTime = log.metrics?.operationExecutionTime ?? "N/A"; + const docsExamined = log.metrics?.docsExamined ?? "N/A"; + const docsReturned = log.metrics?.docsReturned ?? "N/A"; + return `${i + 1} | ${namespace} | ${opType} | ${executionTime}ms | ${docsExamined} | ${docsReturned}`; + }) + .join("\n"); + + return `# | Namespace | Operation | Execution Time | Docs Examined | Docs Returned +---|-----------|-----------|---------------|---------------|--------------- +${rows}`; +} + +function getTriggerDescription(triggerType: SchemaTriggerType | undefined): string { + if (!triggerType) return "N/A"; + return SCHEMA_TRIGGER_DESCRIPTIONS[triggerType] ?? triggerType; +} + +function getNamespaceTriggerDescriptions(namespace: { triggers?: Array<{ triggerType?: SchemaTriggerType }> }): string { + if (!namespace.triggers) return "N/A"; + + return namespace.triggers.map((trigger) => getTriggerDescription(trigger.triggerType)).join(", "); +} + +function getTriggerDescriptions(suggestion: SchemaRecommendation): string { + if (!suggestion.affectedNamespaces) return "N/A"; + + return suggestion.affectedNamespaces.map((namespace) => getNamespaceTriggerDescriptions(namespace)).join(", "); +} + +export function formatSchemaSuggestionsTable(schemaSuggestions: Array): string { + if (schemaSuggestions.length === 0) return "No schema suggestions found."; + + const rows = schemaSuggestions + .map((suggestion: SchemaRecommendation, i) => { + const recommendation = suggestion.recommendation + ? (SCHEMA_RECOMMENDATION_DESCRIPTIONS[suggestion.recommendation] ?? suggestion.recommendation) + : "N/A"; + const description = suggestion.description ?? "N/A"; + const triggeredBy = getTriggerDescriptions(suggestion); + const affectedNamespaces = suggestion.affectedNamespaces?.length ?? 0; + return `${i + 1} | ${recommendation} | ${description} | ${triggeredBy} | ${affectedNamespaces} namespaces`; + }) + .join("\n"); + + return `# | Recommendation | Description | Triggered By | Affected Namespaces +---|---------------|-------------|----------|------------------- +${rows}`; +} diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index 3141ee38f..d90337af3 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -10,13 +10,10 @@ import { getSlowQueries, PerformanceAdvisorOperation, type PerformanceAdvisorData, - type SuggestedIndex, - type DropIndexSuggestion, - type SlowQueryLog, - type SchemaRecommendation, - SCHEMA_RECOMMENDATION_DESCRIPTIONS, - SCHEMA_TRIGGER_DESCRIPTIONS, - type SchemaTriggerType, + formatSuggestedIndexesTable, + formatDropIndexesTable, + formatSlowQueriesTable, + formatSchemaSuggestionsTable, } from "../../../common/atlas/performanceAdvisorUtils.js"; export class ListPerformanceAdvisorTool extends AtlasToolBase { @@ -35,113 +32,6 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), }; - private formatSuggestedIndexesTable(suggestedIndexes: Array): string { - if (suggestedIndexes.length === 0) return "No suggested indexes found."; - - const rows = suggestedIndexes - .map((index, i) => { - const namespace = index.namespace ?? "N/A"; - const weight = index.weight ?? "N/A"; - const avgObjSize = index.avgObjSize ?? "N/A"; - const indexKeys = index.index ? index.index.map((key) => Object.keys(key)[0]).join(", ") : "N/A"; - return `${i + 1} | ${namespace} | ${weight} | ${avgObjSize} | ${indexKeys}`; - }) - .join("\n"); - - return `# | Namespace | Weight | Avg Obj Size | Index Keys ----|-----------|--------|--------------|------------ -${rows}`; - } - - private formatDropIndexesTable(dropIndexSuggestions: { - hiddenIndexes: Array; - redundantIndexes: Array; - unusedIndexes: Array; - }): string { - const allIndexes = [ - ...dropIndexSuggestions.hiddenIndexes.map((idx) => ({ ...idx, type: "Hidden" })), - ...dropIndexSuggestions.redundantIndexes.map((idx) => ({ ...idx, type: "Redundant" })), - ...dropIndexSuggestions.unusedIndexes.map((idx) => ({ ...idx, type: "Unused" })), - ]; - - if (allIndexes.length === 0) return "No drop index suggestions found."; - - const rows = allIndexes - .map((index, i) => { - const name = index.name ?? "N/A"; - const namespace = index.namespace ?? "N/A"; - const type = index.type ?? "N/A"; - const sizeBytes = index.sizeBytes ?? "N/A"; - const accessCount = index.accessCount ?? "N/A"; - return `${i + 1} | ${name} | ${namespace} | ${type} | ${sizeBytes} | ${accessCount}`; - }) - .join("\n"); - - return `# | Index Name | Namespace | Type | Size (bytes) | Access Count ----|------------|-----------|------|--------------|------------- -${rows}`; - } - - private formatSlowQueriesTable(slowQueryLogs: Array): string { - if (slowQueryLogs.length === 0) return "No slow query logs found."; - - const rows = slowQueryLogs - .map((log, i) => { - const namespace = log.namespace ?? "N/A"; - const opType = log.opType ?? "N/A"; - const executionTime = log.metrics?.operationExecutionTime ?? "N/A"; - const docsExamined = log.metrics?.docsExamined ?? "N/A"; - const docsReturned = log.metrics?.docsReturned ?? "N/A"; - return `${i + 1} | ${namespace} | ${opType} | ${executionTime}ms | ${docsExamined} | ${docsReturned}`; - }) - .join("\n"); - - return `# | Namespace | Operation | Execution Time | Docs Examined | Docs Returned ----|-----------|-----------|---------------|---------------|--------------- -${rows}`; - } - - private getTriggerDescription(triggerType: SchemaTriggerType | undefined): string { - if (!triggerType) return "N/A"; - return SCHEMA_TRIGGER_DESCRIPTIONS[triggerType] ?? triggerType; - } - - private getNamespaceTriggerDescriptions(namespace: { - triggers?: Array<{ triggerType?: SchemaTriggerType }>; - }): string { - if (!namespace.triggers) return "N/A"; - - return namespace.triggers.map((trigger) => this.getTriggerDescription(trigger.triggerType)).join(", "); - } - - private getTriggerDescriptions(suggestion: SchemaRecommendation): string { - if (!suggestion.affectedNamespaces) return "N/A"; - - return suggestion.affectedNamespaces - .map((namespace) => this.getNamespaceTriggerDescriptions(namespace)) - .join(", "); - } - - private formatSchemaSuggestionsTable(schemaSuggestions: Array): string { - if (schemaSuggestions.length === 0) return "No schema suggestions found."; - - const rows = schemaSuggestions - .map((suggestion: SchemaRecommendation, i) => { - const recommendation = suggestion.recommendation - ? (SCHEMA_RECOMMENDATION_DESCRIPTIONS[suggestion.recommendation] ?? suggestion.recommendation) - : "N/A"; - const description = suggestion.description ?? "N/A"; - const triggeredBy = this.getTriggerDescriptions(suggestion); - const affectedNamespaces = suggestion.affectedNamespaces?.length ?? 0; - return `${i + 1} | ${recommendation} | ${description} | ${triggeredBy} | ${affectedNamespaces} namespaces`; - }) - .join("\n"); - - return `# | Recommendation | Description | Triggered By | Affected Namespaces ----|---------------|-------------|----------|------------------- -${rows}`; - } - protected async execute({ projectId, clusterName, @@ -213,7 +103,7 @@ ${rows}`; let totalItems = 0; if (data.suggestedIndexes.length > 0) { - const suggestedIndexesTable = this.formatSuggestedIndexesTable(data.suggestedIndexes); + const suggestedIndexesTable = formatSuggestedIndexesTable(data.suggestedIndexes); formattedOutput += `\n## Suggested Indexes\n${suggestedIndexesTable}\n`; totalItems += data.suggestedIndexes.length; } @@ -223,7 +113,7 @@ ${rows}`; data.dropIndexSuggestions.redundantIndexes.length > 0 || data.dropIndexSuggestions.unusedIndexes.length > 0 ) { - const dropIndexesTable = this.formatDropIndexesTable(data.dropIndexSuggestions); + const dropIndexesTable = formatDropIndexesTable(data.dropIndexSuggestions); formattedOutput += `\n## Drop Index Suggestions\n${dropIndexesTable}\n`; totalItems += data.dropIndexSuggestions.hiddenIndexes.length + @@ -232,13 +122,13 @@ ${rows}`; } if (data.slowQueryLogs.length > 0) { - const slowQueriesTable = this.formatSlowQueriesTable(data.slowQueryLogs); + const slowQueriesTable = formatSlowQueriesTable(data.slowQueryLogs); formattedOutput += `\n## Slow Query Logs\n${slowQueriesTable}\n`; totalItems += data.slowQueryLogs.length; } if (data.schemaSuggestions.length > 0) { - const schemaTable = this.formatSchemaSuggestionsTable(data.schemaSuggestions); + const schemaTable = formatSchemaSuggestionsTable(data.schemaSuggestions); formattedOutput += `\n## Schema Suggestions\n${schemaTable}\n`; totalItems += data.schemaSuggestions.length; } From 5871dc071396849de1af6b527061e773f006a9de Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Fri, 12 Sep 2025 22:05:33 -0400 Subject: [PATCH 08/17] Cleanup naming --- src/common/atlas/performanceAdvisorUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 16d12345d..37282feab 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -283,9 +283,9 @@ export function formatDropIndexesTable(dropIndexSuggestions: { unusedIndexes: Array; }): string { const allIndexes = [ - ...dropIndexSuggestions.hiddenIndexes.map((idx) => ({ ...idx, type: "Hidden" })), - ...dropIndexSuggestions.redundantIndexes.map((idx) => ({ ...idx, type: "Redundant" })), - ...dropIndexSuggestions.unusedIndexes.map((idx) => ({ ...idx, type: "Unused" })), + ...dropIndexSuggestions.hiddenIndexes.map((index) => ({ ...index, type: "Hidden" })), + ...dropIndexSuggestions.redundantIndexes.map((index) => ({ ...index, type: "Redundant" })), + ...dropIndexSuggestions.unusedIndexes.map((index) => ({ ...index, type: "Unused" })), ]; if (allIndexes.length === 0) return "No drop index suggestions found."; From d2c2ee69864e7ece5f19e4062d35d64c391fad91 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Tue, 16 Sep 2025 11:17:36 -0400 Subject: [PATCH 09/17] Draft --- scripts/accuracy/runAccuracyTests.sh | 5 ++ tests/accuracy/listPerformanceAdvisor.test.ts | 64 +++++++++++++++++++ tests/accuracy/sdk/accuracyTestingClient.ts | 18 +++++- tests/accuracy/sdk/describeAccuracyTests.ts | 9 ++- 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/accuracy/listPerformanceAdvisor.test.ts diff --git a/scripts/accuracy/runAccuracyTests.sh b/scripts/accuracy/runAccuracyTests.sh index 312d08a19..55c569026 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/tests/accuracy/listPerformanceAdvisor.test.ts b/tests/accuracy/listPerformanceAdvisor.test.ts new file mode 100644 index 000000000..bfe7f4c6c --- /dev/null +++ b/tests/accuracy/listPerformanceAdvisor.test.ts @@ -0,0 +1,64 @@ +import { PerformanceAdvisorOperation } from "../../src/common/atlas/performanceAdvisorUtils.js"; +import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +describeAccuracyTests([ + { + 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-list-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: [PerformanceAdvisorOperation.SUGGESTED_INDEXES], + }, + }, + ], + mockedTools: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + "atlas-list-performance-advisor": (..._parameters): 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", + }, + ], + }; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + "atlas-list-projects": (..._parameters): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 1 project\n\n# | Name | ID\n---|------|----\n1 | mflix | mflix", + }, + ], + }; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + "atlas-list-clusters": (..._parameters): CallToolResult => { + return { + content: [ + { + type: "text", + text: "Found 1 cluster\n\n# | Name | Type | State\n---|------|------|-----\n1 | mflix-cluster | REPLICASET | IDLE", + }, + ], + }; + }, + }, + }, +]); diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 692694aa7..18fbc26bf 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -79,10 +79,24 @@ 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]; + + // Add Atlas API credentials if provided + if (atlasApiClientId) { + args.push("--apiClientId", atlasApiClientId); + } + if (atlasApiClientSecret) { + args.push("--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 6617a84f7..576fedbd4 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(); }); From ce0466b329bf657dd054db8589cb0ea89d41e503 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Tue, 16 Sep 2025 11:19:02 -0400 Subject: [PATCH 10/17] Remove processId arg --- src/common/atlas/performanceAdvisorUtils.ts | 9 ++------- src/tools/atlas/read/listPerformanceAdvisor.ts | 4 +--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 37282feab..41bebb22a 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -225,21 +225,16 @@ export async function getSlowQueries( projectId: string, clusterName: string, since?: Date, - processId?: string, namespaces?: Array ): Promise<{ slowQueryLogs: Array }> { try { - // If processId is not provided, get it from inspecting the cluster - let actualProcessId = processId; - if (!actualProcessId) { - actualProcessId = await getProcessIdFromCluster(apiClient, projectId, clusterName); - } + const processId = await getProcessIdFromCluster(apiClient, projectId, clusterName); const response = await apiClient.listSlowQueries({ params: { path: { groupId: projectId, - processId: actualProcessId, + processId, }, query: { ...(since && { since: since.getTime() }), diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index d90337af3..ff8b4e468 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -28,7 +28,6 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { .default(Object.values(PerformanceAdvisorOperation)) .describe("Operations to list performance advisor recommendations"), since: z.date().describe("Date to list slow query logs since").optional(), - processId: z.string().describe("Process ID to list slow query logs").optional(), namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), }; @@ -37,7 +36,6 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { clusterName, operations, since, - processId, namespaces, }: ToolArgs): Promise { const data: PerformanceAdvisorData = { @@ -70,7 +68,7 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { if (operations.includes(PerformanceAdvisorOperation.SLOW_QUERY_LOGS)) { performanceAdvisorPromises.push( - getSlowQueries(this.session.apiClient, projectId, clusterName, since, processId, namespaces).then( + getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces).then( ({ slowQueryLogs }) => { data.slowQueryLogs = slowQueryLogs; } From fe2e79a2e447af1673109763fad8daf5d2d64f5a Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Tue, 16 Sep 2025 14:21:11 -0400 Subject: [PATCH 11/17] Accuracy tests for all operations --- .../atlas/read/listPerformanceAdvisor.ts | 1 - tests/accuracy/listPerformanceAdvisor.test.ts | 168 ++++++++++++++---- 2 files changed, 137 insertions(+), 32 deletions(-) diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index ff8b4e468..fe0a9a1aa 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -25,7 +25,6 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { clusterName: z.string().describe("Atlas cluster name to list performance advisor recommendations"), operations: z .array(z.nativeEnum(PerformanceAdvisorOperation)) - .default(Object.values(PerformanceAdvisorOperation)) .describe("Operations to list performance advisor recommendations"), since: z.date().describe("Date to list slow query logs since").optional(), namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), diff --git a/tests/accuracy/listPerformanceAdvisor.test.ts b/tests/accuracy/listPerformanceAdvisor.test.ts index bfe7f4c6c..84a00371b 100644 --- a/tests/accuracy/listPerformanceAdvisor.test.ts +++ b/tests/accuracy/listPerformanceAdvisor.test.ts @@ -2,7 +2,42 @@ import { PerformanceAdvisorOperation } from "../../src/common/atlas/performanceA 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-list-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: [ @@ -25,40 +60,111 @@ describeAccuracyTests([ }, }, ], - mockedTools: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - "atlas-list-performance-advisor": (..._parameters): 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", - }, - ], - }; + 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: {}, }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - "atlas-list-projects": (..._parameters): CallToolResult => { - return { - content: [ - { - type: "text", - text: "Found 1 project\n\n# | Name | ID\n---|------|----\n1 | mflix | mflix", - }, - ], - }; + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-list-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: [PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS], + }, + }, + ], + mockedTools, + }, + // Test for Slow Query Logs operation + { + prompt: "Show me the slow query logs for the 'mflix' project and 'mflix-cluster' cluster?", + expectedToolCalls: [ + { + toolName: "atlas-list-projects", + parameters: {}, + }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-list-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: [PerformanceAdvisorOperation.SLOW_QUERY_LOGS], + }, + }, + ], + 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: {}, }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - "atlas-list-clusters": (..._parameters): CallToolResult => { - return { - content: [ - { - type: "text", - text: "Found 1 cluster\n\n# | Name | Type | State\n---|------|------|-----\n1 | mflix-cluster | REPLICASET | IDLE", - }, + { + toolName: "atlas-list-clusters", + parameters: { + projectId: "mflix", + }, + }, + { + toolName: "atlas-list-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: [PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS], + }, + }, + ], + 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-list-performance-advisor", + parameters: { + projectId: "mflix", + clusterName: "mflix-cluster", + operations: [ + PerformanceAdvisorOperation.SUGGESTED_INDEXES, + PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS, + PerformanceAdvisorOperation.SLOW_QUERY_LOGS, + PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS, ], - }; + }, }, - }, + ], + mockedTools, }, ]); From f0b24bb61893bf4352d8d5bb5419d9055f901917 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Wed, 17 Sep 2025 14:58:49 -0400 Subject: [PATCH 12/17] Address comments --- src/common/atlas/cluster.ts | 3 +- src/common/atlas/performanceAdvisorUtils.ts | 156 +----------------- .../atlas/read/listPerformanceAdvisor.ts | 48 ++---- 3 files changed, 26 insertions(+), 181 deletions(-) diff --git a/src/common/atlas/cluster.ts b/src/common/atlas/cluster.ts index 4d1500931..e542c9816 100644 --- a/src/common/atlas/cluster.ts +++ b/src/common/atlas/cluster.ts @@ -109,8 +109,7 @@ export async function getProcessIdFromCluster( throw new Error("No connection string available for cluster"); } const url = new URL(cluster.connectionString); - const processId = `${url.hostname}:${url.port || DEFAULT_PORT}`; - return processId; + return `${url.hostname}:${url.port || DEFAULT_PORT}`; } catch (error) { throw new Error( `Failed to get processId from cluster: ${error instanceof Error ? error.message : String(error)}` diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 41bebb22a..1627360a4 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -1,22 +1,15 @@ import { LogId } from "../logger.js"; import type { ApiClient } from "./apiClient.js"; import { getProcessIdFromCluster } from "./cluster.js"; +import type { components } from "./openapi.js"; -export enum PerformanceAdvisorOperation { - SUGGESTED_INDEXES = "suggestedIndexes", - DROP_INDEX_SUGGESTIONS = "dropIndexSuggestions", - SLOW_QUERY_LOGS = "slowQueryLogs", - SCHEMA_SUGGESTIONS = "schemaSuggestions", -} +export type SuggestedIndex = components["schemas"]["PerformanceAdvisorIndex"]; -export interface SuggestedIndex { - avgObjSize?: number; - id?: string; - impact?: Array; - index?: Array<{ [key: string]: 1 | -1 }>; - namespace?: string; - weight?: number; -} +export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsIndex"]; + +export type SlowQueryLogMetrics = components["schemas"]["PerformanceAdvisorSlowQueryMetrics"]; + +export type SlowQueryLog = components["schemas"]["PerformanceAdvisorSlowQuery"]; interface SuggestedIndexesResponse { content: { @@ -42,16 +35,6 @@ interface SlowQueriesResponse { slowQueries?: Array; } -export interface DropIndexSuggestion { - accessCount?: number; - index?: Array<{ [key: string]: 1 | -1 }>; - name?: string; - namespace?: string; - shards?: Array; - since?: string; - sizeBytes?: number; -} - export type SchemaTriggerType = | "PERCENT_QUERIES_USE_LOOKUP" | "NUMBER_OF_QUERIES_USE_LOOKUP" @@ -102,28 +85,6 @@ export interface SchemaRecommendation { recommendation?: SchemaRecommedationType; } -interface SlowQueryLogMetrics { - docsExamined?: number; - docsExaminedReturnedRatio?: number; - docsReturned?: number; - fromUserConnection?: boolean; - hasIndexCoverage?: boolean; - hasSort?: boolean; - keysExamined?: number; - keysExaminedReturnedRatio?: number; - numYields?: number; - operationExecutionTime?: number; - responseLength?: number; -} - -export interface SlowQueryLog { - line?: string; - metrics?: SlowQueryLogMetrics; - namespace?: string; - opType?: string; - replicaState?: string; -} - export interface PerformanceAdvisorData { suggestedIndexes: Array; dropIndexSuggestions: { @@ -253,106 +214,3 @@ export async function getSlowQueries( throw new Error(`Failed to list slow query logs: ${err instanceof Error ? err.message : String(err)}`); } } - -export function formatSuggestedIndexesTable(suggestedIndexes: Array): string { - if (suggestedIndexes.length === 0) return "No suggested indexes found."; - - const rows = suggestedIndexes - .map((index, i) => { - const namespace = index.namespace ?? "N/A"; - const weight = index.weight ?? "N/A"; - const avgObjSize = index.avgObjSize ?? "N/A"; - const indexKeys = index.index ? index.index.map((key) => Object.keys(key)[0]).join(", ") : "N/A"; - return `${i + 1} | ${namespace} | ${weight} | ${avgObjSize} | ${indexKeys}`; - }) - .join("\n"); - - return `# | Namespace | Weight | Avg Obj Size | Index Keys ----|-----------|--------|--------------|------------ -${rows}`; -} - -export function formatDropIndexesTable(dropIndexSuggestions: { - hiddenIndexes: Array; - redundantIndexes: Array; - unusedIndexes: Array; -}): string { - const allIndexes = [ - ...dropIndexSuggestions.hiddenIndexes.map((index) => ({ ...index, type: "Hidden" })), - ...dropIndexSuggestions.redundantIndexes.map((index) => ({ ...index, type: "Redundant" })), - ...dropIndexSuggestions.unusedIndexes.map((index) => ({ ...index, type: "Unused" })), - ]; - - if (allIndexes.length === 0) return "No drop index suggestions found."; - - const rows = allIndexes - .map((index, i) => { - const name = index.name ?? "N/A"; - const namespace = index.namespace ?? "N/A"; - const type = index.type ?? "N/A"; - const sizeBytes = index.sizeBytes ?? "N/A"; - const accessCount = index.accessCount ?? "N/A"; - return `${i + 1} | ${name} | ${namespace} | ${type} | ${sizeBytes} | ${accessCount}`; - }) - .join("\n"); - - return `# | Index Name | Namespace | Type | Size (bytes) | Access Count ----|------------|-----------|------|--------------|------------- -${rows}`; -} - -export function formatSlowQueriesTable(slowQueryLogs: Array): string { - if (slowQueryLogs.length === 0) return "No slow query logs found."; - - const rows = slowQueryLogs - .map((log, i) => { - const namespace = log.namespace ?? "N/A"; - const opType = log.opType ?? "N/A"; - const executionTime = log.metrics?.operationExecutionTime ?? "N/A"; - const docsExamined = log.metrics?.docsExamined ?? "N/A"; - const docsReturned = log.metrics?.docsReturned ?? "N/A"; - return `${i + 1} | ${namespace} | ${opType} | ${executionTime}ms | ${docsExamined} | ${docsReturned}`; - }) - .join("\n"); - - return `# | Namespace | Operation | Execution Time | Docs Examined | Docs Returned ----|-----------|-----------|---------------|---------------|--------------- -${rows}`; -} - -function getTriggerDescription(triggerType: SchemaTriggerType | undefined): string { - if (!triggerType) return "N/A"; - return SCHEMA_TRIGGER_DESCRIPTIONS[triggerType] ?? triggerType; -} - -function getNamespaceTriggerDescriptions(namespace: { triggers?: Array<{ triggerType?: SchemaTriggerType }> }): string { - if (!namespace.triggers) return "N/A"; - - return namespace.triggers.map((trigger) => getTriggerDescription(trigger.triggerType)).join(", "); -} - -function getTriggerDescriptions(suggestion: SchemaRecommendation): string { - if (!suggestion.affectedNamespaces) return "N/A"; - - return suggestion.affectedNamespaces.map((namespace) => getNamespaceTriggerDescriptions(namespace)).join(", "); -} - -export function formatSchemaSuggestionsTable(schemaSuggestions: Array): string { - if (schemaSuggestions.length === 0) return "No schema suggestions found."; - - const rows = schemaSuggestions - .map((suggestion: SchemaRecommendation, i) => { - const recommendation = suggestion.recommendation - ? (SCHEMA_RECOMMENDATION_DESCRIPTIONS[suggestion.recommendation] ?? suggestion.recommendation) - : "N/A"; - const description = suggestion.description ?? "N/A"; - const triggeredBy = getTriggerDescriptions(suggestion); - const affectedNamespaces = suggestion.affectedNamespaces?.length ?? 0; - return `${i + 1} | ${recommendation} | ${description} | ${triggeredBy} | ${affectedNamespaces} namespaces`; - }) - .join("\n"); - - return `# | Recommendation | Description | Triggered By | Affected Namespaces ----|---------------|-------------|----------|------------------- -${rows}`; -} diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index ff8b4e468..e6c241105 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -8,14 +8,15 @@ import { getDropIndexSuggestions, getSchemaAdvice, getSlowQueries, - PerformanceAdvisorOperation, type PerformanceAdvisorData, - formatSuggestedIndexesTable, - formatDropIndexesTable, - formatSlowQueriesTable, - formatSchemaSuggestionsTable, } from "../../../common/atlas/performanceAdvisorUtils.js"; +const PerformanceAdvisorOperationType = z.enum([ + "suggestedIndexes", + "dropIndexSuggestions", + "slowQueryLogs", + "schemaSuggestions", +]); export class ListPerformanceAdvisorTool extends AtlasToolBase { public name = "atlas-list-performance-advisor"; protected description = "List MongoDB Atlas performance advisor recommendations"; @@ -24,8 +25,8 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { projectId: z.string().describe("Atlas project ID to list performance advisor recommendations"), clusterName: z.string().describe("Atlas cluster name to list performance advisor recommendations"), operations: z - .array(z.nativeEnum(PerformanceAdvisorOperation)) - .default(Object.values(PerformanceAdvisorOperation)) + .array(PerformanceAdvisorOperationType) + .default(PerformanceAdvisorOperationType.options) .describe("Operations to list performance advisor recommendations"), since: z.date().describe("Date to list slow query logs since").optional(), namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), @@ -48,7 +49,7 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { try { const performanceAdvisorPromises = []; - if (operations.includes(PerformanceAdvisorOperation.SUGGESTED_INDEXES)) { + if (operations.includes("suggestedIndexes")) { performanceAdvisorPromises.push( getSuggestedIndexes(this.session.apiClient, projectId, clusterName).then(({ suggestedIndexes }) => { data.suggestedIndexes = suggestedIndexes; @@ -56,7 +57,7 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { ); } - if (operations.includes(PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS)) { + if (operations.includes("dropIndexSuggestions")) { performanceAdvisorPromises.push( getDropIndexSuggestions(this.session.apiClient, projectId, clusterName).then( ({ hiddenIndexes, redundantIndexes, unusedIndexes }) => { @@ -66,7 +67,7 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { ); } - if (operations.includes(PerformanceAdvisorOperation.SLOW_QUERY_LOGS)) { + if (operations.includes("slowQueryLogs")) { performanceAdvisorPromises.push( getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces).then( ({ slowQueryLogs }) => { @@ -76,7 +77,7 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { ); } - if (operations.includes(PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS)) { + if (operations.includes("schemaSuggestions")) { performanceAdvisorPromises.push( getSchemaAdvice(this.session.apiClient, projectId, clusterName).then(({ recommendations }) => { data.schemaSuggestions = recommendations; @@ -96,14 +97,10 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { }; } - // Format the data as tables let formattedOutput = ""; - let totalItems = 0; if (data.suggestedIndexes.length > 0) { - const suggestedIndexesTable = formatSuggestedIndexesTable(data.suggestedIndexes); - formattedOutput += `\n## Suggested Indexes\n${suggestedIndexesTable}\n`; - totalItems += data.suggestedIndexes.length; + formattedOutput += `\n## Suggested Indexes\n${JSON.stringify(data.suggestedIndexes)}\n`; } if ( @@ -111,34 +108,25 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { data.dropIndexSuggestions.redundantIndexes.length > 0 || data.dropIndexSuggestions.unusedIndexes.length > 0 ) { - const dropIndexesTable = formatDropIndexesTable(data.dropIndexSuggestions); - formattedOutput += `\n## Drop Index Suggestions\n${dropIndexesTable}\n`; - totalItems += - data.dropIndexSuggestions.hiddenIndexes.length + - data.dropIndexSuggestions.redundantIndexes.length + - data.dropIndexSuggestions.unusedIndexes.length; + formattedOutput += `\n## Drop Index Suggestions\n${JSON.stringify(data.dropIndexSuggestions)}\n`; } if (data.slowQueryLogs.length > 0) { - const slowQueriesTable = formatSlowQueriesTable(data.slowQueryLogs); - formattedOutput += `\n## Slow Query Logs\n${slowQueriesTable}\n`; - totalItems += data.slowQueryLogs.length; + formattedOutput += `\n## Slow Query Logs\n${JSON.stringify(data.slowQueryLogs)}\n`; } if (data.schemaSuggestions.length > 0) { - const schemaTable = formatSchemaSuggestionsTable(data.schemaSuggestions); - formattedOutput += `\n## Schema Suggestions\n${schemaTable}\n`; - totalItems += data.schemaSuggestions.length; + formattedOutput += `\n## Schema Suggestions\n${JSON.stringify(data.schemaSuggestions)}\n`; } - if (totalItems === 0) { + if (formattedOutput === "") { return { content: [{ type: "text", text: "No performance advisor recommendations found." }], }; } return { - content: formatUntrustedData(`Found ${totalItems} performance advisor recommendations`, formattedOutput), + content: formatUntrustedData("Performance advisor data", formattedOutput), }; } } From 3217e95fab2197b3631b64262d814fe7dc604b4a Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Thu, 18 Sep 2025 10:55:52 -0400 Subject: [PATCH 13/17] Typing --- src/common/atlas/performanceAdvisorUtils.ts | 87 ++----------------- .../atlas/read/listPerformanceAdvisor.ts | 40 ++++++--- 2 files changed, 32 insertions(+), 95 deletions(-) diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index 1627360a4..c9efa6489 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -4,97 +4,20 @@ import { getProcessIdFromCluster } from "./cluster.js"; import type { components } from "./openapi.js"; export type SuggestedIndex = components["schemas"]["PerformanceAdvisorIndex"]; - export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsIndex"]; - export type SlowQueryLogMetrics = components["schemas"]["PerformanceAdvisorSlowQueryMetrics"]; - export type SlowQueryLog = components["schemas"]["PerformanceAdvisorSlowQuery"]; interface SuggestedIndexesResponse { - content: { - suggestedIndexes?: Array; - }; + content: components["schemas"]["PerformanceAdvisorResponse"]; } - interface DropIndexesResponse { - content: { - hiddenIndexes?: Array; - redundantIndexes?: Array; - unusedIndexes?: Array; - }; + content: components["schemas"]["DropIndexSuggestionsResponse"]; } - interface SchemaAdviceResponse { - content: { - recommendations?: Array; - }; -} - -interface SlowQueriesResponse { - slowQueries?: Array; -} - -export type SchemaTriggerType = - | "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"; - -export const SCHEMA_TRIGGER_DESCRIPTIONS: Record = { - PERCENT_QUERIES_USE_LOOKUP: "High percentage of queries (>50%) use $lookup operations", - NUMBER_OF_QUERIES_USE_LOOKUP: "High number of queries (>100) use $lookup operations", - DOCS_CONTAIN_UNBOUNDED_ARRAY: "Arrays with over 10000 entries detected in the collection(s)", - NUMBER_OF_NAMESPACES: "Too many namespaces (collections) in the database (>100)", - DOC_SIZE_TOO_LARGE: "Documents larger than 2 MB found in the collection(s)", - NUM_INDEXES: "More than 30 indexes detected in the collection(s) scanned", - QUERIES_CONTAIN_CASE_INSENSITIVE_REGEX: "Queries use case-insensitive regular expressions", -}; - -type SchemaRecommedationType = - | "REDUCE_LOOKUP_OPS" - | "AVOID_UNBOUNDED_ARRAY" - | "REDUCE_DOCUMENT_SIZE" - | "REMOVE_UNNECESSARY_INDEXES" - | "REDUCE_NUMBER_OF_NAMESPACES" - | "OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES" - | "OPTIMIZE_TEXT_QUERIES"; - -export const SCHEMA_RECOMMENDATION_DESCRIPTIONS: Record = { - REDUCE_LOOKUP_OPS: "Reduce the use of $lookup operations", - AVOID_UNBOUNDED_ARRAY: "Avoid using unbounded arrays in documents", - REDUCE_DOCUMENT_SIZE: "Reduce the size of documents", - REMOVE_UNNECESSARY_INDEXES: "Remove unnecessary indexes", - REDUCE_NUMBER_OF_NAMESPACES: "Reduce the number of collections in the database", - OPTIMIZE_CASE_INSENSITIVE_REGEX_QUERIES: "Optimize case-insensitive regex queries", - OPTIMIZE_TEXT_QUERIES: "Optimize text search queries", -}; - -export interface SchemaRecommendation { - affectedNamespaces?: Array<{ - namespace?: string | null; - triggers?: Array<{ - description?: string; - triggerType?: SchemaTriggerType; - }>; - }>; - description?: string; - recommendation?: SchemaRecommedationType; -} - -export interface PerformanceAdvisorData { - suggestedIndexes: Array; - dropIndexSuggestions: { - hiddenIndexes: Array; - redundantIndexes: Array; - unusedIndexes: Array; - }; - slowQueryLogs: Array; - schemaSuggestions: Array; + content: components["schemas"]["SchemaAdvisorResponse"]; } +export type SchemaRecommendation = components["schemas"]["SchemaAdvisorItemRecommendation"]; export async function getSuggestedIndexes( apiClient: ApiClient, @@ -204,7 +127,7 @@ export async function getSlowQueries( }, }); - return { slowQueryLogs: (response as SlowQueriesResponse).slowQueries ?? [] }; + return { slowQueryLogs: response.slowQueries ?? [] }; } catch (err) { apiClient.logger.debug({ id: LogId.atlasPaSlowQueryLogsFailure, diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index e6c241105..5f4aa6941 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -8,7 +8,10 @@ import { getDropIndexSuggestions, getSchemaAdvice, getSlowQueries, - type PerformanceAdvisorData, + type SuggestedIndex, + type DropIndexSuggestion, + type SlowQueryLog, + type SchemaRecommendation, } from "../../../common/atlas/performanceAdvisorUtils.js"; const PerformanceAdvisorOperationType = z.enum([ @@ -17,9 +20,21 @@ const PerformanceAdvisorOperationType = z.enum([ "slowQueryLogs", "schemaSuggestions", ]); + +interface PerformanceAdvisorData { + suggestedIndexes?: Array; + dropIndexSuggestions?: { + hiddenIndexes?: Array; + redundantIndexes?: Array; + unusedIndexes?: Array; + }; + slowQueryLogs?: Array; + schemaSuggestions?: Array; +} export class ListPerformanceAdvisorTool extends AtlasToolBase { public name = "atlas-list-performance-advisor"; - protected description = "List MongoDB Atlas performance advisor recommendations"; + protected description = + "List MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, slow query logs, and schema suggestions"; public operationType: OperationType = "read"; protected argsShape = { projectId: z.string().describe("Atlas project ID to list performance advisor recommendations"), @@ -29,7 +44,10 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { .default(PerformanceAdvisorOperationType.options) .describe("Operations to list performance advisor recommendations"), since: z.date().describe("Date to list slow query logs since").optional(), - namespaces: z.array(z.string()).describe("Namespaces to list slow query logs").optional(), + namespaces: z + .array(z.string()) + .describe("Namespaces to list slow query logs. Only relevant for the slowQueryLogs operation.") + .optional(), }; protected async execute({ @@ -60,8 +78,8 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { if (operations.includes("dropIndexSuggestions")) { performanceAdvisorPromises.push( getDropIndexSuggestions(this.session.apiClient, projectId, clusterName).then( - ({ hiddenIndexes, redundantIndexes, unusedIndexes }) => { - data.dropIndexSuggestions = { hiddenIndexes, redundantIndexes, unusedIndexes }; + (dropIndexSuggestions) => { + data.dropIndexSuggestions = dropIndexSuggestions; } ) ); @@ -99,23 +117,19 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { let formattedOutput = ""; - if (data.suggestedIndexes.length > 0) { + if (data.suggestedIndexes && data.suggestedIndexes.length > 0) { formattedOutput += `\n## Suggested Indexes\n${JSON.stringify(data.suggestedIndexes)}\n`; } - if ( - data.dropIndexSuggestions.hiddenIndexes.length > 0 || - data.dropIndexSuggestions.redundantIndexes.length > 0 || - data.dropIndexSuggestions.unusedIndexes.length > 0 - ) { + if (data.dropIndexSuggestions) { formattedOutput += `\n## Drop Index Suggestions\n${JSON.stringify(data.dropIndexSuggestions)}\n`; } - if (data.slowQueryLogs.length > 0) { + if (data.slowQueryLogs && data.slowQueryLogs.length > 0) { formattedOutput += `\n## Slow Query Logs\n${JSON.stringify(data.slowQueryLogs)}\n`; } - if (data.schemaSuggestions.length > 0) { + if (data.schemaSuggestions && data.schemaSuggestions.length > 0) { formattedOutput += `\n## Schema Suggestions\n${JSON.stringify(data.schemaSuggestions)}\n`; } From ea41c0c0726d1c9e57e9f20e6864e1032d95ef5e Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Thu, 18 Sep 2025 13:11:42 -0400 Subject: [PATCH 14/17] Add args to slow query test --- .../atlas/read/listPerformanceAdvisor.ts | 1 + tests/accuracy/listPerformanceAdvisor.test.ts | 19 +++++++------------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/tools/atlas/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts index 7fb3d9f14..5f4aa6941 100644 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ b/src/tools/atlas/read/listPerformanceAdvisor.ts @@ -41,6 +41,7 @@ export class ListPerformanceAdvisorTool extends AtlasToolBase { clusterName: z.string().describe("Atlas cluster name to list performance advisor recommendations"), operations: z .array(PerformanceAdvisorOperationType) + .default(PerformanceAdvisorOperationType.options) .describe("Operations to list performance advisor recommendations"), since: z.date().describe("Date to list slow query logs since").optional(), namespaces: z diff --git a/tests/accuracy/listPerformanceAdvisor.test.ts b/tests/accuracy/listPerformanceAdvisor.test.ts index 84a00371b..905245688 100644 --- a/tests/accuracy/listPerformanceAdvisor.test.ts +++ b/tests/accuracy/listPerformanceAdvisor.test.ts @@ -1,4 +1,3 @@ -import { PerformanceAdvisorOperation } from "../../src/common/atlas/performanceAdvisorUtils.js"; import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; @@ -56,7 +55,7 @@ describeAccuracyTests([ parameters: { projectId: "mflix", clusterName: "mflix-cluster", - operations: [PerformanceAdvisorOperation.SUGGESTED_INDEXES], + operations: ["suggestedIndexes"], }, }, ], @@ -81,7 +80,7 @@ describeAccuracyTests([ parameters: { projectId: "mflix", clusterName: "mflix-cluster", - operations: [PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS], + operations: ["dropIndexSuggestions"], }, }, ], @@ -89,7 +88,7 @@ describeAccuracyTests([ }, // Test for Slow Query Logs operation { - prompt: "Show me the slow query logs for the 'mflix' project and 'mflix-cluster' cluster?", + 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", @@ -106,7 +105,9 @@ describeAccuracyTests([ parameters: { projectId: "mflix", clusterName: "mflix-cluster", - operations: [PerformanceAdvisorOperation.SLOW_QUERY_LOGS], + operations: ["slowQueryLogs"], + namespaces: ["mflix.movies", "mflix.shows"], + since: "2025-01-01T00:00:00Z", }, }, ], @@ -131,7 +132,7 @@ describeAccuracyTests([ parameters: { projectId: "mflix", clusterName: "mflix-cluster", - operations: [PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS], + operations: ["schemaSuggestions"], }, }, ], @@ -156,12 +157,6 @@ describeAccuracyTests([ parameters: { projectId: "mflix", clusterName: "mflix-cluster", - operations: [ - PerformanceAdvisorOperation.SUGGESTED_INDEXES, - PerformanceAdvisorOperation.DROP_INDEX_SUGGESTIONS, - PerformanceAdvisorOperation.SLOW_QUERY_LOGS, - PerformanceAdvisorOperation.SCHEMA_SUGGESTIONS, - ], }, }, ], From 016af0e16fd06f2dfd8d6da76eb5c72b5f714bf8 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Thu, 18 Sep 2025 14:37:15 -0400 Subject: [PATCH 15/17] Clean up PA retrieval code --- src/tools/atlas/read/getPerformanceAdvisor.ts | 98 ++++++++++++ .../atlas/read/listPerformanceAdvisor.ts | 146 ------------------ src/tools/atlas/tools.ts | 4 +- 3 files changed, 100 insertions(+), 148 deletions(-) create mode 100644 src/tools/atlas/read/getPerformanceAdvisor.ts delete mode 100644 src/tools/atlas/read/listPerformanceAdvisor.ts diff --git a/src/tools/atlas/read/getPerformanceAdvisor.ts b/src/tools/atlas/read/getPerformanceAdvisor.ts new file mode 100644 index 000000000..09843d5b7 --- /dev/null +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -0,0 +1,98 @@ +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, +} from "../../../common/atlas/performanceAdvisorUtils.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, slow query logs, and schema suggestions"; + public operationType: OperationType = "read"; + protected argsShape = { + projectId: z.string().describe("Atlas project ID to get performance advisor recommendations"), + clusterName: z.string().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.date().describe("Date to get slow query logs since").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.all([ + operations.includes("suggestedIndexes") + ? getSuggestedIndexes(this.session.apiClient, projectId, clusterName) + : { suggestedIndexes: [] }, + operations.includes("dropIndexSuggestions") + ? getDropIndexSuggestions(this.session.apiClient, projectId, clusterName) + : { hiddenIndexes: [], redundantIndexes: [], unusedIndexes: [] }, + operations.includes("slowQueryLogs") + ? getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces) + : { slowQueryLogs: [] }, + operations.includes("schemaSuggestions") + ? getSchemaAdvice(this.session.apiClient, projectId, clusterName) + : { recommendations: [] }, + ]); + + const performanceAdvisorData = [ + suggestedIndexesResult?.suggestedIndexes?.length > 0 + ? `## Suggested Indexes\n${JSON.stringify(suggestedIndexesResult.suggestedIndexes)}` + : "No suggested indexes found.", + dropIndexSuggestionsResult + ? `## Drop Index Suggestions\n${JSON.stringify(dropIndexSuggestionsResult)}` + : "No drop index suggestions found.", + slowQueryLogsResult?.slowQueryLogs?.length > 0 + ? `## Slow Query Logs\n${JSON.stringify(slowQueryLogsResult.slowQueryLogs)}` + : "No slow query logs found.", + schemaSuggestionsResult?.recommendations?.length > 0 + ? `## Schema Suggestions\n${JSON.stringify(schemaSuggestionsResult.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/read/listPerformanceAdvisor.ts b/src/tools/atlas/read/listPerformanceAdvisor.ts deleted file mode 100644 index 5f4aa6941..000000000 --- a/src/tools/atlas/read/listPerformanceAdvisor.ts +++ /dev/null @@ -1,146 +0,0 @@ -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, - type SuggestedIndex, - type DropIndexSuggestion, - type SlowQueryLog, - type SchemaRecommendation, -} from "../../../common/atlas/performanceAdvisorUtils.js"; - -const PerformanceAdvisorOperationType = z.enum([ - "suggestedIndexes", - "dropIndexSuggestions", - "slowQueryLogs", - "schemaSuggestions", -]); - -interface PerformanceAdvisorData { - suggestedIndexes?: Array; - dropIndexSuggestions?: { - hiddenIndexes?: Array; - redundantIndexes?: Array; - unusedIndexes?: Array; - }; - slowQueryLogs?: Array; - schemaSuggestions?: Array; -} -export class ListPerformanceAdvisorTool extends AtlasToolBase { - public name = "atlas-list-performance-advisor"; - protected description = - "List MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, slow query logs, and schema suggestions"; - public operationType: OperationType = "read"; - protected argsShape = { - projectId: z.string().describe("Atlas project ID to list performance advisor recommendations"), - clusterName: z.string().describe("Atlas cluster name to list performance advisor recommendations"), - operations: z - .array(PerformanceAdvisorOperationType) - .default(PerformanceAdvisorOperationType.options) - .describe("Operations to list performance advisor recommendations"), - since: z.date().describe("Date to list slow query logs since").optional(), - namespaces: z - .array(z.string()) - .describe("Namespaces to list slow query logs. Only relevant for the slowQueryLogs operation.") - .optional(), - }; - - protected async execute({ - projectId, - clusterName, - operations, - since, - namespaces, - }: ToolArgs): Promise { - const data: PerformanceAdvisorData = { - suggestedIndexes: [], - dropIndexSuggestions: { hiddenIndexes: [], redundantIndexes: [], unusedIndexes: [] }, - slowQueryLogs: [], - schemaSuggestions: [], - }; - - try { - const performanceAdvisorPromises = []; - - if (operations.includes("suggestedIndexes")) { - performanceAdvisorPromises.push( - getSuggestedIndexes(this.session.apiClient, projectId, clusterName).then(({ suggestedIndexes }) => { - data.suggestedIndexes = suggestedIndexes; - }) - ); - } - - if (operations.includes("dropIndexSuggestions")) { - performanceAdvisorPromises.push( - getDropIndexSuggestions(this.session.apiClient, projectId, clusterName).then( - (dropIndexSuggestions) => { - data.dropIndexSuggestions = dropIndexSuggestions; - } - ) - ); - } - - if (operations.includes("slowQueryLogs")) { - performanceAdvisorPromises.push( - getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces).then( - ({ slowQueryLogs }) => { - data.slowQueryLogs = slowQueryLogs; - } - ) - ); - } - - if (operations.includes("schemaSuggestions")) { - performanceAdvisorPromises.push( - getSchemaAdvice(this.session.apiClient, projectId, clusterName).then(({ recommendations }) => { - data.schemaSuggestions = recommendations; - }) - ); - } - - await Promise.all(performanceAdvisorPromises); - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error retrieving performance advisor data: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } - - let formattedOutput = ""; - - if (data.suggestedIndexes && data.suggestedIndexes.length > 0) { - formattedOutput += `\n## Suggested Indexes\n${JSON.stringify(data.suggestedIndexes)}\n`; - } - - if (data.dropIndexSuggestions) { - formattedOutput += `\n## Drop Index Suggestions\n${JSON.stringify(data.dropIndexSuggestions)}\n`; - } - - if (data.slowQueryLogs && data.slowQueryLogs.length > 0) { - formattedOutput += `\n## Slow Query Logs\n${JSON.stringify(data.slowQueryLogs)}\n`; - } - - if (data.schemaSuggestions && data.schemaSuggestions.length > 0) { - formattedOutput += `\n## Schema Suggestions\n${JSON.stringify(data.schemaSuggestions)}\n`; - } - - if (formattedOutput === "") { - return { - content: [{ type: "text", text: "No performance advisor recommendations found." }], - }; - } - - return { - content: formatUntrustedData("Performance advisor data", formattedOutput), - }; - } -} diff --git a/src/tools/atlas/tools.ts b/src/tools/atlas/tools.ts index a488660a7..c2822ec55 100644 --- a/src/tools/atlas/tools.ts +++ b/src/tools/atlas/tools.ts @@ -10,7 +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 { ListPerformanceAdvisorTool } from "./read/listPerformanceAdvisor.js"; +import { GetPerformanceAdvisorTool } from "./read/getPerformanceAdvisor.js"; export const AtlasTools = [ ListClustersTool, @@ -25,5 +25,5 @@ export const AtlasTools = [ ListOrganizationsTool, ConnectClusterTool, ListAlertsTool, - ListPerformanceAdvisorTool, + GetPerformanceAdvisorTool, ]; From 4645d07047a8843b5ffd4faee4e45fa127d93f76 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Thu, 18 Sep 2025 20:30:23 -0400 Subject: [PATCH 16/17] Clean up nits --- src/tools/atlas/read/getPerformanceAdvisor.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/tools/atlas/read/getPerformanceAdvisor.ts b/src/tools/atlas/read/getPerformanceAdvisor.ts index 09843d5b7..bd2ebf4a2 100644 --- a/src/tools/atlas/read/getPerformanceAdvisor.ts +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -9,6 +9,7 @@ import { getSchemaAdvice, getSlowQueries, } from "../../../common/atlas/performanceAdvisorUtils.js"; +import { AtlasArgs } from "../../args.js"; const PerformanceAdvisorOperationType = z.enum([ "suggestedIndexes", @@ -23,13 +24,16 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase { "Get MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, slow query logs, and schema suggestions"; public operationType: OperationType = "read"; protected argsShape = { - projectId: z.string().describe("Atlas project ID to get performance advisor recommendations"), - clusterName: z.string().describe("Atlas cluster name to get performance advisor recommendations"), + 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.date().describe("Date to get slow query logs since").optional(), + since: z + .date() + .describe("Date to get slow query logs since. 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.") @@ -48,29 +52,29 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase { await Promise.all([ operations.includes("suggestedIndexes") ? getSuggestedIndexes(this.session.apiClient, projectId, clusterName) - : { suggestedIndexes: [] }, + : undefined, operations.includes("dropIndexSuggestions") ? getDropIndexSuggestions(this.session.apiClient, projectId, clusterName) - : { hiddenIndexes: [], redundantIndexes: [], unusedIndexes: [] }, + : undefined, operations.includes("slowQueryLogs") ? getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces) - : { slowQueryLogs: [] }, + : undefined, operations.includes("schemaSuggestions") ? getSchemaAdvice(this.session.apiClient, projectId, clusterName) - : { recommendations: [] }, + : undefined, ]); const performanceAdvisorData = [ - suggestedIndexesResult?.suggestedIndexes?.length > 0 + suggestedIndexesResult && suggestedIndexesResult?.suggestedIndexes?.length > 0 ? `## Suggested Indexes\n${JSON.stringify(suggestedIndexesResult.suggestedIndexes)}` : "No suggested indexes found.", dropIndexSuggestionsResult ? `## Drop Index Suggestions\n${JSON.stringify(dropIndexSuggestionsResult)}` : "No drop index suggestions found.", - slowQueryLogsResult?.slowQueryLogs?.length > 0 + slowQueryLogsResult && slowQueryLogsResult?.slowQueryLogs?.length > 0 ? `## Slow Query Logs\n${JSON.stringify(slowQueryLogsResult.slowQueryLogs)}` : "No slow query logs found.", - schemaSuggestionsResult?.recommendations?.length > 0 + schemaSuggestionsResult && schemaSuggestionsResult?.recommendations?.length > 0 ? `## Schema Suggestions\n${JSON.stringify(schemaSuggestionsResult.recommendations)}` : "No schema suggestions found.", ]; From 80bc769d38eb740a1b21e7839ebf0b30fa0565e1 Mon Sep 17 00:00:00 2001 From: Kyle W Lai Date: Fri, 19 Sep 2025 12:50:32 -0400 Subject: [PATCH 17/17] More concise logic for adding api id/secret --- tests/accuracy/sdk/accuracyTestingClient.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 18fbc26bf..130e8fb05 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -84,15 +84,13 @@ export class AccuracyTestingClient { atlasApiClientId?: string, atlasApiClientSecret?: string ): Promise { - const args = [MCP_SERVER_CLI_SCRIPT, "--connectionString", mdbConnectionString]; - - // Add Atlas API credentials if provided - if (atlasApiClientId) { - args.push("--apiClientId", atlasApiClientId); - } - if (atlasApiClientSecret) { - args.push("--apiClientSecret", atlasApiClientSecret); - } + const args = [ + MCP_SERVER_CLI_SCRIPT, + "--connectionString", + mdbConnectionString, + ...(atlasApiClientId ? ["--apiClientId", atlasApiClientId] : []), + ...(atlasApiClientSecret ? ["--apiClientSecret", atlasApiClientSecret] : []), + ]; const clientTransport = new StdioClientTransport({ command: process.execPath,