From 503a4bc50cdd09cc112e4a7d84fd02e034ea4fa5 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 7 Oct 2025 14:18:25 +0200 Subject: [PATCH 01/12] feat: add ability to create vector search indexes --- src/tools/mongodb/create/createIndex.ts | 82 ++++- tests/accuracy/createIndex.test.ts | 14 +- .../tools/mongodb/create/createIndex.test.ts | 301 ++++++++++-------- 3 files changed, 253 insertions(+), 144 deletions(-) diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index d87b9df0b..49c633810 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -9,8 +9,57 @@ export class CreateIndexTool extends MongoDBToolBase { protected description = "Create an index for a collection"; protected argsShape = { ...DbOperationArgs, - keys: z.object({}).catchall(z.custom()).describe("The index definition"), name: z.string().optional().describe("The name of the index"), + definition: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("classic"), + keys: z.object({}).catchall(z.custom()).describe("The index definition"), + }), + z.object({ + type: z.literal("vectorSearch"), + fields: z + .array( + z.object({ + type: z + .enum(["vector", "filter"]) + .describe( + "Field type to use to index fields for vector search. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on." + ), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + numDimensions: z + .number() + .min(1) + .max(8192) + .describe( + "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" + ), + similarity: z + .enum(["cosine", "euclidean", "dotProduct"]) + .describe( + "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." + ), + quantization: z + .enum(["none", "scalar", "binary"]) + .optional() + .default("none") + .describe( + "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." + ), + }) + ) + .describe( + "Definitions for the vector and filter fields to index, one definition per document. The fields array must contain at least one vector-type field definition." + ), + }), + ]) + .describe( + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" + ), }; public operationType: OperationType = "create"; @@ -18,16 +67,33 @@ export class CreateIndexTool extends MongoDBToolBase { protected async execute({ database, collection, - keys, name, + definition, }: ToolArgs): Promise { const provider = await this.ensureConnected(); - const indexes = await provider.createIndexes(database, collection, [ - { - key: keys, - name, - }, - ]); + let indexes: string[] = []; + switch (definition.type) { + case "classic": + indexes = await provider.createIndexes(database, collection, [ + { + key: definition.keys, + name, + }, + ]); + + break; + case "vectorSearch": + indexes = await provider.createSearchIndexes(database, collection, [ + { + name, + definition: { + fields: definition.fields, + }, + type: "vectorSearch", + }, + ]); + break; + } return { content: [ diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index 08326ce31..d348e869a 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -11,8 +11,11 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - release_year: 1, + definition: { + type: "classic", + keys: { + release_year: 1, + }, }, }, }, @@ -27,8 +30,11 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - title: "text", + definition: { + type: "classic", + keys: { + title: "text", + }, }, }, }, diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 3c789be83..8f44803ac 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -8,7 +8,7 @@ import { expectDefined, } from "../../../helpers.js"; import type { IndexDirection } from "mongodb"; -import { expect, it } from "vitest"; +import { expect, it, describe } from "vitest"; describeWithMongoDB("createIndex tool", (integration) => { validateToolMetadata(integration, "create-index", "Create an index for a collection", [ @@ -29,174 +29,211 @@ describeWithMongoDB("createIndex tool", (integration) => { validateThrowsForInvalidArguments(integration, "create-index", [ {}, - { collection: "bar", database: 123, keys: { foo: 1 } }, - { collection: [], database: "test", keys: { foo: 1 } }, - { collection: "bar", database: "test", keys: { foo: 1 }, name: 123 }, - { collection: "bar", database: "test", keys: "foo", name: "my-index" }, + { collection: "bar", database: 123, definition: { type: "classic", keys: { foo: 1 } } }, + { collection: [], database: "test", definition: { type: "classic", keys: { foo: 1 } } }, + { collection: "bar", database: "test", definition: { type: "classic", keys: { foo: 1 } }, name: 123 }, + { collection: "bar", database: "test", definition: { type: "classic", keys: { foo: 1 } }, name: "my-index" }, ]); - const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { - const mongoClient = integration.mongoClient(); - const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); - expect(collections).toHaveLength(1); - expect(collections[0]?.name).toEqual("coll1"); - const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); - expect(indexes).toHaveLength(expected.length + 1); - expect(indexes[0]?.name).toEqual("_id_"); - for (const index of expected) { - const foundIndex = indexes.find((i) => i.name === index.name); - expectDefined(foundIndex); - expect(foundIndex.key).toEqual(index.key); - } - }; - - it("creates the namespace if necessary", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - keys: { prop1: 1 }, - name: "my-index", - }, - }); - - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` - ); - - await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); - }); + describe("with classic indexes", () => { + const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { + const mongoClient = integration.mongoClient(); + const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0]?.name).toEqual("coll1"); + const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); + expect(indexes).toHaveLength(expected.length + 1); + expect(indexes[0]?.name).toEqual("_id_"); + for (const index of expected) { + const foundIndex = indexes.find((i) => i.name === index.name); + expectDefined(foundIndex); + expect(foundIndex.key).toEqual(index.key); + } + }; - it("generates a name if not provided", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, - }); + it("creates the namespace if necessary", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { + type: "classic", + keys: { prop1: 1 }, + }, + name: "my-index", + }, + }); - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); - }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` + ); - it("can create multiple indexes in the same collection", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, + await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("generates a name if not provided", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: 1 } }, + }, + }); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop2: -1 } }, + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("can create multiple indexes in the same collection", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: 1 } }, + }, + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop2_-1", key: { prop2: -1 } }, - ]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - it("can create multiple indexes on the same property", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, - }); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop2: -1 } }, + }, + }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: -1 } }, + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop2_-1", key: { prop2: -1 } }, + ]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("can create multiple indexes on the same property", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: 1 } }, + }, + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop1_-1", key: { prop1: -1 } }, - ]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - it("doesn't duplicate indexes", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, - }); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: -1 } }, + }, + }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: 1 } }, + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop1_-1", key: { prop1: -1 } }, + ]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("doesn't duplicate indexes", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: 1 } }, + }, + }); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - const testCases: { name: string; direction: IndexDirection }[] = [ - { name: "descending", direction: -1 }, - { name: "ascending", direction: 1 }, - { name: "hashed", direction: "hashed" }, - { name: "text", direction: "text" }, - { name: "geoHaystack", direction: "2dsphere" }, - { name: "geo2d", direction: "2d" }, - ]; - - for (const { name, direction } of testCases) { - it(`creates ${name} index`, async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ + response = await integration.mcpClient().callTool({ name: "create-index", - arguments: { database: integration.randomDbName(), collection: "coll1", keys: { prop1: direction } }, + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: 1 } }, + }, }); expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` ); - let expectedKey: object = { prop1: direction }; - if (direction === "text") { - expectedKey = { - _fts: "text", - _ftsx: 1, - }; - } - await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); - } + + const testCases: { name: string; direction: IndexDirection }[] = [ + { name: "descending", direction: -1 }, + { name: "ascending", direction: 1 }, + { name: "hashed", direction: "hashed" }, + { name: "text", direction: "text" }, + { name: "geoHaystack", direction: "2dsphere" }, + { name: "geo2d", direction: "2d" }, + ]; + + for (const { name, direction } of testCases) { + it(`creates ${name} index`, async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: { type: "classic", keys: { prop1: direction } }, + }, + }); + + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` + ); + + let expectedKey: object = { prop1: direction }; + if (direction === "text") { + expectedKey = { + _fts: "text", + _ftsx: 1, + }; + } + await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + }); + } + }); validateAutoConnectBehavior(integration, "create-index", () => { return { args: { database: integration.randomDbName(), collection: "coll1", - keys: { prop1: 1 }, + definition: { type: "classic", keys: { prop1: 1 } }, }, expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, }; From 0b9df0df4c3f4be2abcc71d0f76cfed657d4e0a2 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Wed, 8 Oct 2025 12:51:56 +0200 Subject: [PATCH 02/12] fix tests --- tests/integration/helpers.ts | 51 +++++++++++++++---- .../tools/mongodb/create/createIndex.test.ts | 16 ++++-- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 0f510beca..bde3c622a 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -29,13 +29,22 @@ export const driverOptions = setupDriverConfig({ export const defaultDriverOptions: DriverOptions = { ...driverOptions }; -interface ParameterInfo { +interface Parameter { name: string; - type: string; description: string; required: boolean; } +interface SingleValueParameter extends Parameter { + type: string; +} + +interface AnyOfParameter extends Parameter { + anyOf: { type: string }[]; +} + +type ParameterInfo = SingleValueParameter | AnyOfParameter; + type ToolInfo = Awaited>["tools"][number]; export interface IntegrationTest { @@ -219,18 +228,38 @@ export function getParameters(tool: ToolInfo): ParameterInfo[] { return Object.entries(tool.inputSchema.properties) .sort((a, b) => a[0].localeCompare(b[0])) - .map(([key, value]) => { - expect(value).toHaveProperty("type"); + .map(([name, value]) => { expect(value).toHaveProperty("description"); - const typedValue = value as { type: string; description: string }; - expect(typeof typedValue.type).toBe("string"); - expect(typeof typedValue.description).toBe("string"); + const description = (value as { description: string }).description; + const required = (tool.inputSchema.required as string[])?.includes(name) ?? false; + expect(typeof description).toBe("string"); + + if (value && typeof value === "object" && "anyOf" in value) { + const typedOptions = new Array<{ type: string }>(); + for (const option of value.anyOf as { type: string }[]) { + expect(option).toHaveProperty("type"); + + typedOptions.push({ type: option.type }); + } + + return { + name, + anyOf: typedOptions, + description: description, + required, + }; + } + + expect(value).toHaveProperty("type"); + + const type = (value as { type: string }).type; + expect(typeof type).toBe("string"); return { - name: key, - type: typedValue.type, - description: typedValue.description, - required: (tool.inputSchema.required as string[])?.includes(key) ?? false, + name, + type, + description, + required, }; }); } diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 8f44803ac..156c33ff2 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -14,9 +14,17 @@ describeWithMongoDB("createIndex tool", (integration) => { validateToolMetadata(integration, "create-index", "Create an index for a collection", [ ...databaseCollectionParameters, { - name: "keys", - type: "object", - description: "The index definition", + name: "definition", + anyOf: [ + { + type: "object", + }, + { + type: "object", + }, + ], + description: + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes", required: true, }, { @@ -32,7 +40,7 @@ describeWithMongoDB("createIndex tool", (integration) => { { collection: "bar", database: 123, definition: { type: "classic", keys: { foo: 1 } } }, { collection: [], database: "test", definition: { type: "classic", keys: { foo: 1 } } }, { collection: "bar", database: "test", definition: { type: "classic", keys: { foo: 1 } }, name: 123 }, - { collection: "bar", database: "test", definition: { type: "classic", keys: { foo: 1 } }, name: "my-index" }, + { collection: "bar", database: "test", definition: { type: "unknown", keys: { foo: 1 } }, name: "my-index" }, ]); describe("with classic indexes", () => { From 3c5937631a3ea192a60a92675bb93fb53c27c4b7 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Thu, 9 Oct 2025 22:58:18 +0200 Subject: [PATCH 03/12] tweak create index args to be compatible with clients --- src/tools/mongodb/create/createIndex.ts | 102 ++++++++------- tests/accuracy/createIndex.test.ts | 116 ++++++++++++++++-- .../tools/mongodb/create/createIndex.test.ts | 45 +++---- 3 files changed, 181 insertions(+), 82 deletions(-) diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index 49c633810..4fd79c533 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -11,52 +11,55 @@ export class CreateIndexTool extends MongoDBToolBase { ...DbOperationArgs, name: z.string().optional().describe("The name of the index"), definition: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal("classic"), - keys: z.object({}).catchall(z.custom()).describe("The index definition"), - }), - z.object({ - type: z.literal("vectorSearch"), - fields: z - .array( - z.object({ - type: z - .enum(["vector", "filter"]) - .describe( - "Field type to use to index fields for vector search. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on." - ), - path: z - .string() - .describe( - "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" - ), - numDimensions: z - .number() - .min(1) - .max(8192) - .describe( - "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" - ), - similarity: z - .enum(["cosine", "euclidean", "dotProduct"]) - .describe( - "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." - ), - quantization: z - .enum(["none", "scalar", "binary"]) - .optional() - .default("none") - .describe( - "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." - ), - }) - ) - .describe( - "Definitions for the vector and filter fields to index, one definition per document. The fields array must contain at least one vector-type field definition." - ), - }), - ]) + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("classic"), + keys: z.object({}).catchall(z.custom()).describe("The index definition"), + }), + z.object({ + type: z.literal("vectorSearch"), + fields: z + .array( + z.object({ + type: z + .enum(["vector", "filter"]) + .describe( + "Field type to use to index fields for vector search. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on." + ), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + numDimensions: z + .number() + .min(1) + .max(8192) + .describe( + "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" + ), + similarity: z + .enum(["cosine", "euclidean", "dotProduct"]) + .default("cosine") + .describe( + "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." + ), + quantization: z + .enum(["none", "scalar", "binary"]) + .optional() + .default("none") + .describe( + "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." + ), + }) + ) + .describe( + "Definitions for the vector and filter fields to index, one definition per document. The fields array must contain at least one vector-type field definition." + ), + }), + ]) + ) .describe( "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" ), @@ -68,10 +71,15 @@ export class CreateIndexTool extends MongoDBToolBase { database, collection, name, - definition, + definition: definitions, }: ToolArgs): Promise { const provider = await this.ensureConnected(); let indexes: string[] = []; + const definition = definitions[0]; + if (!definition) { + throw new Error("Index definition not provided. Expected one of the following: `classic`, `vectorSearch`"); + } + switch (definition.type) { case "classic": indexes = await provider.createIndexes(database, collection, [ diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index d348e869a..86cab73d7 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -11,12 +11,14 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - definition: { - type: "classic", - keys: { - release_year: 1, + definition: [ + { + type: "classic", + keys: { + release_year: 1, + }, }, - }, + ], }, }, ], @@ -30,12 +32,106 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - definition: { - type: "classic", - keys: { - title: "text", + definition: [ + { + type: "classic", + keys: { + title: "text", + }, }, - }, + ], + }, + }, + ], + }, + { + prompt: "Create a vector search index on 'mydb.movies' namespace with on the 'plotSummary' field. The index should use 1024 dimensions.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mydb", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: 1024, + }, + { + type: "filter", + path: "releaseDate", + }, + ], + }, + ], + }, + }, + ], + }, + { + prompt: "Create a vector search index on 'mydb.movies' namespace with on the 'plotSummary' field and 'genre' field. Pick a sensible number of dimensions for a voyage 3.5 model.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mydb", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: Matcher.number( + (value) => value % 2 === 0 && value >= 256 && value <= 8192 + ), + }, + { + type: "vector", + path: "genre", + numDimensions: Matcher.number( + (value) => value % 2 === 0 && value >= 256 && value <= 8192 + ), + }, + ], + }, + ], + }, + }, + ], + }, + { + prompt: "Create a vector search index on 'mydb.movies' namespace where the 'plotSummary' field is indexed as a 1024-dimensional vector and the 'releaseDate' field is indexed as a regular field.", + expectedToolCalls: [ + { + toolName: "create-index", + parameters: { + database: "mydb", + collection: "movies", + name: Matcher.anyOf(Matcher.undefined, Matcher.string()), + definition: [ + { + type: "vectorSearch", + fields: [ + { + type: "vector", + path: "plotSummary", + numDimensions: 1024, + }, + { + type: "filter", + path: "releaseDate", + }, + ], + }, + ], }, }, ], diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 156c33ff2..78fcd271b 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -15,14 +15,7 @@ describeWithMongoDB("createIndex tool", (integration) => { ...databaseCollectionParameters, { name: "definition", - anyOf: [ - { - type: "object", - }, - { - type: "object", - }, - ], + type: "array", description: "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes", required: true, @@ -37,10 +30,10 @@ describeWithMongoDB("createIndex tool", (integration) => { validateThrowsForInvalidArguments(integration, "create-index", [ {}, - { collection: "bar", database: 123, definition: { type: "classic", keys: { foo: 1 } } }, - { collection: [], database: "test", definition: { type: "classic", keys: { foo: 1 } } }, - { collection: "bar", database: "test", definition: { type: "classic", keys: { foo: 1 } }, name: 123 }, - { collection: "bar", database: "test", definition: { type: "unknown", keys: { foo: 1 } }, name: "my-index" }, + { collection: "bar", database: 123, definition: [{ type: "classic", keys: { foo: 1 } }] }, + { collection: [], database: "test", definition: [{ type: "classic", keys: { foo: 1 } }] }, + { collection: "bar", database: "test", definition: [{ type: "classic", keys: { foo: 1 } }], name: 123 }, + { collection: "bar", database: "test", definition: [{ type: "unknown", keys: { foo: 1 } }], name: "my-index" }, ]); describe("with classic indexes", () => { @@ -66,10 +59,12 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { - type: "classic", - keys: { prop1: 1 }, - }, + definition: [ + { + type: "classic", + keys: { prop1: 1 }, + }, + ], name: "my-index", }, }); @@ -89,7 +84,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: 1 } }, + definition: [{ type: "classic", keys: { prop1: 1 } }], }, }); @@ -107,7 +102,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: 1 } }, + definition: [{ type: "classic", keys: { prop1: 1 } }], }, }); @@ -120,7 +115,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop2: -1 } }, + definition: [{ type: "classic", keys: { prop2: -1 } }], }, }); @@ -141,7 +136,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: 1 } }, + definition: [{ type: "classic", keys: { prop1: 1 } }], }, }); @@ -154,7 +149,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: -1 } }, + definition: [{ type: "classic", keys: { prop1: -1 } }], }, }); @@ -175,7 +170,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: 1 } }, + definition: [{ type: "classic", keys: { prop1: 1 } }], }, }); @@ -188,7 +183,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: 1 } }, + definition: [{ type: "classic", keys: { prop1: 1 } }], }, }); @@ -216,7 +211,7 @@ describeWithMongoDB("createIndex tool", (integration) => { arguments: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: direction } }, + definition: [{ type: "classic", keys: { prop1: direction } }], }, }); @@ -241,7 +236,7 @@ describeWithMongoDB("createIndex tool", (integration) => { args: { database: integration.randomDbName(), collection: "coll1", - definition: { type: "classic", keys: { prop1: 1 } }, + definition: [{ type: "classic", keys: { prop1: 1 } }], }, expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, }; From 69aa6d01e05ab8d78f0f1f9c7053f0809e549328 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 01:07:18 +0200 Subject: [PATCH 04/12] add tests --- src/tools/mongodb/create/createIndex.ts | 76 +-- .../tools/mongodb/create/createIndex.test.ts | 465 ++++++++++++------ .../tools/mongodb/mongodbHelpers.ts | 56 +++ .../mongodb/search/listSearchIndexes.test.ts | 59 +-- 4 files changed, 407 insertions(+), 249 deletions(-) diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index 4fd79c533..6d5b15e75 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -21,41 +21,51 @@ export class CreateIndexTool extends MongoDBToolBase { type: z.literal("vectorSearch"), fields: z .array( - z.object({ - type: z - .enum(["vector", "filter"]) - .describe( - "Field type to use to index fields for vector search. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on." - ), - path: z - .string() - .describe( - "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" - ), - numDimensions: z - .number() - .min(1) - .max(8192) - .describe( - "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" - ), - similarity: z - .enum(["cosine", "euclidean", "dotProduct"]) - .default("cosine") - .describe( - "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." - ), - quantization: z - .enum(["none", "scalar", "binary"]) - .optional() - .default("none") - .describe( - "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." - ), - }) + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("filter"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + }), + z.object({ + type: z.literal("vector"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + numDimensions: z + .number() + .min(1) + .max(8192) + .describe( + "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" + ), + similarity: z + .enum(["cosine", "euclidean", "dotProduct"]) + .default("cosine") + .describe( + "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." + ), + quantization: z + .enum(["none", "scalar", "binary"]) + .optional() + .default("none") + .describe( + "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." + ), + }), + ]) ) + .nonempty() + .refine((fields) => fields.some((f) => f.type === "vector"), { + message: "At least one vector field must be defined", + }) .describe( - "Definitions for the vector and filter fields to index, one definition per document. The fields array must contain at least one vector-type field definition." + "Definitions for the vector and filter fields to index, one definition per document. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on. At least one vector-type field definition is required." ), }), ]) diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 78fcd271b..ea2568d36 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -1,4 +1,4 @@ -import { describeWithMongoDB, validateAutoConnectBehavior } from "../mongodbHelpers.js"; +import { describeWithMongoDB, validateAutoConnectBehavior, waitUntilSearchIsReady } from "../mongodbHelpers.js"; import { getResponseContent, @@ -7,10 +7,11 @@ import { validateThrowsForInvalidArguments, expectDefined, } from "../../../helpers.js"; -import type { IndexDirection } from "mongodb"; -import { expect, it, describe } from "vitest"; +import { ObjectId, type IndexDirection } from "mongodb"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; -describeWithMongoDB("createIndex tool", (integration) => { +describeWithMongoDB("createIndex tool with classic indexes", (integration) => { validateToolMetadata(integration, "create-index", "Create an index for a collection", [ ...databaseCollectionParameters, { @@ -34,211 +35,347 @@ describeWithMongoDB("createIndex tool", (integration) => { { collection: [], database: "test", definition: [{ type: "classic", keys: { foo: 1 } }] }, { collection: "bar", database: "test", definition: [{ type: "classic", keys: { foo: 1 } }], name: 123 }, { collection: "bar", database: "test", definition: [{ type: "unknown", keys: { foo: 1 } }], name: "my-index" }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: { foo: 1 } }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [] }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [{ type: "vector", path: "foo" }] }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [{ type: "filter", path: "foo" }] }], + }, + { + collection: "bar", + database: "test", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "foo", numDimensions: 128 }, + { type: "filter", path: "bar", numDimensions: 128 }, + ], + }, + ], + }, ]); - describe("with classic indexes", () => { - const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { - const mongoClient = integration.mongoClient(); - const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); - expect(collections).toHaveLength(1); - expect(collections[0]?.name).toEqual("coll1"); - const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); - expect(indexes).toHaveLength(expected.length + 1); - expect(indexes[0]?.name).toEqual("_id_"); - for (const index of expected) { - const foundIndex = indexes.find((i) => i.name === index.name); - expectDefined(foundIndex); - expect(foundIndex.key).toEqual(index.key); - } - }; + const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { + const mongoClient = integration.mongoClient(); + const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0]?.name).toEqual("coll1"); + const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); + expect(indexes).toHaveLength(expected.length + 1); + expect(indexes[0]?.name).toEqual("_id_"); + for (const index of expected) { + const foundIndex = indexes.find((i) => i.name === index.name); + expectDefined(foundIndex); + expect(foundIndex.key).toEqual(index.key); + } + }; - it("creates the namespace if necessary", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [ - { - type: "classic", - keys: { prop1: 1 }, - }, - ], - name: "my-index", - }, - }); + it("creates the namespace if necessary", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [ + { + type: "classic", + keys: { prop1: 1 }, + }, + ], + name: "my-index", + }, + }); - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` - ); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` + ); - await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); + await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); + }); + + it("generates a name if not provided", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, }); - it("generates a name if not provided", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); + }); - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); + it("can create multiple indexes in the same collection", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, }); - it("can create multiple indexes in the same collection", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop2: -1 } }], + }, + }); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop2: -1 } }], - }, - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop2_-1", key: { prop2: -1 } }, + ]); + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop2_-1", key: { prop2: -1 } }, - ]); + it("can create multiple indexes on the same property", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, }); - it("can create multiple indexes on the same property", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: -1 } }], + }, + }); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: -1 } }], - }, - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop1_-1", key: { prop1: -1 } }, + ]); + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop1_-1", key: { prop1: -1 } }, - ]); + it("doesn't duplicate indexes", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, }); - it("doesn't duplicate indexes", async () => { + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); + + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); + + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); + + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); + }); + + const testCases: { name: string; direction: IndexDirection }[] = [ + { name: "descending", direction: -1 }, + { name: "ascending", direction: 1 }, + { name: "hashed", direction: "hashed" }, + { name: "text", direction: "text" }, + { name: "geoHaystack", direction: "2dsphere" }, + { name: "geo2d", direction: "2d" }, + ]; + + for (const { name, direction } of testCases) { + it(`creates ${name} index`, async () => { await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ + const response = await integration.mcpClient().callTool({ name: "create-index", arguments: { database: integration.randomDbName(), collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], + definition: [{ type: "classic", keys: { prop1: direction } }], }, }); expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` ); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, + let expectedKey: object = { prop1: direction }; + if (direction === "text") { + expectedKey = { + _fts: "text", + _ftsx: 1, + }; + } + await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + }); + } + + validateAutoConnectBehavior(integration, "create-index", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, + }; + }); +}); + +describeWithMongoDB( + "createIndex tool with vector search indexes", + (integration) => { + let provider: NodeDriverServiceProvider; + + beforeEach(async ({ signal }) => { + await integration.connectMcpClient(); + provider = integration.mcpServer().session.serviceProvider; + await waitUntilSearchIsReady(provider, signal); + }); + + describe("when the collection does not exist", () => { + it("throws an error", async () => { + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "foo", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4 }, + { type: "filter", path: "category" }, + ], + }, + ], + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain(`Collection '${integration.randomDbName()}.foo' does not exist`); }); + }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + describe("when the database does not exist", () => { + it("throws an error", async () => { + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: "nonexistent_db", + collection: "foo", + definition: [ + { + type: "vectorSearch", + fields: [{ type: "vector", path: "vector_1", numDimensions: 4 }], + }, + ], + }, + }); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); + const content = getResponseContent(response.content); + expect(content).toContain(`Collection 'nonexistent_db.foo' does not exist`); + }); }); - const testCases: { name: string; direction: IndexDirection }[] = [ - { name: "descending", direction: -1 }, - { name: "ascending", direction: 1 }, - { name: "hashed", direction: "hashed" }, - { name: "text", direction: "text" }, - { name: "geoHaystack", direction: "2dsphere" }, - { name: "geo2d", direction: "2d" }, - ]; - - for (const { name, direction } of testCases) { - it(`creates ${name} index`, async () => { - await integration.connectMcpClient(); + describe("when the collection exists", () => { + it("creates the index", async () => { + const collection = new ObjectId().toString(); + await provider.createCollection(integration.randomDbName(), collection); const response = await integration.mcpClient().callTool({ name: "create-index", arguments: { database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: direction } }], + collection, + name: "vector_1_vector", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4 }, + { type: "filter", path: "category" }, + ], + }, + ], }, }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "vector_1_vector" on collection "${collection}" in database "${integration.randomDbName()}"` ); - let expectedKey: object = { prop1: direction }; - if (direction === "text") { - expectedKey = { - _fts: "text", - _ftsx: 1, - }; - } - await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + const indexes = await provider.getSearchIndexes(integration.randomDbName(), collection); + expect(indexes).toHaveLength(1); + expect(indexes[0]?.name).toEqual("vector_1_vector"); + expect(indexes[0]?.type).toEqual("vectorSearch"); + expect(indexes[0]?.status).toEqual("PENDING"); + expect(indexes[0]?.queryable).toEqual(false); + expect(indexes[0]?.latestDefinition).toEqual({ + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4, similarity: "cosine" }, + { type: "filter", path: "category" }, + ], + }); }); - } - }); - - validateAutoConnectBehavior(integration, "create-index", () => { - return { - args: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, - }; - }); -}); + }); + }, + undefined, + undefined, + { + search: true, + } +); diff --git a/tests/integration/tools/mongodb/mongodbHelpers.ts b/tests/integration/tools/mongodb/mongodbHelpers.ts index e3a332ae8..17a7faad3 100644 --- a/tests/integration/tools/mongodb/mongodbHelpers.ts +++ b/tests/integration/tools/mongodb/mongodbHelpers.ts @@ -10,12 +10,14 @@ import { defaultTestConfig, defaultDriverOptions, getDataFromUntrustedContent, + sleep, } from "../../helpers.js"; import type { UserConfig, DriverOptions } from "../../../../src/common/config.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { EJSON } from "bson"; import { MongoDBClusterProcess } from "./mongodbClusterProcess.js"; import type { MongoClusterConfiguration } from "./mongodbClusterProcess.js"; +import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -259,3 +261,57 @@ export async function getServerVersion(integration: MongoDBIntegrationTestCase): const serverStatus = await client.db("admin").admin().serverStatus(); return serverStatus.version as string; } + +const SEARCH_RETRIES = 200; + +export async function waitUntilSearchIsReady( + provider: NodeDriverServiceProvider, + abortSignal: AbortSignal +): Promise { + let lastError: unknown = null; + + for (let i = 0; i < SEARCH_RETRIES && !abortSignal.aborted; i++) { + try { + await provider.insertOne("tmp", "test", { field1: "yay" }); + await provider.createSearchIndexes("tmp", "test", [{ definition: { mappings: { dynamic: true } } }]); + await provider.dropCollection("tmp", "test"); + return; + } catch (err) { + lastError = err; + await sleep(100); + } + } + + throw new Error(`Search Management Index is not ready.\nlastError: ${JSON.stringify(lastError)}`); +} + +export async function waitUntilSearchIndexIsQueryable( + provider: NodeDriverServiceProvider, + database: string, + collection: string, + indexName: string, + abortSignal: AbortSignal +): Promise { + let lastIndexStatus: unknown = null; + let lastError: unknown = null; + + for (let i = 0; i < SEARCH_RETRIES && !abortSignal.aborted; i++) { + try { + const [indexStatus] = await provider.getSearchIndexes(database, collection, indexName); + lastIndexStatus = indexStatus; + + if (indexStatus?.queryable === true) { + return; + } + } catch (err) { + lastError = err; + await sleep(100); + } + } + + throw new Error( + `Index ${indexName} in ${database}.${collection} is not ready: +lastIndexStatus: ${JSON.stringify(lastIndexStatus)} +lastError: ${JSON.stringify(lastError)}` + ); +} diff --git a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts index 97571c0a9..e04b9bd32 100644 --- a/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts +++ b/tests/integration/tools/mongodb/search/listSearchIndexes.test.ts @@ -1,4 +1,9 @@ -import { describeWithMongoDB, getSingleDocFromUntrustedContent } from "../mongodbHelpers.js"; +import { + describeWithMongoDB, + getSingleDocFromUntrustedContent, + waitUntilSearchIndexIsQueryable, + waitUntilSearchIsReady, +} from "../mongodbHelpers.js"; import { describe, it, expect, beforeEach } from "vitest"; import { getResponseContent, @@ -6,13 +11,11 @@ import { validateToolMetadata, validateThrowsForInvalidArguments, databaseCollectionInvalidArgs, - sleep, getDataFromUntrustedContent, } from "../../../helpers.js"; import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; import type { SearchIndexStatus } from "../../../../../src/tools/mongodb/search/listSearchIndexes.js"; -const SEARCH_RETRIES = 200; const SEARCH_TIMEOUT = 20_000; describeWithMongoDB("list search indexes tool in local MongoDB", (integration) => { @@ -98,7 +101,7 @@ describeWithMongoDB( "returns the list of existing indexes and detects if they are queryable", { timeout: SEARCH_TIMEOUT }, async ({ signal }) => { - await waitUntilIndexIsQueryable(provider, "any", "foo", "default", signal); + await waitUntilSearchIndexIsQueryable(provider, "any", "foo", "default", signal); const response = await integration.mcpClient().callTool({ name: "list-search-indexes", @@ -121,51 +124,3 @@ describeWithMongoDB( undefined, // default driver config { search: true } // use a search cluster ); - -async function waitUntilSearchIsReady(provider: NodeDriverServiceProvider, abortSignal: AbortSignal): Promise { - let lastError: unknown = null; - - for (let i = 0; i < SEARCH_RETRIES && !abortSignal.aborted; i++) { - try { - await provider.insertOne("tmp", "test", { field1: "yay" }); - await provider.createSearchIndexes("tmp", "test", [{ definition: { mappings: { dynamic: true } } }]); - return; - } catch (err) { - lastError = err; - await sleep(100); - } - } - - throw new Error(`Search Management Index is not ready.\nlastError: ${JSON.stringify(lastError)}`); -} - -async function waitUntilIndexIsQueryable( - provider: NodeDriverServiceProvider, - database: string, - collection: string, - indexName: string, - abortSignal: AbortSignal -): Promise { - let lastIndexStatus: unknown = null; - let lastError: unknown = null; - - for (let i = 0; i < SEARCH_RETRIES && !abortSignal.aborted; i++) { - try { - const [indexStatus] = await provider.getSearchIndexes(database, collection, indexName); - lastIndexStatus = indexStatus; - - if (indexStatus?.queryable === true) { - return; - } - } catch (err) { - lastError = err; - await sleep(100); - } - } - - throw new Error( - `Index ${indexName} in ${database}.${collection} is not ready: -lastIndexStatus: ${JSON.stringify(lastIndexStatus)} -lastError: ${JSON.stringify(lastError)}` - ); -} From 88acf380b0122ca80cf62698046a50d1eda740f1 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 02:51:44 +0200 Subject: [PATCH 05/12] add primitive feature flagging --- src/tools/mongodb/create/createIndex.ts | 183 +++--- src/tools/tool.ts | 14 + tests/accuracy/createIndex.test.ts | 2 + tests/accuracy/sdk/accuracyTestingClient.ts | 4 +- tests/accuracy/sdk/describeAccuracyTests.ts | 4 +- .../tools/mongodb/create/createIndex.test.ts | 540 +++++++++++------- 6 files changed, 440 insertions(+), 307 deletions(-) diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index 6d5b15e75..c1ca390dd 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -1,79 +1,90 @@ +import type { ZodDiscriminatedUnionOption } from "zod"; import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; -import type { ToolArgs, OperationType } from "../../tool.js"; +import { type ToolArgs, type OperationType, type ToolConstructorParams, FeatureFlags } from "../../tool.js"; import type { IndexDirection } from "mongodb"; +const classicIndexDefinition = z.object({ + type: z.literal("classic"), + keys: z.object({}).catchall(z.custom()).describe("The index definition"), +}); + +const vectorSearchIndexDefinition = z.object({ + type: z.literal("vectorSearch"), + fields: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("filter"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + }), + z.object({ + type: z.literal("vector"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + numDimensions: z + .number() + .min(1) + .max(8192) + .describe( + "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" + ), + similarity: z + .enum(["cosine", "euclidean", "dotProduct"]) + .default("cosine") + .describe( + "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." + ), + quantization: z + .enum(["none", "scalar", "binary"]) + .optional() + .default("none") + .describe( + "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." + ), + }), + ]) + ) + .nonempty() + .refine((fields) => fields.some((f) => f.type === "vector"), { + message: "At least one vector field must be defined", + }) + .describe( + "Definitions for the vector and filter fields to index, one definition per document. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on. At least one vector-type field definition is required." + ), +}); + export class CreateIndexTool extends MongoDBToolBase { public name = "create-index"; protected description = "Create an index for a collection"; - protected argsShape = { - ...DbOperationArgs, - name: z.string().optional().describe("The name of the index"), - definition: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("classic"), - keys: z.object({}).catchall(z.custom()).describe("The index definition"), - }), - z.object({ - type: z.literal("vectorSearch"), - fields: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("filter"), - path: z - .string() - .describe( - "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" - ), - }), - z.object({ - type: z.literal("vector"), - path: z - .string() - .describe( - "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" - ), - numDimensions: z - .number() - .min(1) - .max(8192) - .describe( - "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" - ), - similarity: z - .enum(["cosine", "euclidean", "dotProduct"]) - .default("cosine") - .describe( - "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." - ), - quantization: z - .enum(["none", "scalar", "binary"]) - .optional() - .default("none") - .describe( - "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." - ), - }), - ]) - ) - .nonempty() - .refine((fields) => fields.some((f) => f.type === "vector"), { - message: "At least one vector field must be defined", - }) - .describe( - "Definitions for the vector and filter fields to index, one definition per document. You must specify `vector` for fields that contain vector embeddings and `filter` for additional fields to filter on. At least one vector-type field definition is required." - ), - }), - ]) - ) - .describe( - "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" - ), - }; + protected argsShape; + + constructor(params: ToolConstructorParams) { + super(params); + + const additionalIndexDefinitions: ZodDiscriminatedUnionOption<"type">[] = []; + if (this.isFeatureFlagEnabled(FeatureFlags.VectorSearch)) { + additionalIndexDefinitions.push(vectorSearchIndexDefinition); + } + + this.argsShape = { + ...DbOperationArgs, + name: z.string().optional().describe("The name of the index"), + definition: z + .array(z.discriminatedUnion("type", [classicIndexDefinition, ...additionalIndexDefinitions])) + .describe( + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" + ), + }; + } public operationType: OperationType = "create"; @@ -92,24 +103,30 @@ export class CreateIndexTool extends MongoDBToolBase { switch (definition.type) { case "classic": - indexes = await provider.createIndexes(database, collection, [ - { - key: definition.keys, - name, - }, - ]); - + { + const typedDefinition = definition as z.infer; + indexes = await provider.createIndexes(database, collection, [ + { + key: typedDefinition.keys, + name, + }, + ]); + } break; case "vectorSearch": - indexes = await provider.createSearchIndexes(database, collection, [ - { - name, - definition: { - fields: definition.fields, + { + const typedDefinition = definition as z.infer; + indexes = await provider.createSearchIndexes(database, collection, [ + { + name, + definition: { + fields: typedDefinition.fields, + }, + type: "vectorSearch", }, - type: "vectorSearch", - }, - ]); + ]); + } + break; } diff --git a/src/tools/tool.ts b/src/tools/tool.ts index d609e78a8..bb7e872c4 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -15,6 +15,10 @@ export type ToolCallbackArgs = Parameters = Parameters>[1]; +export const enum FeatureFlags { + VectorSearch = "vectorSearch", +} + /** * The type of operation the tool performs. This is used when evaluating if a tool is allowed to run based on * the config's `disabledTools` and `readOnly` settings. @@ -314,6 +318,16 @@ export abstract class ToolBase { this.telemetry.emitEvents([event]); } + + // TODO: Move this to a separate file + protected isFeatureFlagEnabled(flag: FeatureFlags): boolean { + switch (flag) { + case FeatureFlags.VectorSearch: + return this.config.voyageApiKey !== ""; + default: + return false; + } + } } /** diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index 86cab73d7..c4078a259 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -1,6 +1,8 @@ import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; +process.env.MDB_VOYAGE_API_KEY = "valid-key"; + describeAccuracyTests([ { prompt: "Create an index that covers the following query on 'mflix.movies' namespace - { \"release_year\": 1992 }", diff --git a/tests/accuracy/sdk/accuracyTestingClient.ts b/tests/accuracy/sdk/accuracyTestingClient.ts index 130e8fb05..bae19461d 100644 --- a/tests/accuracy/sdk/accuracyTestingClient.ts +++ b/tests/accuracy/sdk/accuracyTestingClient.ts @@ -82,7 +82,8 @@ export class AccuracyTestingClient { static async initializeClient( mdbConnectionString: string, atlasApiClientId?: string, - atlasApiClientSecret?: string + atlasApiClientSecret?: string, + voyageApiKey?: string ): Promise { const args = [ MCP_SERVER_CLI_SCRIPT, @@ -90,6 +91,7 @@ export class AccuracyTestingClient { mdbConnectionString, ...(atlasApiClientId ? ["--apiClientId", atlasApiClientId] : []), ...(atlasApiClientSecret ? ["--apiClientSecret", atlasApiClientSecret] : []), + ...(voyageApiKey ? ["--voyageApiKey", voyageApiKey] : []), ]; const clientTransport = new StdioClientTransport({ diff --git a/tests/accuracy/sdk/describeAccuracyTests.ts b/tests/accuracy/sdk/describeAccuracyTests.ts index df35e3a03..4c39e9623 100644 --- a/tests/accuracy/sdk/describeAccuracyTests.ts +++ b/tests/accuracy/sdk/describeAccuracyTests.ts @@ -68,6 +68,7 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) const atlasApiClientId = process.env.MDB_MCP_API_CLIENT_ID; const atlasApiClientSecret = process.env.MDB_MCP_API_CLIENT_SECRET; + const voyageApiKey = process.env.MDB_VOYAGE_API_KEY; let commitSHA: string; let accuracyResultStorage: AccuracyResultStorage; @@ -85,7 +86,8 @@ export function describeAccuracyTests(accuracyTestConfigs: AccuracyTestConfig[]) testMCPClient = await AccuracyTestingClient.initializeClient( mdbIntegration.connectionString(), atlasApiClientId, - atlasApiClientSecret + atlasApiClientSecret, + voyageApiKey ); agent = getVercelToolCallingAgent(); }); diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index ea2568d36..133ec2f2f 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -6,274 +6,365 @@ import { validateToolMetadata, validateThrowsForInvalidArguments, expectDefined, + defaultTestConfig, } from "../../../helpers.js"; import { ObjectId, type IndexDirection } from "mongodb"; import { beforeEach, describe, expect, it } from "vitest"; import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver"; -describeWithMongoDB("createIndex tool with classic indexes", (integration) => { - validateToolMetadata(integration, "create-index", "Create an index for a collection", [ - ...databaseCollectionParameters, - { - name: "definition", - type: "array", - description: - "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes", - required: true, - }, - { - name: "name", - type: "string", - description: "The name of the index", - required: false, - }, - ]); - - validateThrowsForInvalidArguments(integration, "create-index", [ - {}, - { collection: "bar", database: 123, definition: [{ type: "classic", keys: { foo: 1 } }] }, - { collection: [], database: "test", definition: [{ type: "classic", keys: { foo: 1 } }] }, - { collection: "bar", database: "test", definition: [{ type: "classic", keys: { foo: 1 } }], name: 123 }, - { collection: "bar", database: "test", definition: [{ type: "unknown", keys: { foo: 1 } }], name: "my-index" }, - { - collection: "bar", - database: "test", - definition: [{ type: "vectorSearch", fields: { foo: 1 } }], - }, - { - collection: "bar", - database: "test", - definition: [{ type: "vectorSearch", fields: [] }], - }, - { - collection: "bar", - database: "test", - definition: [{ type: "vectorSearch", fields: [{ type: "vector", path: "foo" }] }], - }, - { - collection: "bar", - database: "test", - definition: [{ type: "vectorSearch", fields: [{ type: "filter", path: "foo" }] }], - }, - { - collection: "bar", - database: "test", - definition: [ - { - type: "vectorSearch", - fields: [ - { type: "vector", path: "foo", numDimensions: 128 }, - { type: "filter", path: "bar", numDimensions: 128 }, - ], - }, - ], - }, - ]); - - const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { - const mongoClient = integration.mongoClient(); - const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); - expect(collections).toHaveLength(1); - expect(collections[0]?.name).toEqual("coll1"); - const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); - expect(indexes).toHaveLength(expected.length + 1); - expect(indexes[0]?.name).toEqual("_id_"); - for (const index of expected) { - const foundIndex = indexes.find((i) => i.name === index.name); - expectDefined(foundIndex); - expect(foundIndex.key).toEqual(index.key); - } - }; - - it("creates the namespace if necessary", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", +describeWithMongoDB("createIndex tool when search is not enabled", (integration) => { + it("doesn't allow creating vector search indexes", async () => { + expect(integration.mcpServer().userConfig.voyageApiKey).toEqual(""); + + const { tools } = await integration.mcpClient().listTools(); + const createIndexTool = tools.find((tool) => tool.name === "create-index"); + const definitionProperty = createIndexTool?.inputSchema.properties?.definition as { + type: string; + items: { anyOf: Array<{ properties: Record> }> }; + }; + expectDefined(definitionProperty); + + expect(definitionProperty.type).toEqual("array"); + + // Because search is not enabled, the only available index definition is 'classic' + // We expect 1 option in the anyOf array where type is "classic" + expect(definitionProperty.items.anyOf).toHaveLength(1); + expect(definitionProperty.items.anyOf?.[0]?.properties?.type).toEqual({ type: "string", const: "classic" }); + expect(definitionProperty.items.anyOf?.[0]?.properties?.keys).toBeDefined(); + }); +}); + +describeWithMongoDB( + "createIndex tool when search is enabled", + (integration) => { + it("allows creating vector search indexes", async () => { + expect(integration.mcpServer().userConfig.voyageApiKey).not.toEqual(""); + + const { tools } = await integration.mcpClient().listTools(); + const createIndexTool = tools.find((tool) => tool.name === "create-index"); + const definitionProperty = createIndexTool?.inputSchema.properties?.definition as { + type: string; + items: { anyOf: Array<{ properties: Record> }> }; + }; + expectDefined(definitionProperty); + + expect(definitionProperty.type).toEqual("array"); + + // Because search is now enabled, we should see both "classic" and "vectorSearch" options in + // the anyOf array. + expect(definitionProperty.items.anyOf).toHaveLength(2); + expect(definitionProperty.items.anyOf?.[0]?.properties?.type).toEqual({ type: "string", const: "classic" }); + expect(definitionProperty.items.anyOf?.[0]?.properties?.keys).toBeDefined(); + expect(definitionProperty.items.anyOf?.[1]?.properties?.type).toEqual({ + type: "string", + const: "vectorSearch", + }); + expect(definitionProperty.items.anyOf?.[1]?.properties?.fields).toBeDefined(); + + const fields = definitionProperty.items.anyOf?.[1]?.properties?.fields as { + type: string; + items: { anyOf: Array<{ type: string; properties: Record> }> }; + }; + + expect(fields.type).toEqual("array"); + expect(fields.items.anyOf).toHaveLength(2); + expect(fields.items.anyOf?.[0]?.type).toEqual("object"); + expect(fields.items.anyOf?.[0]?.properties?.type).toEqual({ type: "string", const: "filter" }); + expectDefined(fields.items.anyOf?.[0]?.properties?.path); + + expect(fields.items.anyOf?.[1]?.type).toEqual("object"); + expect(fields.items.anyOf?.[1]?.properties?.type).toEqual({ type: "string", const: "vector" }); + expectDefined(fields.items.anyOf?.[1]?.properties?.path); + expectDefined(fields.items.anyOf?.[1]?.properties?.quantization); + expectDefined(fields.items.anyOf?.[1]?.properties?.numDimensions); + expectDefined(fields.items.anyOf?.[1]?.properties?.similarity); + }); + }, + () => { + return { + ...defaultTestConfig, + voyageApiKey: "valid_key", + }; + } +); + +describeWithMongoDB( + "createIndex tool with classic indexes", + (integration) => { + validateToolMetadata(integration, "create-index", "Create an index for a collection", [ + ...databaseCollectionParameters, + { + name: "definition", + type: "array", + description: + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes", + required: true, + }, + { + name: "name", + type: "string", + description: "The name of the index", + required: false, + }, + ]); + + validateThrowsForInvalidArguments(integration, "create-index", [ + {}, + { collection: "bar", database: 123, definition: [{ type: "classic", keys: { foo: 1 } }] }, + { collection: [], database: "test", definition: [{ type: "classic", keys: { foo: 1 } }] }, + { collection: "bar", database: "test", definition: [{ type: "classic", keys: { foo: 1 } }], name: 123 }, + { + collection: "bar", + database: "test", + definition: [{ type: "unknown", keys: { foo: 1 } }], + name: "my-index", + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: { foo: 1 } }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [] }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [{ type: "vector", path: "foo" }] }], + }, + { + collection: "bar", + database: "test", + definition: [{ type: "vectorSearch", fields: [{ type: "filter", path: "foo" }] }], + }, + { + collection: "bar", + database: "test", definition: [ { - type: "classic", - keys: { prop1: 1 }, + type: "vectorSearch", + fields: [ + { type: "vector", path: "foo", numDimensions: 128 }, + { type: "filter", path: "bar", numDimensions: 128 }, + ], }, ], - name: "my-index", }, - }); - - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` - ); + ]); - await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); - }); + const validateIndex = async (collection: string, expected: { name: string; key: object }[]): Promise => { + const mongoClient = integration.mongoClient(); + const collections = await mongoClient.db(integration.randomDbName()).listCollections().toArray(); + expect(collections).toHaveLength(1); + expect(collections[0]?.name).toEqual("coll1"); + const indexes = await mongoClient.db(integration.randomDbName()).collection(collection).indexes(); + expect(indexes).toHaveLength(expected.length + 1); + expect(indexes[0]?.name).toEqual("_id_"); + for (const index of expected) { + const foundIndex = indexes.find((i) => i.name === index.name); + expectDefined(foundIndex); + expect(foundIndex.key).toEqual(index.key); + } + }; - it("generates a name if not provided", async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - }); + it("creates the namespace if necessary", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [ + { + type: "classic", + keys: { prop1: 1 }, + }, + ], + name: "my-index", + }, + }); - const content = getResponseContent(response.content); - expect(content).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); - }); + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "my-index" on collection "coll1" in database "${integration.randomDbName()}"` + ); - it("can create multiple indexes in the same collection", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, + await validateIndex("coll1", [{ name: "my-index", key: { prop1: 1 } }]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("generates a name if not provided", async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop2: -1 } }], - }, + const content = getResponseContent(response.content); + expect(content).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("can create multiple indexes in the same collection", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop2_-1", key: { prop2: -1 } }, - ]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - it("can create multiple indexes on the same property", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - }); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop2: -1 } }], + }, + }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop2_-1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: -1 } }], - }, + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop2_-1", key: { prop2: -1 } }, + ]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("can create multiple indexes on the same property", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); - await validateIndex("coll1", [ - { name: "prop1_1", key: { prop1: 1 } }, - { name: "prop1_-1", key: { prop1: -1 } }, - ]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - it("doesn't duplicate indexes", async () => { - await integration.connectMcpClient(); - let response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - }); + response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: -1 } }], + }, + }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_-1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - response = await integration.mcpClient().callTool({ - name: "create-index", - arguments: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, + await validateIndex("coll1", [ + { name: "prop1_1", key: { prop1: 1 } }, + { name: "prop1_-1", key: { prop1: -1 } }, + ]); }); - expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` - ); + it("doesn't duplicate indexes", async () => { + await integration.connectMcpClient(); + let response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + }); - await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); - }); + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` + ); - const testCases: { name: string; direction: IndexDirection }[] = [ - { name: "descending", direction: -1 }, - { name: "ascending", direction: 1 }, - { name: "hashed", direction: "hashed" }, - { name: "text", direction: "text" }, - { name: "geoHaystack", direction: "2dsphere" }, - { name: "geo2d", direction: "2d" }, - ]; - - for (const { name, direction } of testCases) { - it(`creates ${name} index`, async () => { - await integration.connectMcpClient(); - const response = await integration.mcpClient().callTool({ + response = await integration.mcpClient().callTool({ name: "create-index", arguments: { database: integration.randomDbName(), collection: "coll1", - definition: [{ type: "classic", keys: { prop1: direction } }], + definition: [{ type: "classic", keys: { prop1: 1 } }], }, }); expect(getResponseContent(response.content)).toEqual( - `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` + `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"` ); - let expectedKey: object = { prop1: direction }; - if (direction === "text") { - expectedKey = { - _fts: "text", - _ftsx: 1, - }; - } - await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); - } - validateAutoConnectBehavior(integration, "create-index", () => { + const testCases: { name: string; direction: IndexDirection }[] = [ + { name: "descending", direction: -1 }, + { name: "ascending", direction: 1 }, + { name: "hashed", direction: "hashed" }, + { name: "text", direction: "text" }, + { name: "geoHaystack", direction: "2dsphere" }, + { name: "geo2d", direction: "2d" }, + ]; + + for (const { name, direction } of testCases) { + it(`creates ${name} index`, async () => { + await integration.connectMcpClient(); + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: direction } }], + }, + }); + + expect(getResponseContent(response.content)).toEqual( + `Created the index "prop1_${direction}" on collection "coll1" in database "${integration.randomDbName()}"` + ); + + let expectedKey: object = { prop1: direction }; + if (direction === "text") { + expectedKey = { + _fts: "text", + _ftsx: 1, + }; + } + await validateIndex("coll1", [{ name: `prop1_${direction}`, key: expectedKey }]); + }); + } + + validateAutoConnectBehavior(integration, "create-index", () => { + return { + args: { + database: integration.randomDbName(), + collection: "coll1", + definition: [{ type: "classic", keys: { prop1: 1 } }], + }, + expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, + }; + }); + }, + () => { return { - args: { - database: integration.randomDbName(), - collection: "coll1", - definition: [{ type: "classic", keys: { prop1: 1 } }], - }, - expectedResponse: `Created the index "prop1_1" on collection "coll1" in database "${integration.randomDbName()}"`, + ...defaultTestConfig, + voyageApiKey: "valid-key", }; - }); -}); + } +); describeWithMongoDB( "createIndex tool with vector search indexes", @@ -373,7 +464,12 @@ describeWithMongoDB( }); }); }, - undefined, + () => { + return { + ...defaultTestConfig, + voyageApiKey: "valid_key", + }; + }, undefined, { search: true, From 428d37342b70bfb6481e1b48af73b95a29835f8d Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 02:55:26 +0200 Subject: [PATCH 06/12] fix test --- src/tools/mongodb/create/createIndex.ts | 76 +++++++++++++------------ 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index c1ca390dd..3cdcb09be 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -15,42 +15,46 @@ const vectorSearchIndexDefinition = z.object({ fields: z .array( z.discriminatedUnion("type", [ - z.object({ - type: z.literal("filter"), - path: z - .string() - .describe( - "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" - ), - }), - z.object({ - type: z.literal("vector"), - path: z - .string() - .describe( - "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" - ), - numDimensions: z - .number() - .min(1) - .max(8192) - .describe( - "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" - ), - similarity: z - .enum(["cosine", "euclidean", "dotProduct"]) - .default("cosine") - .describe( - "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." - ), - quantization: z - .enum(["none", "scalar", "binary"]) - .optional() - .default("none") - .describe( - "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." - ), - }), + z + .object({ + type: z.literal("filter"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + }) + .strict(), + z + .object({ + type: z.literal("vector"), + path: z + .string() + .describe( + "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" + ), + numDimensions: z + .number() + .min(1) + .max(8192) + .describe( + "Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time" + ), + similarity: z + .enum(["cosine", "euclidean", "dotProduct"]) + .default("cosine") + .describe( + "Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields." + ), + quantization: z + .enum(["none", "scalar", "binary"]) + .optional() + .default("none") + .describe( + "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." + ), + }) + .strict(), ]) ) .nonempty() From f934235e7db736457dcfa1fe1b56f779dd346ff5 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 12:02:59 +0200 Subject: [PATCH 07/12] simplify the args definition --- src/tools/mongodb/create/createIndex.ts | 85 +++++++++++-------------- 1 file changed, 36 insertions(+), 49 deletions(-) diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index 3cdcb09be..6553a3854 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -1,15 +1,9 @@ -import type { ZodDiscriminatedUnionOption } from "zod"; import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; -import { type ToolArgs, type OperationType, type ToolConstructorParams, FeatureFlags } from "../../tool.js"; +import { type ToolArgs, type OperationType, FeatureFlags } from "../../tool.js"; import type { IndexDirection } from "mongodb"; -const classicIndexDefinition = z.object({ - type: z.literal("classic"), - keys: z.object({}).catchall(z.custom()).describe("The index definition"), -}); - const vectorSearchIndexDefinition = z.object({ type: z.literal("vectorSearch"), fields: z @@ -24,7 +18,8 @@ const vectorSearchIndexDefinition = z.object({ "Name of the field to index. For nested fields, use dot notation to specify path to embedded fields" ), }) - .strict(), + .strict() + .describe("Definition for a field that will be used for pre-filtering results."), z .object({ type: z.literal("vector"), @@ -54,7 +49,8 @@ const vectorSearchIndexDefinition = z.object({ "Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors." ), }) - .strict(), + .strict() + .describe("Definition for a field that contains vector embeddings."), ]) ) .nonempty() @@ -69,26 +65,23 @@ const vectorSearchIndexDefinition = z.object({ export class CreateIndexTool extends MongoDBToolBase { public name = "create-index"; protected description = "Create an index for a collection"; - protected argsShape; - - constructor(params: ToolConstructorParams) { - super(params); - - const additionalIndexDefinitions: ZodDiscriminatedUnionOption<"type">[] = []; - if (this.isFeatureFlagEnabled(FeatureFlags.VectorSearch)) { - additionalIndexDefinitions.push(vectorSearchIndexDefinition); - } - - this.argsShape = { - ...DbOperationArgs, - name: z.string().optional().describe("The name of the index"), - definition: z - .array(z.discriminatedUnion("type", [classicIndexDefinition, ...additionalIndexDefinitions])) - .describe( - "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" - ), - }; - } + protected argsShape = { + ...DbOperationArgs, + name: z.string().optional().describe("The name of the index"), + definition: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("classic"), + keys: z.object({}).catchall(z.custom()).describe("The index definition"), + }), + ...(this.isFeatureFlagEnabled(FeatureFlags.VectorSearch) ? [vectorSearchIndexDefinition] : []), + ]) + ) + .describe( + "The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes" + ), + }; public operationType: OperationType = "create"; @@ -107,29 +100,23 @@ export class CreateIndexTool extends MongoDBToolBase { switch (definition.type) { case "classic": - { - const typedDefinition = definition as z.infer; - indexes = await provider.createIndexes(database, collection, [ - { - key: typedDefinition.keys, - name, - }, - ]); - } + indexes = await provider.createIndexes(database, collection, [ + { + key: definition.keys, + name, + }, + ]); break; case "vectorSearch": - { - const typedDefinition = definition as z.infer; - indexes = await provider.createSearchIndexes(database, collection, [ - { - name, - definition: { - fields: typedDefinition.fields, - }, - type: "vectorSearch", + indexes = await provider.createSearchIndexes(database, collection, [ + { + name, + definition: { + fields: definition.fields, }, - ]); - } + type: "vectorSearch", + }, + ]); break; } From 6231b28c57f9be1ee604d83620ce116b00810380 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 12:19:32 +0200 Subject: [PATCH 08/12] fix accuracy tests --- tests/accuracy/createIndex.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index c4078a259..dd28afd4a 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -47,7 +47,7 @@ describeAccuracyTests([ ], }, { - prompt: "Create a vector search index on 'mydb.movies' namespace with on the 'plotSummary' field. The index should use 1024 dimensions.", + prompt: "Create a vector search index on 'mydb.movies' namespace on the 'plotSummary' field. The index should use 1024 dimensions.", expectedToolCalls: [ { toolName: "create-index", @@ -64,10 +64,6 @@ describeAccuracyTests([ path: "plotSummary", numDimensions: 1024, }, - { - type: "filter", - path: "releaseDate", - }, ], }, ], @@ -76,7 +72,7 @@ describeAccuracyTests([ ], }, { - prompt: "Create a vector search index on 'mydb.movies' namespace with on the 'plotSummary' field and 'genre' field. Pick a sensible number of dimensions for a voyage 3.5 model.", + prompt: "Create a vector search index on 'mydb.movies' namespace with on the 'plotSummary' field and 'genre' field, both of which contain vector embeddings. Pick a sensible number of dimensions for a voyage 3.5 model.", expectedToolCalls: [ { toolName: "create-index", @@ -94,6 +90,7 @@ describeAccuracyTests([ numDimensions: Matcher.number( (value) => value % 2 === 0 && value >= 256 && value <= 8192 ), + similarity: Matcher.anyOf(Matcher.undefined, Matcher.string()), }, { type: "vector", @@ -101,6 +98,7 @@ describeAccuracyTests([ numDimensions: Matcher.number( (value) => value % 2 === 0 && value >= 256 && value <= 8192 ), + similarity: Matcher.anyOf(Matcher.undefined, Matcher.string()), }, ], }, From 2d3b74e929716a565958a068609434b97afbc9cc Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 12:43:03 +0200 Subject: [PATCH 09/12] fix drop index accuracy test --- tests/accuracy/dropIndex.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/accuracy/dropIndex.test.ts b/tests/accuracy/dropIndex.test.ts index 48023af55..82e760756 100644 --- a/tests/accuracy/dropIndex.test.ts +++ b/tests/accuracy/dropIndex.test.ts @@ -40,9 +40,14 @@ describeAccuracyTests([ database: "mflix", collection: "movies", name: Matcher.anyOf(Matcher.undefined, Matcher.string()), - keys: { - title: "text", - }, + definition: [ + { + keys: { + title: "text", + }, + type: "classic", + }, + ], }, }, { From 75092a8ff985363efffa86ae357bc28550eb5e14 Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 13:42:13 +0200 Subject: [PATCH 10/12] apply copilot suggestion --- tests/accuracy/createIndex.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/accuracy/createIndex.test.ts b/tests/accuracy/createIndex.test.ts index dd28afd4a..becd5b464 100644 --- a/tests/accuracy/createIndex.test.ts +++ b/tests/accuracy/createIndex.test.ts @@ -1,7 +1,19 @@ +import { afterAll, beforeAll } from "vitest"; import { describeAccuracyTests } from "./sdk/describeAccuracyTests.js"; import { Matcher } from "./sdk/matcher.js"; -process.env.MDB_VOYAGE_API_KEY = "valid-key"; +let originalApiKey: string | undefined; +beforeAll(() => { + originalApiKey = process.env.MDB_VOYAGE_API_KEY; + + // We just need a valid key when registering the tool, the actual value is not important + if (!originalApiKey) { + process.env.MDB_VOYAGE_API_KEY = "valid-key"; + } +}); +afterAll(() => { + process.env.MDB_VOYAGE_API_KEY = originalApiKey; +}); describeAccuracyTests([ { From e62725874ca118f184204c5886108107042f5caf Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 15:50:05 +0200 Subject: [PATCH 11/12] add vector search detection and a more graceful error message --- src/common/connectionManager.ts | 53 ++++++++++++++----- src/common/session.ts | 22 ++++---- src/resources/common/debug.ts | 2 +- src/tools/mongodb/create/createIndex.ts | 38 ++++++++++--- src/tools/mongodb/mongodbTool.ts | 2 +- .../tools/mongodb/create/createIndex.test.ts | 30 +++++++++++ 6 files changed, 110 insertions(+), 37 deletions(-) diff --git a/src/common/connectionManager.ts b/src/common/connectionManager.ts index 1094f8453..9145441ea 100644 --- a/src/common/connectionManager.ts +++ b/src/common/connectionManager.ts @@ -32,9 +32,33 @@ export interface ConnectionState { connectedAtlasCluster?: AtlasClusterConnectionInfo; } -export interface ConnectionStateConnected extends ConnectionState { - tag: "connected"; - serviceProvider: NodeDriverServiceProvider; +export class ConnectionStateConnected implements ConnectionState { + public tag = "connected" as const; + + constructor( + public serviceProvider: NodeDriverServiceProvider, + public connectionStringAuthType?: ConnectionStringAuthType, + public connectedAtlasCluster?: AtlasClusterConnectionInfo + ) {} + + private _isSearchSupported?: boolean; + + public async isSearchSupported(): Promise { + if (this._isSearchSupported === undefined) { + try { + const dummyDatabase = `search-index-test-db-${Date.now()}`; + const dummyCollection = `search-index-test-coll-${Date.now()}`; + // If a cluster supports search indexes, the call below will succeed + // with a cursor otherwise will throw an Error + await this.serviceProvider.getSearchIndexes(dummyDatabase, dummyCollection); + this._isSearchSupported = true; + } catch { + this._isSearchSupported = false; + } + } + + return this._isSearchSupported; + } } export interface ConnectionStateConnecting extends ConnectionState { @@ -199,12 +223,10 @@ export class MCPConnectionManager extends ConnectionManager { }); } - return this.changeState("connection-success", { - tag: "connected", - connectedAtlasCluster: settings.atlas, - serviceProvider: await serviceProvider, - connectionStringAuthType, - }); + return this.changeState( + "connection-success", + new ConnectionStateConnected(await serviceProvider, connectionStringAuthType, settings.atlas) + ); } catch (error: unknown) { const errorReason = error instanceof Error ? error.message : `${error as string}`; this.changeState("connection-error", { @@ -270,11 +292,14 @@ export class MCPConnectionManager extends ConnectionManager { this.currentConnectionState.tag === "connecting" && this.currentConnectionState.connectionStringAuthType?.startsWith("oidc") ) { - this.changeState("connection-success", { - ...this.currentConnectionState, - tag: "connected", - serviceProvider: await this.currentConnectionState.serviceProvider, - }); + this.changeState( + "connection-success", + new ConnectionStateConnected( + await this.currentConnectionState.serviceProvider, + this.currentConnectionState.connectionStringAuthType, + this.currentConnectionState.connectedAtlasCluster + ) + ); } this.logger.info({ diff --git a/src/common/session.ts b/src/common/session.ts index 4ec536f4e..6a30cbf06 100644 --- a/src/common/session.ts +++ b/src/common/session.ts @@ -141,6 +141,15 @@ export class Session extends EventEmitter { return this.connectionManager.currentConnectionState.tag === "connected"; } + get isConnectedToMongot(): Promise { + const state = this.connectionManager.currentConnectionState; + if (state.tag === "connected") { + return state.isSearchSupported(); + } + + return Promise.resolve(false); + } + get serviceProvider(): NodeDriverServiceProvider { if (this.isConnectedToMongoDB) { const state = this.connectionManager.currentConnectionState as ConnectionStateConnected; @@ -153,17 +162,4 @@ export class Session extends EventEmitter { get connectedAtlasCluster(): AtlasClusterConnectionInfo | undefined { return this.connectionManager.currentConnectionState.connectedAtlasCluster; } - - async isSearchIndexSupported(): Promise { - try { - const dummyDatabase = `search-index-test-db-${Date.now()}`; - const dummyCollection = `search-index-test-coll-${Date.now()}`; - // If a cluster supports search indexes, the call below will succeed - // with a cursor otherwise will throw an Error - await this.serviceProvider.getSearchIndexes(dummyDatabase, dummyCollection); - return true; - } catch { - return false; - } - } } diff --git a/src/resources/common/debug.ts b/src/resources/common/debug.ts index f76030b5a..6f381b938 100644 --- a/src/resources/common/debug.ts +++ b/src/resources/common/debug.ts @@ -61,7 +61,7 @@ export class DebugResource extends ReactiveResource< switch (this.current.tag) { case "connected": { - const searchIndexesSupported = await this.session.isSearchIndexSupported(); + const searchIndexesSupported = await this.session.isConnectedToMongot; result += `The user is connected to the MongoDB cluster${searchIndexesSupported ? " with support for search indexes" : " without any support for search indexes"}.`; break; } diff --git a/src/tools/mongodb/create/createIndex.ts b/src/tools/mongodb/create/createIndex.ts index 6553a3854..305ab5769 100644 --- a/src/tools/mongodb/create/createIndex.ts +++ b/src/tools/mongodb/create/createIndex.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js"; +import type { ToolCategory } from "../../tool.js"; import { type ToolArgs, type OperationType, FeatureFlags } from "../../tool.js"; import type { IndexDirection } from "mongodb"; @@ -108,15 +109,36 @@ export class CreateIndexTool extends MongoDBToolBase { ]); break; case "vectorSearch": - indexes = await provider.createSearchIndexes(database, collection, [ - { - name, - definition: { - fields: definition.fields, + { + const isVectorSearchSupported = await this.session.isConnectedToMongot; + if (!isVectorSearchSupported) { + // TODO: remove hacky casts once we merge the local dev tools + const isLocalAtlasAvailable = + (this.server?.tools.filter((t) => t.category === ("atlas-local" as unknown as ToolCategory)) + .length ?? 0) > 0; + + const CTA = isLocalAtlasAvailable ? "`atlas-local` tools" : "Atlas CLI"; + return { + content: [ + { + text: `The connected MongoDB deployment does not support vector search indexes. Either connect to a MongoDB Atlas cluster or use the ${CTA} to create and manage a local Atlas deployment.`, + type: "text", + }, + ], + isError: true, + }; + } + + indexes = await provider.createSearchIndexes(database, collection, [ + { + name, + definition: { + fields: definition.fields, + }, + type: "vectorSearch", }, - type: "vectorSearch", - }, - ]); + ]); + } break; } diff --git a/src/tools/mongodb/mongodbTool.ts b/src/tools/mongodb/mongodbTool.ts index ded994ab3..2b9010364 100644 --- a/src/tools/mongodb/mongodbTool.ts +++ b/src/tools/mongodb/mongodbTool.ts @@ -13,7 +13,7 @@ export const DbOperationArgs = { }; export abstract class MongoDBToolBase extends ToolBase { - private server?: Server; + protected server?: Server; public category: ToolCategory = "mongodb"; protected async ensureConnected(): Promise { diff --git a/tests/integration/tools/mongodb/create/createIndex.test.ts b/tests/integration/tools/mongodb/create/createIndex.test.ts index 133ec2f2f..f54e89ad6 100644 --- a/tests/integration/tools/mongodb/create/createIndex.test.ts +++ b/tests/integration/tools/mongodb/create/createIndex.test.ts @@ -311,6 +311,36 @@ describeWithMongoDB( await validateIndex("coll1", [{ name: "prop1_1", key: { prop1: 1 } }]); }); + it("fails to create a vector search index", async () => { + await integration.connectMcpClient(); + const collection = new ObjectId().toString(); + await integration + .mcpServer() + .session.serviceProvider.createCollection(integration.randomDbName(), collection); + + const response = await integration.mcpClient().callTool({ + name: "create-index", + arguments: { + database: integration.randomDbName(), + collection, + name: "vector_1_vector", + definition: [ + { + type: "vectorSearch", + fields: [ + { type: "vector", path: "vector_1", numDimensions: 4 }, + { type: "filter", path: "category" }, + ], + }, + ], + }, + }); + + const content = getResponseContent(response.content); + expect(content).toContain("The connected MongoDB deployment does not support vector search indexes."); + expect(response.isError).toBe(true); + }); + const testCases: { name: string; direction: IndexDirection }[] = [ { name: "descending", direction: -1 }, { name: "ascending", direction: 1 }, From d887fbd9fb766b0207bf9604413a9eaf0656080f Mon Sep 17 00:00:00 2001 From: nirinchev Date: Tue, 14 Oct 2025 23:37:12 +0200 Subject: [PATCH 12/12] fix tests --- tests/unit/common/session.test.ts | 4 ++-- tests/unit/resources/common/debug.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/common/session.test.ts b/tests/unit/common/session.test.ts index ea6ac348b..40ae560dd 100644 --- a/tests/unit/common/session.test.ts +++ b/tests/unit/common/session.test.ts @@ -134,7 +134,7 @@ describe("Session", () => { await session.connectToMongoDB({ connectionString: "mongodb://localhost:27017", }); - expect(await session.isSearchIndexSupported()).toEqual(true); + expect(await session.isConnectedToMongot).toEqual(true); }); it("should return false if listing search indexes fail with search error", async () => { @@ -142,7 +142,7 @@ describe("Session", () => { await session.connectToMongoDB({ connectionString: "mongodb://localhost:27017", }); - expect(await session.isSearchIndexSupported()).toEqual(false); + expect(await session.isConnectedToMongot).toEqual(false); }); }); }); diff --git a/tests/unit/resources/common/debug.test.ts b/tests/unit/resources/common/debug.test.ts index 3a4c68e27..c7cf80615 100644 --- a/tests/unit/resources/common/debug.test.ts +++ b/tests/unit/resources/common/debug.test.ts @@ -103,7 +103,7 @@ describe("debug resource", () => { }); it("should notify if a cluster supports search indexes", async () => { - session.isSearchIndexSupported = vi.fn().mockResolvedValue(true); + vi.spyOn(session, "isConnectedToMongot", "get").mockImplementation(() => Promise.resolve(true)); debugResource.reduceApply("connect", undefined); const output = await debugResource.toOutput();