Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 7 additions & 10 deletions src/tools/atlas/read/inspectAccessList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,14 @@ export class InspectAccessListTool extends AtlasToolBase {
};
}

const entries = results.map((entry) => ({
ipAddress: entry.ipAddress,
cidrBlock: entry.cidrBlock,
comment: entry.comment,
}));

return {
content: formatUntrustedData(
`Found ${results.length} access list entries`,
`IP ADDRESS | CIDR | COMMENT
------|------|------
${results
.map((entry) => {
return `${entry.ipAddress} | ${entry.cidrBlock} | ${entry.comment}`;
})
.join("\n")}`
),
content: formatUntrustedData(`Found ${results.length} access list entries`, JSON.stringify(entries)),
};
}
}
16 changes: 10 additions & 6 deletions src/tools/atlas/read/inspectCluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ export class InspectClusterTool extends AtlasToolBase {
}

private formatOutput(formattedCluster: Cluster): CallToolResult {
const clusterDetails = {
name: formattedCluster.name || "Unknown",
Copy link
Member

Choose a reason for hiding this comment

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

In other commands we return null/undefined, could we do that for all tools?

Copy link
Collaborator

@gagik gagik Oct 31, 2025

Choose a reason for hiding this comment

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

That's an interesting point. having these human-readable make it easier to parse from both human and LLM perspective I'd think. If there's both null and undefined cases which mean different things then it's worth keeping or handling them differently but otherwise I don't have strong feelings about it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ack, I'll keep "N/A" like the previous implementation everywhere then! thanks for flagging

Copy link
Collaborator

@fmenezes fmenezes Nov 3, 2025

Choose a reason for hiding this comment

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

for context on the initial atlas tools I've added N/A as it was a better fit for markdown tables, for json I personally think null would make more sense (or omitting the field)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think I can group this feedback and structuredContent into upcoming changes and get rid of the tables for now, so i'll merge

instanceType: formattedCluster.instanceType,
instanceSize: formattedCluster.instanceSize || "N/A",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not sure if this is supposed to be a number but if yes then you might wanna use ?? instead of || because the latter works on any falsy values, including 0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

thanks for flagging but it's text

state: formattedCluster.state || "UNKNOWN",
mongoDBVersion: formattedCluster.mongoDBVersion || "N/A",
connectionStrings: formattedCluster.connectionStrings || {},
};

return {
content: formatUntrustedData(
"Cluster details:",
`Cluster Name | Cluster Type | Tier | State | MongoDB Version | Connection String
----------------|----------------|----------------|----------------|----------------|----------------
${formattedCluster.name || "Unknown"} | ${formattedCluster.instanceType} | ${formattedCluster.instanceSize || "N/A"} | ${formattedCluster.state || "UNKNOWN"} | ${formattedCluster.mongoDBVersion || "N/A"} | ${formattedCluster.connectionStrings?.standardSrv || formattedCluster.connectionStrings?.standard || "N/A"}`
),
content: formatUntrustedData("Cluster details:", JSON.stringify(clusterDetails)),
};
}
}
26 changes: 12 additions & 14 deletions src/tools/atlas/read/listAlerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,20 @@ export class ListAlertsTool extends AtlasToolBase {
return { content: [{ type: "text", text: "No alerts found in your MongoDB Atlas project." }] };
}

// Format alerts as a table
const output =
`Alert ID | Status | Created | Updated | Type | Comment
----------|---------|----------|----------|------|--------
` +
data.results
.map((alert) => {
const created = alert.created ? new Date(alert.created).toLocaleString() : "N/A";
const updated = alert.updated ? new Date(alert.updated).toLocaleString() : "N/A";
const comment = alert.acknowledgementComment ?? "N/A";
return `${alert.id} | ${alert.status} | ${created} | ${updated} | ${alert.eventTypeName} | ${comment}`;
})
.join("\n");
const alerts = data.results.map((alert) => ({
id: alert.id,
status: alert.status,
created: alert.created ? new Date(alert.created).toISOString() : "N/A",
updated: alert.updated ? new Date(alert.updated).toISOString() : "N/A",
eventTypeName: alert.eventTypeName,
acknowledgementComment: alert.acknowledgementComment ?? "N/A",
Copy link
Member

Choose a reason for hiding this comment

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

Same remark, can we avoid mixing null and N/A?

}));

