Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bd69450
Add base atlas performance advisor MCP server tool
kylelai1 Sep 4, 2025
f331bac
Cleanup comments
kylelai1 Sep 4, 2025
78f9888
Merge main
kylelai1 Sep 4, 2025
2f023eb
Fix API return type
kylelai1 Sep 5, 2025
a4cbc8b
Clean up getting slow query logs from atlas admin api
kylelai1 Sep 8, 2025
2ef0ded
Fix types for performance advisor api response
kylelai1 Sep 8, 2025
923263e
Address comments
kylelai1 Sep 13, 2025
8272719
Move utils to util file
kylelai1 Sep 13, 2025
5871dc0
Cleanup naming
kylelai1 Sep 13, 2025
ce0466b
Remove processId arg
kylelai1 Sep 16, 2025
f0b24bb
Address comments
kylelai1 Sep 17, 2025
3217e95
Typing
kylelai1 Sep 18, 2025
016af0e
Clean up PA retrieval code
kylelai1 Sep 18, 2025
a2fbe34
Merge branch 'main' into atlas-list-performance-advisor-base-tool
kylelai1 Sep 19, 2025
4645d07
Clean up nits
kylelai1 Sep 19, 2025
d6b7db8
Merge branch 'atlas-list-performance-advisor-tool' into atlas-list-pe…
kylelai1 Sep 19, 2025
9dbdca1
Use promise.resolve for an undefined value in a promise.all
kylelai1 Sep 19, 2025
5da6e8d
Add tests
kylelai1 Sep 23, 2025
63a94e1
merge base branch
kylelai1 Sep 23, 2025
93d9757
Retrieve process ID from standard connection string
kylelai1 Sep 24, 2025
4869b78
Cleanup
kylelai1 Sep 24, 2025
53c262b
fix type error
kylelai1 Sep 24, 2025
095256c
Type fixes
kylelai1 Sep 24, 2025
9d56da0
Address comments
kylelai1 Sep 25, 2025
400bff1
handle 402 error for payment
kylelai1 Sep 25, 2025
35b695d
Cleanup
kylelai1 Sep 26, 2025
3e73b21
Limit max num of slow queries returned by the tool
kylelai1 Sep 29, 2025
3efe310
Use promise.allSettled for slow query promises
kylelai1 Sep 30, 2025
3b7112d
Set sampled slow query logs limit to 50
kylelai1 Sep 30, 2025
f5c1d8d
Product feedback
kylelai1 Oct 1, 2025
7b4922f
Fix since param
kylelai1 Oct 1, 2025
8352d18
Use promise.allSettled
kylelai1 Oct 1, 2025
26ccdfc
Address comments
kylelai1 Oct 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions src/common/atlas/cluster.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
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<AtlasProcessId> {
if (!connectionString) {
return [];
}
const connectionStringUrl = new ConnectionString(connectionString);
return connectionStringUrl.hosts as Array<AtlasProcessId>;
}
export interface Cluster {
name?: string;
instanceType: "FREE" | "DEDICATED" | "FLEX";
instanceSize?: string;
state?: "IDLE" | "CREATING" | "UPDATING" | "DELETING" | "REPAIRING";
mongoDBVersion?: string;
connectionString?: string;
processIds?: Array<string>;
}

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 ?? ""),
};
}

Expand Down Expand Up @@ -53,14 +65,16 @@ 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,
instanceType: clusterInstanceType,
instanceSize: clusterInstanceType === "DEDICATED" ? instanceSize : undefined,
state: cluster.stateName,
mongoDBVersion: cluster.mongoDBVersion,
connectionString: cluster.connectionStrings?.standardSrv || cluster.connectionStrings?.standard,
connectionString,
processIds: extractProcessIds(cluster.connectionStrings?.standard ?? ""),
};
}

Expand Down Expand Up @@ -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<string> {
): Promise<Array<string>> {
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)}`
);
}
}
43 changes: 29 additions & 14 deletions src/common/atlas/performanceAdvisorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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"];
export type DropIndexSuggestion = components["schemas"]["DropIndexSuggestionsIndex"];
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"];
}
Expand Down Expand Up @@ -112,22 +114,35 @@ export async function getSlowQueries(
namespaces?: Array<string>
): Promise<{ slowQueryLogs: Array<SlowQueryLog> }> {
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<SlowQueryLog>);

return { slowQueryLogs: response.slowQueries ?? [] };
return { slowQueryLogs: allSlowQueryLogs };
} catch (err) {
apiClient.logger.debug({
id: LogId.atlasPaSlowQueryLogsFailure,
Expand Down
17 changes: 16 additions & 1 deletion src/tools/atlas/atlasTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,29 @@ For more information on setting up API keys, visit: https://www.mongodb.com/docs
};
}

if (statusCode === 402) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we getting 402 when a customer tries to access PA on a free/shared tier cluster?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we're getting a 402 for the integration tests when they're run with an org that has no payment set up, since an M10 is created for the new integration test. PA on a free/shared tier cluster will be a 200.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So are users likely to ever get the 402 response?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one thing to add here is probably elicitation is needed for all these tools since they can add cost to the user, here's some context bcbf889

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

users are not likely to get the 402 response. This is purely for the integration tests.

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: [
{
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/`,
},
Expand Down
50 changes: 33 additions & 17 deletions src/tools/atlas/read/getPerformanceAdvisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getDropIndexSuggestions,
getSchemaAdvice,
getSlowQueries,
DEFAULT_SLOW_QUERY_LOGS_LIMIT,
} from "../../../common/atlas/performanceAdvisorUtils.js";
import { AtlasArgs } from "../../args.js";

Expand All @@ -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"),
Expand All @@ -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())
Expand All @@ -57,26 +60,39 @@ 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 && suggestedIndexesResult?.suggestedIndexes?.length > 0;
const hasDropIndexSuggestions =
dropIndexSuggestionsResult &&
dropIndexSuggestionsResult?.hiddenIndexes?.length > 0 &&
dropIndexSuggestionsResult?.redundantIndexes?.length > 0 &&
dropIndexSuggestionsResult?.unusedIndexes?.length > 0;
const hasSlowQueryLogs = slowQueryLogsResult && slowQueryLogsResult?.slowQueryLogs?.length > 0;
const hasSchemaSuggestions =
schemaSuggestionsResult && schemaSuggestionsResult?.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.suggestedIndexes)}`
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is included from product feedback to make it apparent to the LLM that the "weight" field is in bytes, and gives the definition to relay to the LLM

: "No suggested indexes found."
}`,
`## Drop Index Suggestions\n${hasDropIndexSuggestions ? JSON.stringify(dropIndexSuggestionsResult) : "No drop index suggestions found."}`,
`## Slow Query Logs\n${hasSlowQueryLogs ? JSON.stringify(slowQueryLogsResult.slowQueryLogs) : "No slow query logs found."}`,
`## Schema Suggestions\n${hasSchemaSuggestions ? JSON.stringify(schemaSuggestionsResult.recommendations) : "No schema suggestions found."}`,
];

if (performanceAdvisorData.length === 0) {
Expand Down
83 changes: 82 additions & 1 deletion tests/integration/tools/atlas/atlasHelpers.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -143,3 +144,83 @@ async function createProject(apiClient: ApiClient): Promise<Group & Required<Pic

return group as Group & Required<Pick<Group, "id">>;
}

export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function assertClusterIsAvailable(
session: Session,
projectId: string,
clusterName: string
): Promise<boolean> {
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<void> {
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<boolean>,
pollingInterval: number = 1000,
maxPollingIterations: number = 300
): Promise<void> {
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`
);
}
Loading
Loading