Skip to content

feat: add support for managing search indexes #181

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
92 changes: 92 additions & 0 deletions src/tools/mongodb/create/createSearchIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";

export class CreateSearchIndexTool extends MongoDBToolBase {
protected name = "create-search-index";
protected description = "Create an Atlas Search index for a collection";
protected argsShape = {
...DbOperationArgs,
name: z.string().optional().describe("The name of the index"),
type: z.enum(["search", "vectorSearch"]).optional().default("search").describe("The type of the index"),
analyzer: z
.string()
.optional()
.default("lucene.standard")
.describe(
"The analyzer to use for the index. Can be one of the built-in lucene analyzers (`lucene.standard`, `lucene.simple`, `lucene.whitespace`, `lucene.keyword`), a language-specific analyzer, such as `lucene.cjk` or `lucene.czech`, or a custom analyzer defined in the Atlas UI."
),
mappings: z.object({
dynamic: z
.boolean()
.optional()
.default(false)
.describe(
"Enables or disables dynamic mapping of fields for this index. If set to true, Atlas Search recursively indexes all dynamically indexable fields. If set to false, you must specify individual fields to index using mappings.fields."
),
fields: z
.record(
z.string().describe("The field name"),
z
.object({
type: z
.enum([
"autocomplete",
"boolean",
"date",
"document",
"embeddedDocuments",
"geo",
"knnVector",
"number",
"objectId",
"string",
"token",
"uuid",
])
.describe("The field type"),
})
.passthrough()

.describe(
"The field index definition. It must contain the field type, as well as any additional options for that field type."
)
)
.optional()
.describe("The field mapping definitions. If `dynamic` is set to false, this is required."),
}),
};

protected operationType: OperationType = "create";

protected async execute({
database,
collection,
name,
type,
analyzer,
mappings,
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();
const indexes = await provider.createSearchIndexes(database, collection, [
{
name,
type,
definition: {
analyzer,
mappings,
},
},
]);

return {
content: [
{
text: `Created the index "${indexes[0]}" on collection "${collection}" in database "${database}"`,
type: "text",
},
],
};
}
}
57 changes: 57 additions & 0 deletions src/tools/mongodb/delete/dropIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { z } from "zod";
import { MongoServerError } from "mongodb";

export class DropIndexTool extends MongoDBToolBase {
protected name = "drop-index";
protected description = "Removes an index from a collection.";
protected argsShape = {
...DbOperationArgs,
name: z.string().describe("The name of the index to drop"),
};
protected operationType: OperationType = "delete";

protected async execute({ database, collection, name }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();
await provider.mongoClient.db(database).collection(collection).dropIndex(name);
try {
await provider.dropSearchIndex(database, collection, name);
} catch (error) {
if (error instanceof MongoServerError && error.codeName === "SearchNotEnabled") {
// If search is not enabled (e.g. due to connecting to a non-Atlas cluster), we can ignore the error
// and return an empty array for search indexes.
} else {
throw error;
}
}

return {
content: [
{
text: `Successfully dropped index "${name}" in "${database}.${collection}"`,
type: "text",
},
],
};
}

protected handleError(
error: unknown,
args: ToolArgs<typeof this.argsShape>
): Promise<CallToolResult> | CallToolResult {
if (error instanceof Error && "codeName" in error && error.codeName === "NamespaceNotFound") {
return {
content: [
{
text: `Cannot drop index "${args.name}" because the namespace "${args.database}.${args.collection}" does not exist.`,
type: "text",
},
],
};
}

return super.handleError(error, args);
}
}
29 changes: 25 additions & 4 deletions src/tools/mongodb/read/collectionIndexes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { Document } from "bson";
import { MongoServerError } from "mongodb";

export class CollectionIndexesTool extends MongoDBToolBase {
protected name = "collection-indexes";
Expand All @@ -12,18 +14,37 @@ export class CollectionIndexesTool extends MongoDBToolBase {
const provider = await this.ensureConnected();
const indexes = await provider.getIndexes(database, collection);

let searchIndexes: Document[];
try {
searchIndexes = await provider.getSearchIndexes(database, collection);
} catch (error) {
if (error instanceof MongoServerError && error.codeName === "SearchNotEnabled") {
// If search is not enabled (e.g. due to connecting to a non-Atlas cluster), we can ignore the error
// and return an empty array for search indexes.
searchIndexes = [];
} else {
throw error;
}
}

return {
content: [
{
text: `Found ${indexes.length} indexes in the collection "${collection}":`,
text: `Found ${indexes.length + searchIndexes.length} indexes in the collection "${collection}":`,
type: "text",
},
...(indexes.map((indexDefinition) => {
...indexes.map((indexDefinition) => {
return {
text: `Name "${indexDefinition.name}", definition: ${JSON.stringify(indexDefinition.key)}`,
type: "text",
};
}) as { text: string; type: "text" }[]),
} as const;
}),
...searchIndexes.map((indexDefinition) => {
return {
text: `Search index name: "${indexDefinition.name}", status: ${indexDefinition.status}, definition: ${JSON.stringify(indexDefinition.latestDefinition)}`,
type: "text",
} as const;
}),
],
};
}
Expand Down
36 changes: 22 additions & 14 deletions src/tools/mongodb/tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled
// import { ConnectTool } from "./metadata/connect.js";
import { ListCollectionsTool } from "./metadata/listCollections.js";
import { CollectionIndexesTool } from "./read/collectionIndexes.js";
import { CollectionIndexesTool as ListIndexesTool } from "./read/collectionIndexes.js";
import { ListDatabasesTool } from "./metadata/listDatabases.js";
import { CreateIndexTool } from "./create/createIndex.js";
import { CollectionSchemaTool } from "./metadata/collectionSchema.js";
Expand All @@ -19,27 +19,35 @@ import { DropCollectionTool } from "./delete/dropCollection.js";
import { ExplainTool } from "./metadata/explain.js";
import { CreateCollectionTool } from "./create/createCollection.js";
import { LogsTool } from "./metadata/logs.js";
import { CreateSearchIndexTool } from "./create/createSearchIndex.js";
import { DropIndexTool } from "./delete/dropIndex.js";

export const MongoDbTools = [
// TODO: https://github.com/mongodb-js/mongodb-mcp-server/issues/141 - reenable when the connect tool is reenabled
// ConnectTool,
CreateCollectionTool,
ListCollectionsTool,
ListDatabasesTool,
CollectionIndexesTool,
CreateIndexTool,
CollectionSchemaTool,
FindTool,
InsertManyTool,
DeleteManyTool,
CollectionStorageSizeTool,
CountTool,
DbStatsTool,
AggregateTool,
UpdateManyTool,
RenameCollectionTool,
DropDatabaseTool,
DropCollectionTool,
ExplainTool,
CreateCollectionTool,

ListDatabasesTool,
DropDatabaseTool,
DbStatsTool,
LogsTool,

FindTool,
AggregateTool,
CountTool,
ExplainTool,

InsertManyTool,
DeleteManyTool,
UpdateManyTool,

CreateIndexTool,
CreateSearchIndexTool,
ListIndexesTool,
DropIndexTool,
];
4 changes: 4 additions & 0 deletions tests/integration/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,7 @@ export function validateThrowsForInvalidArguments(
export function expectDefined<T>(arg: T): asserts arg is Exclude<T, undefined> {
expect(arg).toBeDefined();
}

export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
67 changes: 67 additions & 0 deletions tests/integration/tools/atlas-search/atlasSearchHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ObjectId } from "bson";
import { defaultTestConfig, IntegrationTest, setupIntegrationTest } from "../../helpers.js";
import { waitClusterState, withProject } from "../atlas/atlasHelpers.js";

export function describeWithAtlasSearch(
name: string,
fn: (integration: IntegrationTest & { connectMcpClient: () => Promise<void> }) => void
): void {
const describeFn =
process.env.MDB_MCP_API_CLIENT_ID?.length && process.env.MDB_MCP_API_CLIENT_SECRET?.length
? describe
: describe.skip;

describeFn("atlas-search", () => {
const integration = setupIntegrationTest(() => ({
...defaultTestConfig,
apiClientId: process.env.MDB_MCP_API_CLIENT_ID,
apiClientSecret: process.env.MDB_MCP_API_CLIENT_SECRET,
}));

describe(name, () => {
withProject(integration, ({ getProjectId }) => {
const clusterName = `ClusterTest-${new ObjectId()}`;
beforeAll(async () => {
const projectId = getProjectId();

await integration.mcpClient().callTool({
name: "atlas-create-free-cluster",
arguments: {
projectId,
name: clusterName,
region: "US_EAST_1",
},
});

await waitClusterState(integration.mcpServer().session, projectId, clusterName, "IDLE");
await integration.mcpServer().session.apiClient.createProjectIpAccessList({
params: {
path: {
groupId: projectId,
},
},
body: [
{
comment: "MCP test",
cidrBlock: "0.0.0.0/0",
},
],
});
});

fn({
...integration,
connectMcpClient: async () => {
await integration.mcpClient().callTool({
name: "atlas-connect-cluster",
arguments: { projectId: getProjectId(), clusterName },
});

expect(integration.mcpServer().session.connectedAtlasCluster).toBeDefined();
expect(integration.mcpServer().session.serviceProvider).toBeDefined();
},
});
});
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ObjectId } from "bson";
import { expectDefined, getResponseElements } from "../../../helpers.js";
import { describeWithAtlasSearch } from "../atlasSearchHelpers.js";

describeWithAtlasSearch("collectionIndexes tool", (integration) => {
it("can inspect search indexes", async () => {
await integration.connectMcpClient();

const provider = integration.mcpServer().session.serviceProvider;
expectDefined(provider);

const database = new ObjectId().toString();

await provider.mongoClient
.db(database)
.collection("coll1")
.insertMany([
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
{ name: "Charlie", age: 35 },
]);

const name = await provider.mongoClient
.db(database)
.collection("coll1")
.createSearchIndex({
name: "searchIndex1",
definition: {
mappings: {
dynamic: true,
},
analyzer: "lucene.danish",
},
});

const response = await integration.mcpClient().callTool({
name: "collection-indexes",
arguments: { database, collection: "coll1" },
});

const elements = getResponseElements(response.content);
expect(elements).toHaveLength(3);
expect(elements[0].text).toEqual(`Found 2 indexes in the collection "coll1":`);
expect(elements[1].text).toEqual('Name "_id_", definition: {"_id":1}');
expect(elements[2].text).toContain(`Search index name: "${name}"`);
expect(elements[2].text).toContain('"analyzer":"lucene.danish"');
});
});
Loading