return {
content: formatUntrustedData(`Found ${data.results.length} alerts in project ${projectId}`, output),
content: formatUntrustedData(
`Found ${data.results.length} alerts in project ${projectId}`,
JSON.stringify(alerts)
),
};
}
}
35 changes: 12 additions & 23 deletions src/tools/atlas/read/listClusters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,28 +59,22 @@ export class ListClustersTool extends AtlasToolBase {
}
const formattedClusters = clusters.results
.map((result) => {
return (result.clusters || []).map((cluster) => {
return { ...result, ...cluster, clusters: undefined };
});
return (result.clusters || []).map((cluster) => ({
projectName: result.groupName,
projectId: result.groupId,
clusterName: cluster.name,
}));
})
.flat();
if (!formattedClusters.length) {
throw new Error("No clusters found.");
}
const rows = formattedClusters
.map((cluster) => {
return `${cluster.groupName} (${cluster.groupId}) | ${cluster.name}`;
})
.join("\n");

return {
content: [
{
type: "text",
text: `Project | Cluster Name
----------------|----------------
${rows}`,
},
],
content: formatUntrustedData(
`Found ${formattedClusters.length} clusters across all projects`,
JSON.stringify(formattedClusters)
),
};
}

Expand All @@ -98,16 +92,11 @@ ${rows}`,
const formattedClusters = clusters?.results?.map((cluster) => formatCluster(cluster)) || [];
const formattedFlexClusters = flexClusters?.results?.map((cluster) => formatFlexCluster(cluster)) || [];
const allClusters = [...formattedClusters, ...formattedFlexClusters];

return {
content: formatUntrustedData(
`Found ${allClusters.length} clusters in project "${project.name}" (${project.id}):`,
`Cluster Name | Cluster Type | Tier | State | MongoDB Version | Connection String
----------------|----------------|----------------|----------------|----------------|----------------
${allClusters
.map((formattedCluster) => {
return `${formattedCluster.name || "Unknown"} | ${formattedCluster.instanceType} | ${formattedCluster.instanceSize || "N/A"} | ${formattedCluster.state || "UNKNOWN"} | ${formattedCluster.mongoDBVersion || "N/A"} | ${formattedCluster.connectionStrings?.standardSrv || formattedCluster.connectionStrings?.standard || "N/A"}`;
})
.join("\n")}`
JSON.stringify(allClusters)
),
};
}
Expand Down
49 changes: 19 additions & 30 deletions src/tools/atlas/read/listDBUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { AtlasToolBase } from "../atlasTool.js";
import type { ToolArgs, OperationType } from "../../tool.js";
import { formatUntrustedData } from "../../tool.js";
import type { DatabaseUserRole, UserScope } from "../../../common/atlas/openapi.js";
import { AtlasArgs } from "../../args.js";

export const ListDBUsersArgs = {
Expand Down Expand Up @@ -32,36 +31,26 @@ export class ListDBUsersTool extends AtlasToolBase {
};
}

const output =
`Username | Roles | Scopes
----------------|----------------|----------------
` +
data.results
.map((user) => {
return `${user.username} | ${formatRoles(user.roles)} | ${formatScopes(user.scopes)}`;
})
.join("\n");
const users = data.results.map((user) => ({
username: user.username,
roles:
user.roles?.map((role) => ({
roleName: role.roleName,
databaseName: role.databaseName,
collectionName: role.collectionName,
})) ?? [],
scopes:
user.scopes?.map((scope) => ({
type: scope.type,
name: scope.name,
})) ?? [],
}));

return {
content: formatUntrustedData(`Found ${data.results.length} database users in project ${projectId}`, output),
content: formatUntrustedData(
`Found ${data.results.length} database users in project ${projectId}`,
JSON.stringify(users)
),
};
}
}

function formatRoles(roles?: DatabaseUserRole[]): string {
if (!roles?.length) {
return "N/A";
}
return roles
.map(
(role) =>
`${role.roleName}${role.databaseName ? `@${role.databaseName}${role.collectionName ? `:${role.collectionName}` : ""}` : ""}`
)
.join(", ");
}

function formatScopes(scopes?: UserScope[]): string {
if (!scopes?.length) {
return "All";
}
return scopes.map((scope) => `${scope.type}:${scope.name}`).join(", ");
}
17 changes: 6 additions & 11 deletions src/tools/atlas/read/listOrgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,15 @@ export class ListOrganizationsTool extends AtlasToolBase {
};
}

// Format organizations as a table
const output =
`Organization Name | Organization ID
----------------| ----------------
` +
data.results
.map((org) => {
return `${org.name} | ${org.id}`;
})
.join("\n");
const orgs = data.results.map((org) => ({
name: org.name,
id: org.id,
}));

return {
content: formatUntrustedData(
`Found ${data.results.length} organizations in your MongoDB Atlas account.`,
output
JSON.stringify(orgs)
),
};
}
Expand Down
28 changes: 11 additions & 17 deletions tests/integration/tools/atlas/alerts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expectDefined, getResponseElements } from "../../helpers.js";
import { parseTable, describeWithAtlas, withProject } from "./atlasHelpers.js";
import { expectDefined, getResponseContent } from "../../helpers.js";
import { describeWithAtlas, withProject } from "./atlasHelpers.js";
import { expect, it } from "vitest";

describeWithAtlas("atlas-list-alerts", (integration) => {
Expand All @@ -13,26 +13,20 @@ describeWithAtlas("atlas-list-alerts", (integration) => {
});

withProject(integration, ({ getProjectId }) => {
it("returns alerts in table format", async () => {
it("returns alerts in JSON format", async () => {
const response = await integration.mcpClient().callTool({
name: "atlas-list-alerts",
arguments: { projectId: getProjectId() },
});

const elements = getResponseElements(response.content);
expect(elements).toHaveLength(1);

const data = parseTable(elements[0]?.text ?? "");

// Since we can't guarantee alerts will exist, we just verify the table structure
if (data.length > 0) {
const alert = data[0];
expect(alert).toHaveProperty("Alert ID");
expect(alert).toHaveProperty("Status");
expect(alert).toHaveProperty("Created");
expect(alert).toHaveProperty("Updated");
expect(alert).toHaveProperty("Type");
expect(alert).toHaveProperty("Comment");
const content = getResponseContent(response.content);
// check that there are alerts or no alerts
if (content.includes("Found alerts in project")) {
expect(content).toContain("<untrusted-user-data-");
// expect projectId in the content
expect(content).toContain(getProjectId());
} else {
expect(content).toContain("No alerts found");
}
});
});
Expand Down
20 changes: 0 additions & 20 deletions tests/integration/tools/atlas/atlasHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,26 +101,6 @@ export function withProject(integration: IntegrationTest, fn: ProjectTestFunctio
});
}

export function parseTable(text: string): Record<string, string>[] {
const data = text
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => line.split("|").map((cell) => cell.trim()));

const headers = data[0];
return data
.filter((_, index) => index >= 2)
.map((cells) => {
const row: Record<string, string> = {};
cells.forEach((cell, index) => {
if (headers) {
row[headers[index] ?? ""] = cell;
}
});
return row;
});
}

export const randomId = new ObjectId().toString();

async function createProject(apiClient: ApiClient): Promise<Group & Required<Pick<Group, "id">>> {
Expand Down
Loading
Loading