Skip to content

Commit a7720fe

Browse files
authored
feat: add ability to create vector search indexes MCP-234 (#621)
1 parent 9a2c002 commit a7720fe

File tree

17 files changed

+906
-276
lines changed

17 files changed

+906
-276
lines changed

src/common/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ export interface UserConfig extends CliOptions {
183183
maxBytesPerQuery: number;
184184
atlasTemporaryDatabaseUserLifetimeMs: number;
185185
voyageApiKey: string;
186+
vectorSearchDimensions: number;
187+
vectorSearchSimilarityFunction: "cosine" | "euclidean" | "dotProduct";
186188
}
187189

188190
export const defaultUserConfig: UserConfig = {
@@ -214,6 +216,8 @@ export const defaultUserConfig: UserConfig = {
214216
maxBytesPerQuery: 16 * 1024 * 1024, // By default, we only return ~16 mb of data per query / aggregation
215217
atlasTemporaryDatabaseUserLifetimeMs: 4 * 60 * 60 * 1000, // 4 hours
216218
voyageApiKey: "",
219+
vectorSearchDimensions: 1024,
220+
vectorSearchSimilarityFunction: "euclidean",
217221
};
218222

219223
export const config = setupUserConfig({

src/common/connectionManager.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,33 @@ export interface ConnectionState {
3232
connectedAtlasCluster?: AtlasClusterConnectionInfo;
3333
}
3434

35-
export interface ConnectionStateConnected extends ConnectionState {
36-
tag: "connected";
37-
serviceProvider: NodeDriverServiceProvider;
35+
export class ConnectionStateConnected implements ConnectionState {
36+
public tag = "connected" as const;
37+
38+
constructor(
39+
public serviceProvider: NodeDriverServiceProvider,
40+
public connectionStringAuthType?: ConnectionStringAuthType,
41+
public connectedAtlasCluster?: AtlasClusterConnectionInfo
42+
) {}
43+
44+
private _isSearchSupported?: boolean;
45+
46+
public async isSearchSupported(): Promise<boolean> {
47+
if (this._isSearchSupported === undefined) {
48+
try {
49+
const dummyDatabase = "test";
50+
const dummyCollection = "test";
51+
// If a cluster supports search indexes, the call below will succeed
52+
// with a cursor otherwise will throw an Error
53+
await this.serviceProvider.getSearchIndexes(dummyDatabase, dummyCollection);
54+
this._isSearchSupported = true;
55+
} catch {
56+
this._isSearchSupported = false;
57+
}
58+
}
59+
60+
return this._isSearchSupported;
61+
}
3862
}
3963

4064
export interface ConnectionStateConnecting extends ConnectionState {
@@ -199,12 +223,10 @@ export class MCPConnectionManager extends ConnectionManager {
199223
});
200224
}
201225

202-
return this.changeState("connection-success", {
203-
tag: "connected",
204-
connectedAtlasCluster: settings.atlas,
205-
serviceProvider: await serviceProvider,
206-
connectionStringAuthType,
207-
});
226+
return this.changeState(
227+
"connection-success",
228+
new ConnectionStateConnected(await serviceProvider, connectionStringAuthType, settings.atlas)
229+
);
208230
} catch (error: unknown) {
209231
const errorReason = error instanceof Error ? error.message : `${error as string}`;
210232
this.changeState("connection-error", {
@@ -270,11 +292,14 @@ export class MCPConnectionManager extends ConnectionManager {
270292
this.currentConnectionState.tag === "connecting" &&
271293
this.currentConnectionState.connectionStringAuthType?.startsWith("oidc")
272294
) {
273-
this.changeState("connection-success", {
274-
...this.currentConnectionState,
275-
tag: "connected",
276-
serviceProvider: await this.currentConnectionState.serviceProvider,
277-
});
295+
this.changeState(
296+
"connection-success",
297+
new ConnectionStateConnected(
298+
await this.currentConnectionState.serviceProvider,
299+
this.currentConnectionState.connectionStringAuthType,
300+
this.currentConnectionState.connectedAtlasCluster
301+
)
302+
);
278303
}
279304

280305
this.logger.info({

src/common/session.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ export class Session extends EventEmitter<SessionEvents> {
141141
return this.connectionManager.currentConnectionState.tag === "connected";
142142
}
143143

144+
isSearchSupported(): Promise<boolean> {
145+
const state = this.connectionManager.currentConnectionState;
146+
if (state.tag === "connected") {
147+
return state.isSearchSupported();
148+
}
149+
150+
return Promise.resolve(false);
151+
}
152+
144153
get serviceProvider(): NodeDriverServiceProvider {
145154
if (this.isConnectedToMongoDB) {
146155
const state = this.connectionManager.currentConnectionState as ConnectionStateConnected;
@@ -153,17 +162,4 @@ export class Session extends EventEmitter<SessionEvents> {
153162
get connectedAtlasCluster(): AtlasClusterConnectionInfo | undefined {
154163
return this.connectionManager.currentConnectionState.connectedAtlasCluster;
155164
}
156-
157-
async isSearchIndexSupported(): Promise<boolean> {
158-
try {
159-
const dummyDatabase = `search-index-test-db-${Date.now()}`;
160-
const dummyCollection = `search-index-test-coll-${Date.now()}`;
161-
// If a cluster supports search indexes, the call below will succeed
162-
// with a cursor otherwise will throw an Error
163-
await this.serviceProvider.getSearchIndexes(dummyDatabase, dummyCollection);
164-
return true;
165-
} catch {
166-
return false;
167-
}
168-
}
169165
}

src/resources/common/debug.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class DebugResource extends ReactiveResource<
6161

6262
switch (this.current.tag) {
6363
case "connected": {
64-
const searchIndexesSupported = await this.session.isSearchIndexSupported();
64+
const searchIndexesSupported = await this.session.isSearchSupported();
6565
result += `The user is connected to the MongoDB cluster${searchIndexesSupported ? " with support for search indexes" : " without any support for search indexes"}.`;
6666
break;
6767
}

src/tools/mongodb/create/createIndex.ts

Lines changed: 130 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,158 @@
11
import { z } from "zod";
22
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
4-
import type { ToolArgs, OperationType } from "../../tool.js";
4+
import type { ToolCategory } from "../../tool.js";
5+
import { type ToolArgs, type OperationType, FeatureFlags } from "../../tool.js";
56
import type { IndexDirection } from "mongodb";
67

78
export class CreateIndexTool extends MongoDBToolBase {
9+
private vectorSearchIndexDefinition = z.object({
10+
type: z.literal("vectorSearch"),
11+
fields: z
12+
.array(
13+
z.discriminatedUnion("type", [
14+
z
15+
.object({
16+
type: z.literal("filter"),
17+
path: z
18+
.string()
19+
.describe(
20+
"Name of the field to index. For nested fields, use dot notation to specify path to embedded fields"
21+
),
22+
})
23+
.strict()
24+
.describe("Definition for a field that will be used for pre-filtering results."),
25+
z
26+
.object({
27+
type: z.literal("vector"),
28+
path: z
29+
.string()
30+
.describe(
31+
"Name of the field to index. For nested fields, use dot notation to specify path to embedded fields"
32+
),
33+
numDimensions: z
34+
.number()
35+
.min(1)
36+
.max(8192)
37+
.default(this.config.vectorSearchDimensions)
38+
.describe(
39+
"Number of vector dimensions that MongoDB Vector Search enforces at index-time and query-time"
40+
),
41+
similarity: z
42+
.enum(["cosine", "euclidean", "dotProduct"])
43+
.default(this.config.vectorSearchSimilarityFunction)
44+
.describe(
45+
"Vector similarity function to use to search for top K-nearest neighbors. You can set this field only for vector-type fields."
46+
),
47+
quantization: z
48+
.enum(["none", "scalar", "binary"])
49+
.optional()
50+
.default("none")
51+
.describe(
52+
"Type of automatic vector quantization for your vectors. Use this setting only if your embeddings are float or double vectors."
53+
),
54+
})
55+
.strict()
56+
.describe("Definition for a field that contains vector embeddings."),
57+
])
58+
)
59+
.nonempty()
60+
.refine((fields) => fields.some((f) => f.type === "vector"), {
61+
message: "At least one vector field must be defined",
62+
})
63+
.describe(
64+
"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."
65+
),
66+
});
67+
868
public name = "create-index";
969
protected description = "Create an index for a collection";
1070
protected argsShape = {
1171
...DbOperationArgs,
12-
keys: z.object({}).catchall(z.custom<IndexDirection>()).describe("The index definition"),
1372
name: z.string().optional().describe("The name of the index"),
73+
definition: z
74+
.array(
75+
z.discriminatedUnion("type", [
76+
z.object({
77+
type: z.literal("classic"),
78+
keys: z.object({}).catchall(z.custom<IndexDirection>()).describe("The index definition"),
79+
}),
80+
...(this.isFeatureFlagEnabled(FeatureFlags.VectorSearch) ? [this.vectorSearchIndexDefinition] : []),
81+
])
82+
)
83+
.describe(
84+
"The index definition. Use 'classic' for standard indexes and 'vectorSearch' for vector search indexes"
85+
),
1486
};
1587

1688
public operationType: OperationType = "create";
1789

1890
protected async execute({
1991
database,
2092
collection,
21-
keys,
2293
name,
94+
definition: definitions,
2395
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
2496
const provider = await this.ensureConnected();
25-
const indexes = await provider.createIndexes(database, collection, [
26-
{
27-
key: keys,
28-
name,
29-
},
30-
]);
97+
let indexes: string[] = [];
98+
const definition = definitions[0];
99+
if (!definition) {
100+
throw new Error("Index definition not provided. Expected one of the following: `classic`, `vectorSearch`");
101+
}
102+
103+
let responseClarification = "";
104+
105+
switch (definition.type) {
106+
case "classic":
107+
indexes = await provider.createIndexes(database, collection, [
108+
{
109+
key: definition.keys,
110+
name,
111+
},
112+
]);
113+
break;
114+
case "vectorSearch":
115+
{
116+
const isVectorSearchSupported = await this.session.isSearchSupported();
117+
if (!isVectorSearchSupported) {
118+
// TODO: remove hacky casts once we merge the local dev tools
119+
const isLocalAtlasAvailable =
120+
(this.server?.tools.filter((t) => t.category === ("atlas-local" as unknown as ToolCategory))
121+
.length ?? 0) > 0;
122+
123+
const CTA = isLocalAtlasAvailable ? "`atlas-local` tools" : "Atlas CLI";
124+
return {
125+
content: [
126+
{
127+
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.`,
128+
type: "text",
129+
},
130+
],
131+
isError: true,
132+
};
133+
}
134+
135+
indexes = await provider.createSearchIndexes(database, collection, [
136+
{
137+
name,
138+
definition: {
139+
fields: definition.fields,
140+
},
141+
type: "vectorSearch",
142+
},
143+
]);
144+
145+
responseClarification =
146+
" Since this is a vector search index, it may take a while for the index to build. Use the `list-indexes` tool to check the index status.";
147+
}
148+
149+
break;
150+
}
31151

32152
return {
33153
content: [
34154
{
35-
text: `Created the index "${indexes[0]}" on collection "${collection}" in database "${database}"`,
155+
text: `Created the index "${indexes[0]}" on collection "${collection}" in database "${database}".${responseClarification}`,
36156
type: "text",
37157
},
38158
],

src/tools/mongodb/mongodbTool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const DbOperationArgs = {
1313
};
1414

1515
export abstract class MongoDBToolBase extends ToolBase {
16-
private server?: Server;
16+
protected server?: Server;
1717
public category: ToolCategory = "mongodb";
1818

1919
protected async ensureConnected(): Promise<NodeDriverServiceProvider> {

src/tools/tool.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export type ToolCallbackArgs<Args extends ZodRawShape> = Parameters<ToolCallback
1515

1616
export type ToolExecutionContext<Args extends ZodRawShape = ZodRawShape> = Parameters<ToolCallback<Args>>[1];
1717

18+
export const enum FeatureFlags {
19+
VectorSearch = "vectorSearch",
20+
}
21+
1822
/**
1923
* The type of operation the tool performs. This is used when evaluating if a tool is allowed to run based on
2024
* the config's `disabledTools` and `readOnly` settings.
@@ -314,6 +318,16 @@ export abstract class ToolBase {
314318

315319
this.telemetry.emitEvents([event]);
316320
}
321+
322+
// TODO: Move this to a separate file
323+
protected isFeatureFlagEnabled(flag: FeatureFlags): boolean {
324+
switch (flag) {
325+
case FeatureFlags.VectorSearch:
326+
return this.config.voyageApiKey !== "";
327+
default:
328+
return false;
329+
}
330+
}
317331
}
318332

319333
/**

0 commit comments

Comments
 (0)