Skip to content

Commit 3186188

Browse files
committed
feat(index-check): add index check functionality before query
1 parent 54effbb commit 3186188

File tree

10 files changed

+252
-1
lines changed

10 files changed

+252
-1
lines changed

.smithery/smithery.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,17 @@ startCommand:
2424
title: Read-only
2525
description: When set to true, only allows read and metadata operation types, disabling create/update/delete operations.
2626
default: false
27+
indexCheck:
28+
type: boolean
29+
title: Index Check
30+
description: When set to true, enforces that query operations must use an index, rejecting queries that would perform a collection scan.
31+
default: false
2732
exampleConfig:
2833
atlasClientId: YOUR_ATLAS_CLIENT_ID
2934
atlasClientSecret: YOUR_ATLAS_CLIENT_SECRET
3035
connectionString: mongodb+srv://USERNAME:PASSWORD@YOUR_CLUSTER.mongodb.net
3136
readOnly: true
37+
indexCheck: false
3238

3339
commandFunction:
3440
# A function that produces the CLI command to start the MCP on stdio.
@@ -54,6 +60,10 @@ startCommand:
5460
args.push('--connectionString');
5561
args.push(config.connectionString);
5662
}
63+
64+
if (config.indexCheck) {
65+
args.push('--indexCheck');
66+
}
5767
}
5868

