Skip to content

Commit a04c2f3

Browse files
committed
chore: Merge reliably search permission detection
1 parent 13c1c35 commit a04c2f3

File tree

18 files changed

+332
-154
lines changed

18 files changed

+332
-154
lines changed

src/common/connectionManager.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,39 +25,106 @@ export interface ConnectionSettings {
2525
type ConnectionTag = "connected" | "connecting" | "disconnected" | "errored";
2626
type OIDCConnectionAuthType = "oidc-auth-flow" | "oidc-device-flow";
2727
export type ConnectionStringAuthType = "scram" | "ldap" | "kerberos" | OIDCConnectionAuthType | "x.509";
28+
export type SearchAvailability = false | "not-available-yet" | "available";
2829

2930
export interface ConnectionState {
3031
tag: ConnectionTag;
3132
connectionStringAuthType?: ConnectionStringAuthType;
3233
connectedAtlasCluster?: AtlasClusterConnectionInfo;
3334
}
3435

36+
const MCP_TEST_DATABASE = "#mongodb-mcp";
37+
const SEARCH_AVAILABILITY_CHECK_TIMEOUT_MS = 500;
3538
export class ConnectionStateConnected implements ConnectionState {
3639
public tag = "connected" as const;
3740

3841
constructor(
3942
public serviceProvider: NodeDriverServiceProvider,
4043
public connectionStringAuthType?: ConnectionStringAuthType,
4144
public connectedAtlasCluster?: AtlasClusterConnectionInfo
42-
) {}
45+
) {
46+
this.#isSearchAvailable = false;
47+
}
48+
49+
#isSearchSupported?: boolean;
50+
#isSearchAvailable: boolean;
4351

44-
private _isSearchSupported?: boolean;
52+
public async getSearchAvailability(): Promise<SearchAvailability> {
53+
if ((await this.isSearchSupported()) === true) {
54+
if ((await this.isSearchAvailable()) === true) {
55+
return "available";
56+
}
57+
58+
return "not-available-yet";
59+
}
4560

46-
public async isSearchSupported(): Promise<boolean> {
47-
if (this._isSearchSupported === undefined) {
61+
return false;
62+
}
63+
64+
private async isSearchSupported(): Promise<boolean> {
65+
if (this.#isSearchSupported === undefined) {
4866
try {
49-
const dummyDatabase = "test";
50-
const dummyCollection = "test";
5167
// If a cluster supports search indexes, the call below will succeed
5268
// with a cursor otherwise will throw an Error
53-
await this.serviceProvider.getSearchIndexes(dummyDatabase, dummyCollection);
54-
this._isSearchSupported = true;
69+
await this.serviceProvider.getSearchIndexes(MCP_TEST_DATABASE, "test");
70+
this.#isSearchSupported = true;
5571
} catch {
56-
this._isSearchSupported = false;
72+
this.#isSearchSupported = false;
73+
}
74+
}
75+
76+
return this.#isSearchSupported;
77+
}
78+
79+
private async isSearchAvailable(): Promise<boolean> {
80+
if (this.#isSearchAvailable === true) {
81+
return true;
82+
}
83+
84+
const timeoutPromise = new Promise<boolean>((_resolve, reject) =>
85+
setTimeout(
86+
() =>
87+
reject(
88+
new MongoDBError(
89+
ErrorCodes.AtlasSearchNotAvailable,
90+
"Atlas Search is supported in your environment but is not available yet. Retry again later."
91+
)
92+
),
93+
SEARCH_AVAILABILITY_CHECK_TIMEOUT_MS
94+
)
95+
);
96+
97+
const checkPromise = new Promise<boolean>((resolve) => {
98+
void this.doCheckSearchIndexIsAvailable(resolve);
99+
});
100+
101+
return await Promise.race([checkPromise, timeoutPromise]);
102+
}
103+
104+
private async doCheckSearchIndexIsAvailable(resolve: (result: boolean) => void): Promise<void> {
105+
for (let i = 0; i < 100; i++) {
106+
try {
107+
try {
108+
await this.serviceProvider.insertOne(MCP_TEST_DATABASE, "test", { search: "search is available" });
109+
} catch (err) {
110+
// if inserting one document fails, it means we are in readOnly mode. We can't verify reliably if
111+
// Search is available, so assume it is.
112+
void err;
113+
resolve(true);
114+
return;
115+
}
116+
await this.serviceProvider.createSearchIndexes(MCP_TEST_DATABASE, "test", [
117+
{ definition: { mappings: { dynamic: true } } },
118+
]);
119+
await this.serviceProvider.dropDatabase(MCP_TEST_DATABASE);
120+
resolve(true);
121+
return;
122+
} catch (err) {
123+
void err;
57124
}
58125
}
59126

60-
return this._isSearchSupported;
127+
resolve(false);
61128
}
62129
}
63130

src/common/errors.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ export enum ErrorCodes {
33
MisconfiguredConnectionString = 1_000_001,
44
ForbiddenCollscan = 1_000_002,
55
ForbiddenWriteOperation = 1_000_003,
6-
AtlasSearchNotAvailable = 1_000_004,
6+
AtlasSearchNotSupported = 1_000_004,
7+
AtlasSearchNotAvailable = 1_000_005,
78
}
89

910
export class MongoDBError<ErrorCode extends ErrorCodes = ErrorCodes> extends Error {

src/common/search/vectorSearchEmbeddings.ts

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
22
import { BSON, type Document } from "bson";
33
import type { UserConfig } from "../config.js";
4+
import type { ConnectionManager } from "../connectionManager.js";
45

56
export type VectorFieldIndexDefinition = {
67
type: "vector";
@@ -14,8 +15,8 @@ export type EmbeddingNamespace = `${string}.${string}`;
1415
export class VectorSearchEmbeddings {
1516
constructor(
1617
private readonly config: UserConfig,
17-
private readonly embeddings: Map<EmbeddingNamespace, VectorFieldIndexDefinition[]> = new Map(),
18-
private readonly atlasSearchStatus: Map<string, boolean> = new Map()
18+
private readonly connectionManager: ConnectionManager,
19+
private readonly embeddings: Map<EmbeddingNamespace, VectorFieldIndexDefinition[]> = new Map()
1920
) {}
2021

2122
cleanupEmbeddingsForNamespace({ database, collection }: { database: string; collection: string }): void {
@@ -26,13 +27,12 @@ export class VectorSearchEmbeddings {
2627
async embeddingsForNamespace({
2728
database,
2829
collection,
29-
provider,
3030
}: {
3131
database: string;
3232
collection: string;
33-
provider: NodeDriverServiceProvider;
3433
}): Promise<VectorFieldIndexDefinition[]> {
35-
if (!(await this.isAtlasSearchAvailable(provider))) {
34+
const provider = await this.assertAtlasSearchIsAvailable();
35+
if (!provider) {
3636
return [];
3737
}
3838

@@ -64,15 +64,14 @@ export class VectorSearchEmbeddings {
6464
{
6565
database,
6666
collection,
67-
provider,
6867
}: {
6968
database: string;
7069
collection: string;
71-
provider: NodeDriverServiceProvider;
7270
},
7371
document: Document
7472
): Promise<VectorFieldIndexDefinition[]> {
75-
if (!(await this.isAtlasSearchAvailable(provider))) {
73+
const provider = await this.assertAtlasSearchIsAvailable();
74+
if (!provider) {
7675
return [];
7776
}
7877

@@ -83,25 +82,19 @@ export class VectorSearchEmbeddings {
8382
return [];
8483
}
8584

86-
const embeddings = await this.embeddingsForNamespace({ database, collection, provider });
85+
const embeddings = await this.embeddingsForNamespace({ database, collection });
8786
return embeddings.filter((emb) => !this.documentPassesEmbeddingValidation(emb, document));
8887
}
8988

90-
async isAtlasSearchAvailable(provider: NodeDriverServiceProvider): Promise<boolean> {
91-
const providerUri = provider.getURI();
92-
if (!providerUri) {
93-
// no URI? can't be cached
94-
return await this.canListAtlasSearchIndexes(provider);
95-
}
96-
97-
if (this.atlasSearchStatus.has(providerUri)) {
98-
// has should ensure that get is always defined
99-
return this.atlasSearchStatus.get(providerUri) ?? false;
89+
private async assertAtlasSearchIsAvailable(): Promise<NodeDriverServiceProvider | null> {
90+
const connectionState = this.connectionManager.currentConnectionState;
91+
if (connectionState.tag === "connected") {
92+
if ((await connectionState.getSearchAvailability()) === "available") {
93+
return connectionState.serviceProvider;
94+
}
10095
}
10196

102-
const availability = await this.canListAtlasSearchIndexes(provider);
103-
this.atlasSearchStatus.set(providerUri, availability);
104-
return availability;
97+
return null;
10598
}
10699

107100
private isVectorFieldIndexDefinition(doc: Document): doc is VectorFieldIndexDefinition {
@@ -160,15 +153,6 @@ export class VectorSearchEmbeddings {
160153
return true;
161154
}
162155

163-
private async canListAtlasSearchIndexes(provider: NodeDriverServiceProvider): Promise<boolean> {
164-
try {
165-
await provider.getSearchIndexes("test", "test");
166-
return true;
167-
} catch {
168-
return false;
169-
}
170-
}
171-
172156
private isANumber(value: unknown): boolean {
173157
if (typeof value === "number") {
174158
return true;

src/common/session.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ConnectionSettings,
1212
ConnectionStateConnected,
1313
ConnectionStateErrored,
14+
SearchAvailability,
1415
} from "./connectionManager.js";
1516
import type { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
1617
import { ErrorCodes, MongoDBError } from "./errors.js";
@@ -146,13 +147,32 @@ export class Session extends EventEmitter<SessionEvents> {
146147
return this.connectionManager.currentConnectionState.tag === "connected";
147148
}
148149

149-
isSearchSupported(): Promise<boolean> {
150+
async isSearchAvailable(): Promise<SearchAvailability> {
150151
const state = this.connectionManager.currentConnectionState;
151152
if (state.tag === "connected") {
152-
return state.isSearchSupported();
153+
return await state.getSearchAvailability();
153154
}
154155

155-
return Promise.resolve(false);
156+
return false;
157+
}
158+
159+
async assertSearchAvailable(): Promise<void> {
160+
const availability = await this.isSearchAvailable();
161+
if (!availability) {
162+
throw new MongoDBError(
163+
ErrorCodes.AtlasSearchNotSupported,
164+
"Atlas Search is not supported in the current cluster."
165+
);
166+
}
167+
168+
if (availability === "not-available-yet") {
169+
throw new MongoDBError(
170+
ErrorCodes.AtlasSearchNotAvailable,
171+
"Atlas Search is supported in the current cluster but not available yet."
172+
);
173+
}
174+
175+
return;
156176
}
157177

158178
get serviceProvider(): NodeDriverServiceProvider {

src/resources/common/debug.ts

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

6262
switch (this.current.tag) {
6363
case "connected": {
64-
const searchIndexesSupported = await this.session.isSearchSupported();
64+
const searchAvailability = await this.session.isSearchAvailable();
65+
const searchIndexesSupported = searchAvailability !== false;
6566
result += `The user is connected to the MongoDB cluster${searchIndexesSupported ? " with support for search indexes" : " without any support for search indexes"}.`;
6667
break;
6768
}

src/tools/mongodb/create/createIndex.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { z } from "zod";
22
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
4-
import type { ToolCategory } from "../../tool.js";
54
import { type ToolArgs, type OperationType, FeatureFlags } from "../../tool.js";
65
import type { IndexDirection } from "mongodb";
76

@@ -113,25 +112,7 @@ export class CreateIndexTool extends MongoDBToolBase {
113112
break;
114113
case "vectorSearch":
115114
{
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-
115+
await this.ensureSearchIsAvailable();
135116
indexes = await provider.createSearchIndexes(database, collection, [
136117
{
137118
name,

src/tools/mongodb/create/insertMany.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class InsertManyTool extends MongoDBToolBase {
2828
...(await Promise.all(
2929
documents.flatMap((document) =>
3030
this.session.vectorSearchEmbeddings.findFieldsWithWrongEmbeddings(
31-
{ database, collection, provider },
31+
{ database, collection },
3232
document
3333
)
3434
)

src/tools/mongodb/mongodbTool.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,8 @@ export abstract class MongoDBToolBase extends ToolBase {
4646
return this.session.serviceProvider;
4747
}
4848

49-
protected async ensureSearchAvailable(): Promise<NodeDriverServiceProvider> {
50-
const provider = await this.ensureConnected();
51-
if (!(await this.session.vectorSearchEmbeddings.isAtlasSearchAvailable(provider))) {
52-
throw new MongoDBError(
53-
ErrorCodes.AtlasSearchNotAvailable,
54-
"This MongoDB cluster does not support Search Indexes. Make sure you are using an Atlas Cluster, either remotely in Atlas or using the Atlas Local image, or your cluster supports MongoDB Search."
55-
);
56-
}
57-
58-
return provider;
49+
protected async ensureSearchIsAvailable(): Promise<void> {
50+
return await this.session.assertSearchAvailable();
5951
}
6052

6153
public register(server: Server): boolean {
@@ -94,6 +86,30 @@ export abstract class MongoDBToolBase extends ToolBase {
9486
],
9587
isError: true,
9688
};
89+
case ErrorCodes.AtlasSearchNotSupported: {
90+
const CTA = this.isToolCategoryAvailable("atlas-local" as unknown as ToolCategory)
91+
? "`atlas-local` tools"
92+
: "Atlas CLI";
93+
return {
94+
content: [
95+
{
96+
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.`,
97+
type: "text",
98+
},
99+
],
100+
isError: true,
101+
};
102+
}
103+
case ErrorCodes.AtlasSearchNotAvailable:
104+
return {
105+
content: [
106+
{
107+
text: `The connected MongoDB deployment does support vector search indexes but they are not ready yet. Try again later.`,
108+
type: "text",
109+
},
110+
],
111+
isError: true,
112+
};
97113
}
98114
}
99115

@@ -117,4 +133,8 @@ export abstract class MongoDBToolBase extends ToolBase {
117133

118134
return metadata;
119135
}
136+
137+
protected isToolCategoryAvailable(name: ToolCategory): boolean {
138+
return (this.server?.tools.filter((t) => t.category === name).length ?? 0) > 0;
139+
}
120140
}

src/tools/mongodb/search/listSearchIndexes.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export class ListSearchIndexesTool extends MongoDBToolBase {
1919
public operationType: OperationType = "metadata";
2020

2121
protected async execute({ database, collection }: ToolArgs<typeof DbOperationArgs>): Promise<CallToolResult> {
22-
const provider = await this.ensureSearchAvailable();
22+
const provider = await this.ensureConnected();
23+
await this.session.assertSearchAvailable();
24+
2325
const indexes = await provider.getSearchIndexes(database, collection);
2426
const trimmedIndexDefinitions = this.pickRelevantInformation(indexes);
2527

0 commit comments

Comments
 (0)