diff --git a/src/common/atlas/cluster.ts b/src/common/atlas/cluster.ts index e542c981..1ea30286 100644 --- a/src/common/atlas/cluster.ts +++ b/src/common/atlas/cluster.ts @@ -1,8 +1,17 @@ import type { ClusterDescription20240805, FlexClusterDescription20241113 } from "./openapi.js"; import type { ApiClient } from "./apiClient.js"; import { LogId } from "../logger.js"; +import { ConnectionString } from "mongodb-connection-string-url"; -const DEFAULT_PORT = "27017"; +type AtlasProcessId = `${string}:${number}`; + +function extractProcessIds(connectionString: string): Array { + if (!connectionString) { + return []; + } + const connectionStringUrl = new ConnectionString(connectionString); + return connectionStringUrl.hosts as Array; +} export interface Cluster { name?: string; instanceType: "FREE" | "DEDICATED" | "FLEX"; @@ -10,16 +19,19 @@ export interface Cluster { state?: "IDLE" | "CREATING" | "UPDATING" | "DELETING" | "REPAIRING"; mongoDBVersion?: string; connectionString?: string; + processIds?: Array; } export function formatFlexCluster(cluster: FlexClusterDescription20241113): Cluster { + const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard; return { name: cluster.name, instanceType: "FLEX", instanceSize: undefined, state: cluster.stateName, mongoDBVersion: cluster.mongoDBVersion, - connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard, + connectionString, + processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""), }; } @@ -53,6 +65,7 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster { const instanceSize = regionConfigs[0]?.instanceSize ?? "UNKNOWN"; const clusterInstanceType = instanceSize === "M0" ? "FREE" : "DEDICATED"; + const connectionString = cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard; return { name: cluster.name, @@ -60,7 +73,8 @@ export function formatCluster(cluster: ClusterDescription20240805): Cluster { instanceSize: clusterInstanceType === "DEDICATED" ? instanceSize : undefined, state: cluster.stateName, mongoDBVersion: cluster.mongoDBVersion, - connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard, + connectionString, + processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""), }; } @@ -98,21 +112,17 @@ export async function inspectCluster(apiClient: ApiClient, projectId: string, cl } } -export async function getProcessIdFromCluster( +export async function getProcessIdsFromCluster( apiClient: ApiClient, projectId: string, clusterName: string -): Promise { +): Promise> { try { const cluster = await inspectCluster(apiClient, projectId, clusterName); - if (!cluster.connectionString) { - throw new Error("No connection string available for cluster"); - } - const url = new URL(cluster.connectionString); - return `${url.hostname}:${url.port || DEFAULT_PORT}`; + return cluster.processIds || []; } catch (error) { throw new Error( - `Failed to get processId from cluster: ${error instanceof Error ? error.message : String(error)}` + `Failed to get processIds from cluster: ${error instanceof Error ? error.message : String(error)}` ); } } diff --git a/src/common/atlas/performanceAdvisorUtils.ts b/src/common/atlas/performanceAdvisorUtils.ts index c9efa648..af393c28 100644 --- a/src/common/atlas/performanceAdvisorUtils.ts +++ b/src/common/atlas/performanceAdvisorUtils.ts @@ -1,6 +1,6 @@ import { LogId } from "../logger.js"; import type { ApiClient } from "./apiClient.js"; -import { getProcessIdFromCluster } from "./cluster.js"; +import { getProcessIdsFromCluster } from "./cluster.js"; import type { components } from "./openapi.js"; export type SuggestedIndex = components["schemas"]["PerformanceAdvisorIndex"]; @@ -8,6 +8,8 @@ export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsInd export type SlowQueryLogMetrics = components["schemas"]["PerformanceAdvisorSlowQueryMetrics"]; export type SlowQueryLog = components["schemas"]["PerformanceAdvisorSlowQuery"]; +export const DEFAULT_SLOW_QUERY_LOGS_LIMIT = 50; + interface SuggestedIndexesResponse { content: components["schemas"]["PerformanceAdvisorResponse"]; } @@ -112,22 +114,35 @@ export async function getSlowQueries( namespaces?: Array ): Promise<{ slowQueryLogs: Array }> { try { - const processId = await getProcessIdFromCluster(apiClient, projectId, clusterName); + const processIds = await getProcessIdsFromCluster(apiClient, projectId, clusterName); - const response = await apiClient.listSlowQueries({ - params: { - path: { - groupId: projectId, - processId, - }, - query: { - ...(since && { since: since.getTime() }), - ...(namespaces && { namespaces: namespaces }), + if (processIds.length === 0) { + return { slowQueryLogs: [] }; + } + + const slowQueryPromises = processIds.map((processId) => + apiClient.listSlowQueries({ + params: { + path: { + groupId: projectId, + processId, + }, + query: { + ...(since && { since: since.getTime() }), + ...(namespaces && { namespaces: namespaces }), + nLogs: DEFAULT_SLOW_QUERY_LOGS_LIMIT, + }, }, - }, - }); + }) + ); + + const responses = await Promise.allSettled(slowQueryPromises); + + const allSlowQueryLogs = responses.reduce((acc, response) => { + return acc.concat(response.status === "fulfilled" ? (response.value.slowQueries ?? []) : []); + }, [] as Array); - return { slowQueryLogs: response.slowQueries ?? [] }; + return { slowQueryLogs: allSlowQueryLogs }; } catch (err) { apiClient.logger.debug({ id: LogId.atlasPaSlowQueryLogsFailure, diff --git a/src/tools/atlas/atlasTool.ts b/src/tools/atlas/atlasTool.ts index b68eeafd..8d8914d6 100644 --- a/src/tools/atlas/atlasTool.ts +++ b/src/tools/atlas/atlasTool.ts @@ -38,6 +38,21 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs }; } + if (statusCode === 402) { + return { + content: [ + { + type: "text", + text: `Received a Payment Required API Error: ${error.message} + +Payment setup is required to perform this action in MongoDB Atlas. +Please ensure that your payment method for your organization has been set up and is active. +For more information on setting up payment, visit: https://www.mongodb.com/docs/atlas/billing/`, + }, + ], + }; + } + if (statusCode === 403) { return { content: [ @@ -45,7 +60,7 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs type: "text", text: `Received a Forbidden API Error: ${error.message} -You don't have sufficient permissions to perform this action in MongoDB Atlas +You don't have sufficient permissions to perform this action in MongoDB Atlas. Please ensure your API key has the necessary roles assigned. For more information on Atlas API access roles, visit: https://www.mongodb.com/docs/atlas/api/service-accounts-overview/`, }, diff --git a/src/tools/atlas/read/getPerformanceAdvisor.ts b/src/tools/atlas/read/getPerformanceAdvisor.ts index 512712f5..f3c89049 100644 --- a/src/tools/atlas/read/getPerformanceAdvisor.ts +++ b/src/tools/atlas/read/getPerformanceAdvisor.ts @@ -8,6 +8,7 @@ import { getDropIndexSuggestions, getSchemaAdvice, getSlowQueries, + DEFAULT_SLOW_QUERY_LOGS_LIMIT, } from "../../../common/atlas/performanceAdvisorUtils.js"; import { AtlasArgs } from "../../args.js"; @@ -20,8 +21,7 @@ const PerformanceAdvisorOperationType = z.enum([ 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"; + protected description = `Get MongoDB Atlas performance advisor recommendations, which includes the operations: suggested indexes, drop index suggestions, schema suggestions, and a sample of the most recent (max ${DEFAULT_SLOW_QUERY_LOGS_LIMIT}) slow query logs`; public operationType: OperationType = "read"; protected argsShape = { projectId: AtlasArgs.projectId().describe("Atlas project ID to get performance advisor recommendations"), @@ -31,8 +31,11 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase { .default(PerformanceAdvisorOperationType.options) .describe("Operations to get performance advisor recommendations"), since: z - .date() - .describe("Date to get slow query logs since. Only relevant for the slowQueryLogs operation.") + .string() + .datetime() + .describe( + "Date to get slow query logs since. Must be a string in ISO 8601 format. Only relevant for the slowQueryLogs operation." + ) .optional(), namespaces: z .array(z.string()) @@ -49,7 +52,7 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase { }: ToolArgs): Promise { try { const [suggestedIndexesResult, dropIndexSuggestionsResult, slowQueryLogsResult, schemaSuggestionsResult] = - await Promise.all([ + await Promise.allSettled([ operations.includes("suggestedIndexes") ? getSuggestedIndexes(this.session.apiClient, projectId, clusterName) : Promise.resolve(undefined), @@ -57,26 +60,50 @@ export class GetPerformanceAdvisorTool extends AtlasToolBase { ? getDropIndexSuggestions(this.session.apiClient, projectId, clusterName) : Promise.resolve(undefined), operations.includes("slowQueryLogs") - ? getSlowQueries(this.session.apiClient, projectId, clusterName, since, namespaces) + ? getSlowQueries( + this.session.apiClient, + projectId, + clusterName, + since ? new Date(since) : undefined, + namespaces + ) : Promise.resolve(undefined), operations.includes("schemaSuggestions") ? getSchemaAdvice(this.session.apiClient, projectId, clusterName) : Promise.resolve(undefined), ]); + const hasSuggestedIndexes = + suggestedIndexesResult.status === "fulfilled" && + suggestedIndexesResult.value?.suggestedIndexes && + suggestedIndexesResult.value.suggestedIndexes.length > 0; + const hasDropIndexSuggestions = + dropIndexSuggestionsResult.status === "fulfilled" && + dropIndexSuggestionsResult.value?.hiddenIndexes && + dropIndexSuggestionsResult.value?.redundantIndexes && + dropIndexSuggestionsResult.value?.unusedIndexes && + dropIndexSuggestionsResult.value.hiddenIndexes.length > 0 && + dropIndexSuggestionsResult.value.redundantIndexes.length > 0 && + dropIndexSuggestionsResult.value.unusedIndexes.length > 0; + const hasSlowQueryLogs = + slowQueryLogsResult.status === "fulfilled" && + slowQueryLogsResult.value?.slowQueryLogs && + slowQueryLogsResult.value.slowQueryLogs.length > 0; + const hasSchemaSuggestions = + schemaSuggestionsResult.status === "fulfilled" && + schemaSuggestionsResult.value?.recommendations && + schemaSuggestionsResult.value.recommendations.length > 0; + + // Inserts the performance advisor data with the relevant section header if it exists const performanceAdvisorData = [ - 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 && slowQueryLogsResult?.slowQueryLogs?.length > 0 - ? `## Slow Query Logs\n${JSON.stringify(slowQueryLogsResult.slowQueryLogs)}` - : "No slow query logs found.", - schemaSuggestionsResult && schemaSuggestionsResult?.recommendations?.length > 0 - ? `## Schema Suggestions\n${JSON.stringify(schemaSuggestionsResult.recommendations)}` - : "No schema suggestions found.", + `## Suggested Indexes\n${ + hasSuggestedIndexes + ? `Note: The "Weight" field is measured in bytes, and represents the estimated number of bytes saved in disk reads per executed read query that would be saved by implementing an index suggestion. Please convert this to MB or GB for easier readability.\n${JSON.stringify(suggestedIndexesResult.value?.suggestedIndexes)}` + : "No suggested indexes found." + }`, + `## Drop Index Suggestions\n${hasDropIndexSuggestions ? JSON.stringify(dropIndexSuggestionsResult.value) : "No drop index suggestions found."}`, + `## Slow Query Logs\n${hasSlowQueryLogs ? JSON.stringify(slowQueryLogsResult.value?.slowQueryLogs) : "No slow query logs found."}`, + `## Schema Suggestions\n${hasSchemaSuggestions ? JSON.stringify(schemaSuggestionsResult.value?.recommendations) : "No schema suggestions found."}`, ]; if (performanceAdvisorData.length === 0) { diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 0a2ccfe8..2c846dd8 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -41,6 +41,9 @@ export const defaultDriverOptions: DriverOptions = { ...driverOptions, }; +// Timeout in milliseconds for long running tests: defaults to 20 minutes +export const DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS = 1_200_000; + export function setupIntegrationTest( getUserConfig: () => UserConfig, getDriverOptions: () => DriverOptions, diff --git a/tests/integration/tools/atlas/atlasHelpers.ts b/tests/integration/tools/atlas/atlasHelpers.ts index 00ac53fe..0522e33d 100644 --- a/tests/integration/tools/atlas/atlasHelpers.ts +++ b/tests/integration/tools/atlas/atlasHelpers.ts @@ -1,10 +1,11 @@ import { ObjectId } from "mongodb"; -import type { Group } from "../../../../src/common/atlas/openapi.js"; +import type { ClusterDescription20240805, Group } from "../../../../src/common/atlas/openapi.js"; import type { ApiClient } from "../../../../src/common/atlas/apiClient.js"; import type { IntegrationTest } from "../../helpers.js"; import { setupIntegrationTest, defaultTestConfig, defaultDriverOptions } from "../../helpers.js"; import type { SuiteCollector } from "vitest"; import { afterAll, beforeAll, describe } from "vitest"; +import type { Session } from "../../../../src/common/session.js"; export type IntegrationTestFunction = (integration: IntegrationTest) => void; @@ -143,3 +144,83 @@ async function createProject(apiClient: ApiClient): Promise>; } + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function assertClusterIsAvailable( + session: Session, + projectId: string, + clusterName: string +): Promise { + try { + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + return true; + } catch { + return false; + } +} + +export async function deleteAndWaitCluster( + session: Session, + projectId: string, + clusterName: string, + pollingInterval: number = 1000, + maxPollingIterations: number = 300 +): Promise { + await session.apiClient.deleteCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + + for (let i = 0; i < maxPollingIterations; i++) { + const isAvailable = await assertClusterIsAvailable(session, projectId, clusterName); + if (!isAvailable) { + return; + } + await sleep(pollingInterval); + } + throw new Error( + `Cluster deletion timeout: ${clusterName} did not delete within ${maxPollingIterations} iterations` + ); +} + +export async function waitCluster( + session: Session, + projectId: string, + clusterName: string, + check: (cluster: ClusterDescription20240805) => boolean | Promise, + pollingInterval: number = 1000, + maxPollingIterations: number = 300 +): Promise { + for (let i = 0; i < maxPollingIterations; i++) { + const cluster = await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + if (await check(cluster)) { + return; + } + await sleep(pollingInterval); + } + + throw new Error( + `Cluster wait timeout: ${clusterName} did not meet condition within ${maxPollingIterations} iterations` + ); +} diff --git a/tests/integration/tools/atlas/clusters.test.ts b/tests/integration/tools/atlas/clusters.test.ts index 5c50c570..59234c47 100644 --- a/tests/integration/tools/atlas/clusters.test.ts +++ b/tests/integration/tools/atlas/clusters.test.ts @@ -1,61 +1,16 @@ import type { Session } from "../../../../src/common/session.js"; import { expectDefined, getDataFromUntrustedContent, getResponseElements } from "../../helpers.js"; -import { describeWithAtlas, withProject, randomId, parseTable } from "./atlasHelpers.js"; -import type { ClusterDescription20240805 } from "../../../../src/common/atlas/openapi.js"; +import { + describeWithAtlas, + withProject, + randomId, + parseTable, + deleteAndWaitCluster, + waitCluster, + sleep, +} from "./atlasHelpers.js"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function deleteAndWaitCluster(session: Session, projectId: string, clusterName: string): Promise { - await session.apiClient.deleteCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - while (true) { - try { - await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - await sleep(1000); - } catch { - break; - } - } -} - -async function waitCluster( - session: Session, - projectId: string, - clusterName: string, - check: (cluster: ClusterDescription20240805) => boolean | Promise -): Promise { - while (true) { - const cluster = await session.apiClient.getCluster({ - params: { - path: { - groupId: projectId, - clusterName, - }, - }, - }); - if (await check(cluster)) { - return; - } - await sleep(1000); - } -} - describeWithAtlas("clusters", (integration) => { withProject(integration, ({ getProjectId, getIpAddress }) => { const clusterName = "ClusterTest-" + randomId; diff --git a/tests/integration/tools/atlas/performanceAdvisor.test.ts b/tests/integration/tools/atlas/performanceAdvisor.test.ts new file mode 100644 index 00000000..764797ab --- /dev/null +++ b/tests/integration/tools/atlas/performanceAdvisor.test.ts @@ -0,0 +1,231 @@ +// This test file includes long running tests (>10 minutes) because we provision a real M10 cluster, which can take up to 10 minutes to provision. +// The timeouts for the beforeAll/afterAll hooks have been modified to account for longer running tests. + +import type { Session } from "../../../../src/common/session.js"; +import { DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS, expectDefined, getResponseElements } from "../../helpers.js"; +import { describeWithAtlas, withProject, randomId, deleteAndWaitCluster, waitCluster } from "./atlasHelpers.js"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +describeWithAtlas("performanceAdvisor", (integration) => { + withProject(integration, ({ getProjectId }) => { + const clusterName = "ClusterTest-" + randomId; + + afterAll(async () => { + const projectId = getProjectId(); + if (projectId) { + const session: Session = integration.mcpServer().session; + await deleteAndWaitCluster(session, projectId, clusterName, 1000, 1200); + } + }, DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS); + + describe("atlas-get-performance-advisor", () => { + beforeAll(async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + await session.apiClient.createCluster({ + params: { + path: { + groupId: projectId, + }, + }, + body: { + name: clusterName, + clusterType: "REPLICASET", + backupEnabled: true, + configServerManagementMode: "ATLAS_MANAGED", + diskWarmingMode: "FULLY_WARMED", + replicaSetScalingStrategy: "WORKLOAD_TYPE", + rootCertType: "ISRGROOTX1", + terminationProtectionEnabled: false, + versionReleaseSystem: "LTS", + replicationSpecs: [ + { + zoneName: "Zone 1", + regionConfigs: [ + { + providerName: "AWS", + regionName: "US_EAST_1", + electableSpecs: { instanceSize: "M10", nodeCount: 3 }, + priority: 7, + }, + ], + }, + ], + }, + }); + + await waitCluster( + session, + projectId, + clusterName, + (cluster) => { + return cluster.stateName === "IDLE"; + }, + 10000, + 120 + ); + }, DEFAULT_LONG_RUNNING_TEST_WAIT_TIMEOUT_MS); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should have correct metadata", async () => { + const { tools } = await integration.mcpClient().listTools(); + const getPerformanceAdvisor = tools.find((tool) => tool.name === "atlas-get-performance-advisor"); + expectDefined(getPerformanceAdvisor); + expect(getPerformanceAdvisor.inputSchema.type).toBe("object"); + expectDefined(getPerformanceAdvisor.inputSchema.properties); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("projectId"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("clusterName"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("operations"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("since"); + expect(getPerformanceAdvisor.inputSchema.properties).toHaveProperty("namespaces"); + }); + + it("returns performance advisor data from a paid tier cluster", async () => { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + await session.apiClient.getCluster({ + params: { + path: { + groupId: projectId, + clusterName, + }, + }, + }); + + const response = await integration.mcpClient().callTool({ + name: "atlas-get-performance-advisor", + arguments: { + projectId, + clusterName, + operations: ["suggestedIndexes", "dropIndexSuggestions", "schemaSuggestions"], + }, + }); + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toContain("Performance advisor data"); + expect(elements[1]?.text).toContain(" { + const projectId = getProjectId(); + const session = integration.mcpServer().session; + + // Mock the API client methods since we can't guarantee performance advisor data + const mockSuggestedIndexes = vi.fn().mockResolvedValue({ + content: { + suggestedIndexes: [ + { + namespace: "testdb.testcollection", + index: { field: 1 }, + impact: ["queryShapeString"], + }, + ], + }, + }); + + const mockDropIndexSuggestions = vi.fn().mockResolvedValue({ + content: { + hiddenIndexes: [], + redundantIndexes: [ + { + accessCount: 100, + namespace: "testdb.testcollection", + index: { field: 1 }, + reason: "Redundant with compound index", + }, + ], + unusedIndexes: [], + }, + }); + + const mockSchemaAdvice = vi.fn().mockResolvedValue({ + content: { + recommendations: [ + { + description: "Consider adding an index on 'status' field", + recommendation: "REDUCE_LOOKUP_OPS", + affectedNamespaces: [ + { + namespace: "testdb.testcollection", + triggers: [ + { + triggerType: "PERCENT_QUERIES_USE_LOOKUP", + details: + "Queries filtering by status field are causing collection scans", + }, + ], + }, + ], + }, + ], + }, + }); + + const mockSlowQueries = vi.fn().mockResolvedValue({ + slowQueries: [ + { + namespace: "testdb.testcollection", + query: { find: "testcollection", filter: { status: "active" } }, + duration: 1500, + timestamp: "2024-01-15T10:30:00Z", + }, + ], + }); + + const mockGetCluster = vi.fn().mockResolvedValue({ + connectionStrings: { + standard: "mongodb://test-cluster.mongodb.net:27017", + }, + }); + + session.apiClient.listClusterSuggestedIndexes = mockSuggestedIndexes; + session.apiClient.listDropIndexes = mockDropIndexSuggestions; + session.apiClient.listSchemaAdvice = mockSchemaAdvice; + session.apiClient.listSlowQueries = mockSlowQueries; + session.apiClient.getCluster = mockGetCluster; + + const response = await integration.mcpClient().callTool({ + name: "atlas-get-performance-advisor", + arguments: { + projectId, + clusterName: "mockClusterName", + operations: ["suggestedIndexes", "dropIndexSuggestions", "slowQueryLogs", "schemaSuggestions"], + }, + }); + + if (response.isError) { + console.error("Performance advisor call failed:", response.content); + throw new Error("Performance advisor call failed - see console for details"); + } + + const elements = getResponseElements(response.content); + expect(elements).toHaveLength(2); + + expect(elements[0]?.text).toContain("Performance advisor data"); + expect(elements[1]?.text).toContain("