5969
return {

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow
267267
| `logPath` | Folder to store logs. |
268268
| `disabledTools` | An array of tool names, operation types, and/or categories of tools that will be disabled. |
269269
| `readOnly` | When set to true, only allows read and metadata operation types, disabling create/update/delete operations. |
270+
| `indexCheck` | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. |
270271
| `telemetry` | When set to disabled, disables telemetry collection. |
271272

272273
#### Log Path
@@ -312,6 +313,19 @@ You can enable read-only mode using:
312313

313314
When read-only mode is active, you'll see a message in the server logs indicating which tools were prevented from registering due to this restriction.
314315

316+
#### Index Check Mode
317+
318+
The `indexCheck` configuration option allows you to enforce that query operations must use an index. When enabled, queries that perform a collection scan will be rejected to ensure better performance.
319+
320+
This is useful for scenarios where you want to ensure that database queries are optimized.
321+
322+
You can enable index check mode using:
323+
324+
- **Environment variable**: `export MDB_MCP_INDEX_CHECK=true`
325+
- **Command-line argument**: `--indexCheck`
326+
327+
When index check mode is active, you'll see an error message if a query is rejected due to not using an index.
328+
315329
#### Telemetry
316330

317331
The `telemetry` configuration option allows you to disable telemetry collection. When enabled, the MCP server will collect usage data and send it to MongoDB.
@@ -430,7 +444,7 @@ export MDB_MCP_LOG_PATH="/path/to/logs"
430444
Pass configuration options as command-line arguments when starting the server:
431445

432446
```shell
433-
npx -y mongodb-mcp-server --apiClientId="your-atlas-service-accounts-client-id" --apiClientSecret="your-atlas-service-accounts-client-secret" --connectionString="mongodb+srv://username:[email protected]/myDatabase" --logPath=/path/to/logs
447+
npx -y mongodb-mcp-server --apiClientId="your-atlas-service-accounts-client-id" --apiClientSecret="your-atlas-service-accounts-client-secret" --connectionString="mongodb+srv://username:[email protected]/myDatabase" --logPath=/path/to/logs --readOnly --indexCheck
434448
```
435449

436450
#### MCP configuration file examples

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface UserConfig {
2323
connectOptions: ConnectOptions;
2424
disabledTools: Array<string>;
2525
readOnly?: boolean;
26+
indexCheck?: boolean;
2627
}
2728

2829
const defaults: UserConfig = {
@@ -37,6 +38,7 @@ const defaults: UserConfig = {
3738
disabledTools: [],
3839
telemetry: "enabled",
3940
readOnly: false,
41+
indexCheck: false,
4042
};
4143

4244
export const config = {

src/helpers/indexCheck.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Document } from "mongodb";
2+
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
3+
4+
/**
5+
* Check if the query plan uses an index
6+
* @param explainResult The result of the explain query
7+
* @returns true if an index is used, false if it's a full collection scan
8+
*/
9+
export function usesIndex(explainResult: Document): boolean {
10+
const stage = explainResult?.queryPlanner?.winningPlan?.stage;
11+
const inputStage = explainResult?.queryPlanner?.winningPlan?.inputStage;
12+
13+
if (stage === "IXSCAN" || stage === "COUNT_SCAN") {
14+
return true;
15+
}
16+
17+
if (inputStage && (inputStage.stage === "IXSCAN" || inputStage.stage === "COUNT_SCAN")) {
18+
return true;
19+
}
20+
21+
// Recursively check deeper stages
22+
if (inputStage && inputStage.inputStage) {
23+
return usesIndex({ queryPlanner: { winningPlan: inputStage } });
24+
}
25+
26+
if (stage === "COLLSCAN") {
27+
return false;
28+
}
29+
30+
// Default to false (conservative approach)
31+
return false;
32+
}
33+
34+
/**
35+
* Generate an error message for index check failure
36+
*/
37+
export function getIndexCheckErrorMessage(database: string, collection: string, operation: string): string {
38+
return `Index check failed: The ${operation} operation on "${database}.${collection}" performs a collection scan (COLLSCAN) instead of using an index. Consider adding an index for better performance. Use 'explain' tool for query plan analysis or 'collection-indexes' to view existing indexes. To disable this check, set MDB_MCP_INDEX_CHECK to false.`;
39+
}
40+
41+
/**
42+
* Generic function to perform index usage check
43+
*/
44+
export async function checkIndexUsage(
45+
provider: NodeDriverServiceProvider,
46+
database: string,
47+
collection: string,
48+
operation: string,
49+
explainCallback: () => Promise<Document>
50+
): Promise<void> {
51+
try {
52+
const explainResult = await explainCallback();
53+
54+
if (!usesIndex(explainResult)) {
55+
throw new Error(getIndexCheckErrorMessage(database, collection, operation));
56+
}
57+
} catch (error) {
58+
if (error instanceof Error && error.message.includes("Index check failed")) {
59+
throw error;
60+
}
61+
62+
// If explain itself fails, log but do not prevent query execution
63+
// This avoids blocking normal queries in special cases (e.g., permission issues)
64+
console.warn(`Index check failed to execute explain for ${operation} on ${database}.${collection}:`, error);
65+
}
66+
}

src/tools/mongodb/delete/deleteMany.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod";
22
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
5+
import { checkIndexUsage } from "../../../helpers/indexCheck.js";
56

67
export class DeleteManyTool extends MongoDBToolBase {
78
protected name = "delete-many";
@@ -23,6 +24,25 @@ export class DeleteManyTool extends MongoDBToolBase {
2324
filter,
2425
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
2526
const provider = await this.ensureConnected();
27+
28+
// Check if delete operation uses an index if enabled
29+
if (this.config.indexCheck) {
30+
await checkIndexUsage(provider, database, collection, "deleteMany", async () => {
31+
return provider.mongoClient.db(database).command({
32+
explain: {
33+
delete: collection,
34+
deletes: [
35+
{
36+
q: filter || {},
37+
limit: 0, // 0 means delete all matching documents
38+
},
39+
],
40+
},
41+
verbosity: "queryPlanner",
42+
});
43+
});
44+
}
45+
2646
const result = await provider.deleteMany(database, collection, filter);
2747

2848
return {

src/tools/mongodb/read/aggregate.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
55
import { EJSON } from "bson";
6+
import { checkIndexUsage } from "../../../helpers/indexCheck.js";
67

78
export const AggregateArgs = {
89
pipeline: z.array(z.record(z.string(), z.unknown())).describe("An array of aggregation stages to execute"),
@@ -23,6 +24,16 @@ export class AggregateTool extends MongoDBToolBase {
2324
pipeline,
2425
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
2526
const provider = await this.ensureConnected();
27+
28+
// Check if aggregate operation uses an index if enabled
29+
if (this.config.indexCheck) {
30+
await checkIndexUsage(provider, database, collection, "aggregate", async () => {
31+
return provider
32+
.aggregate(database, collection, pipeline, {}, { writeConcern: undefined })
33+
.explain("queryPlanner");
34+
});
35+
}
36+
2637
const documents = await provider.aggregate(database, collection, pipeline).toArray();
2738

2839
const content: Array<{ text: string; type: "text" }> = [

src/tools/mongodb/read/count.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
22
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
33
import { ToolArgs, OperationType } from "../../tool.js";
44
import { z } from "zod";
5+
import { checkIndexUsage } from "../../../helpers/indexCheck.js";
56

67
export const CountArgs = {
78
query: z
@@ -25,6 +26,20 @@ export class CountTool extends MongoDBToolBase {
2526

2627
protected async execute({ database, collection, query }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
2728
const provider = await this.ensureConnected();
29+
30+
// Check if count operation uses an index if enabled
31+
if (this.config.indexCheck) {
32+
await checkIndexUsage(provider, database, collection, "count", async () => {
33+
return provider.mongoClient.db(database).command({
34+
explain: {
35+
count: collection,
36+
query,
37+
},
38+
verbosity: "queryPlanner",
39+
});
40+
});
41+
}
42+
2843
const count = await provider.count(database, collection, query);
2944

3045
return {

src/tools/mongodb/read/find.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
55
import { SortDirection } from "mongodb";
66
import { EJSON } from "bson";
7+
import { checkIndexUsage } from "../../../helpers/indexCheck.js";
78

89
export const FindArgs = {
910
filter: z
@@ -39,6 +40,16 @@ export class FindTool extends MongoDBToolBase {
3940
sort,
4041
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
4142
const provider = await this.ensureConnected();
43+
44+
// Check if find operation uses an index if enabled
45+
if (this.config.indexCheck) {
46+
await checkIndexUsage(provider, database, collection, "find", async () => {
47+
return provider
48+
.find(database, collection, filter, { projection, limit, sort })
49+
.explain("queryPlanner");
50+
});
51+
}
52+
4253
const documents = await provider.find(database, collection, filter, { projection, limit, sort }).toArray();
4354

4455
const content: Array<{ text: string; type: "text" }> = [

src/tools/mongodb/update/updateMany.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod";
22
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
33
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
44
import { ToolArgs, OperationType } from "../../tool.js";
5+
import { checkIndexUsage } from "../../../helpers/indexCheck.js";
56

67
export class UpdateManyTool extends MongoDBToolBase {
78
protected name = "update-many";
@@ -32,6 +33,27 @@ export class UpdateManyTool extends MongoDBToolBase {
3233
upsert,
3334
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
3435
const provider = await this.ensureConnected();
36+
37+
// Check if update operation uses an index if enabled
38+
if (this.config.indexCheck) {
39+
await checkIndexUsage(provider, database, collection, "updateMany", async () => {
40+
return provider.mongoClient.db(database).command({
41+
explain: {
42+
update: collection,
43+
updates: [
44+
{
45+
q: filter || {},
46+
u: update,
47+
upsert: upsert || false,
48+
multi: true,
49+
},
50+
],
51+
},
52+
verbosity: "queryPlanner",
53+
});
54+
});
55+
}
56+
3557
const result = await provider.updateMany(database, collection, filter, update, {
3658
upsert,
3759
});

tests/unit/indexCheck.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { usesIndex, getIndexCheckErrorMessage } from "../../src/helpers/indexCheck.js";
2+
import { Document } from "mongodb";
3+
4+
describe("indexCheck", () => {
5+
describe("usesIndex", () => {
6+
it("should return true for IXSCAN", () => {
7+
const explainResult: Document = {
8+
queryPlanner: {
9+
winningPlan: {
10+
stage: "IXSCAN",
11+
},
12+
},
13+
};
14+
expect(usesIndex(explainResult)).toBe(true);
15+
});
16+
17+
it("should return true for COUNT_SCAN", () => {
18+
const explainResult: Document = {
19+
queryPlanner: {
20+
winningPlan: {
21+
stage: "COUNT_SCAN",
22+
},
23+
},
24+
};
25+
expect(usesIndex(explainResult)).toBe(true);
26+
});
27+
28+
it("should return false for COLLSCAN", () => {
29+
const explainResult: Document = {
30+
queryPlanner: {
31+
winningPlan: {
32+
stage: "COLLSCAN",
33+
},
34+
},
35+
};
36+
expect(usesIndex(explainResult)).toBe(false);
37+
});
38+
39+
it("should return true for nested IXSCAN in inputStage", () => {
40+
const explainResult: Document = {
41+
queryPlanner: {
42+
winningPlan: {
43+
stage: "LIMIT",
44+
inputStage: {
45+
stage: "IXSCAN",
46+
},
47+
},
48+
},
49+
};
50+
expect(usesIndex(explainResult)).toBe(true);
51+
});
52+
53+
it("should return false for unknown stage types", () => {
54+
const explainResult: Document = {
55+
queryPlanner: {
56+
winningPlan: {
57+
stage: "UNKNOWN_STAGE",
58+
},
59+
},
60+
};
61+
expect(usesIndex(explainResult)).toBe(false);
62+
});
63+
64+
it("should handle missing queryPlanner", () => {
65+
const explainResult: Document = {};
66+
expect(usesIndex(explainResult)).toBe(false);
67+
});
68+
});
69+
70+
describe("getIndexCheckErrorMessage", () => {
71+
it("should generate appropriate error message", () => {
72+
const message = getIndexCheckErrorMessage("testdb", "testcoll", "find");
73+
expect(message).toContain("Index check failed");
74+
expect(message).toContain("testdb.testcoll");
75+
expect(message).toContain("find operation");
76+
expect(message).toContain("collection scan (COLLSCAN)");
77+
expect(message).toContain("MDB_MCP_INDEX_CHECK");
78+
});
79+
});
80+
});

0 commit comments

Comments
 (0)