Skip to content

feat(index-check): add index check functionality before query #309

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .smithery/smithery.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ startCommand:
title: Read-only
description: When set to true, only allows read and metadata operation types, disabling create/update/delete operations.
default: false
indexCheck:
type: boolean
title: Index Check
description: When set to true, enforces that query operations must use an index, rejecting queries that would perform a collection scan.
default: false
exampleConfig:
atlasClientId: YOUR_ATLAS_CLIENT_ID
atlasClientSecret: YOUR_ATLAS_CLIENT_SECRET
connectionString: mongodb+srv://USERNAME:PASSWORD@YOUR_CLUSTER.mongodb.net
readOnly: true
indexCheck: false

commandFunction:
# A function that produces the CLI command to start the MCP on stdio.
Expand All @@ -54,6 +60,10 @@ startCommand:
args.push('--connectionString');
args.push(config.connectionString);
}

if (config.indexCheck) {
args.push('--indexCheck');
}
}

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

#### Log Path
Expand Down Expand Up @@ -312,6 +313,19 @@ You can enable read-only mode using:

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.

#### Index Check Mode

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.

This is useful for scenarios where you want to ensure that database queries are optimized.

You can enable index check mode using:

- **Environment variable**: `export MDB_MCP_INDEX_CHECK=true`
- **Command-line argument**: `--indexCheck`

When index check mode is active, you'll see an error message if a query is rejected due to not using an index.

#### Telemetry

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

```shell
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
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
```

#### MCP configuration file examples
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface UserConfig {
connectOptions: ConnectOptions;
disabledTools: Array<string>;
readOnly?: boolean;
indexCheck?: boolean;
}

const defaults: UserConfig = {
Expand All @@ -37,6 +38,7 @@ const defaults: UserConfig = {
disabledTools: [],
telemetry: "enabled",
readOnly: false,
indexCheck: false,
};

export const config = {
Expand Down
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export enum ErrorCodes {
NotConnectedToMongoDB = 1_000_000,
MisconfiguredConnectionString = 1_000_001,
ForbiddenCollscan = 1_000_002,
}

export class MongoDBError extends Error {
Expand Down
83 changes: 83 additions & 0 deletions src/helpers/indexCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Document } from "mongodb";
import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver";
import { ErrorCodes, MongoDBError } from "../errors.js";

/**
* Check if the query plan uses an index
* @param explainResult The result of the explain query
* @returns true if an index is used, false if it's a full collection scan
*/
export function usesIndex(explainResult: Document): boolean {
const queryPlanner = explainResult?.queryPlanner as Document | undefined;
const winningPlan = queryPlanner?.winningPlan as Document | undefined;
const stage = winningPlan?.stage as string | undefined;
const inputStage = winningPlan?.inputStage as Document | undefined;

// Check for index scan stages (including MongoDB 8.0+ stages)
const indexScanStages = [
"IXSCAN",
"COUNT_SCAN",
"EXPRESS_IXSCAN",
"EXPRESS_CLUSTERED_IXSCAN",
"EXPRESS_UPDATE",
"EXPRESS_DELETE",
"IDHACK",
];

if (stage && indexScanStages.includes(stage)) {
return true;
}

if (inputStage && inputStage.stage && indexScanStages.includes(inputStage.stage as string)) {
return true;
}

// Recursively check deeper stages
if (inputStage && inputStage.inputStage) {
return usesIndex({ queryPlanner: { winningPlan: inputStage } });
}

if (stage === "COLLSCAN") {
return false;
}

// Default to false (conservative approach)
return false;
}

/**
* Generate an error message for index check failure
*/
export function getIndexCheckErrorMessage(database: string, collection: string, operation: string): string {
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.`;
}

/**
* Generic function to perform index usage check
*/
export async function checkIndexUsage(
provider: NodeDriverServiceProvider,
database: string,
collection: string,
operation: string,
explainCallback: () => Promise<Document>
): Promise<void> {
try {
const explainResult = await explainCallback();

if (!usesIndex(explainResult)) {
throw new MongoDBError(
ErrorCodes.ForbiddenCollscan,
getIndexCheckErrorMessage(database, collection, operation)
);
}
} catch (error) {
if (error instanceof MongoDBError && error.code === ErrorCodes.ForbiddenCollscan) {
throw error;
}

// If explain itself fails, log but do not prevent query execution
// This avoids blocking normal queries in special cases (e.g., permission issues)
console.warn(`Index check failed to execute explain for ${operation} on ${database}.${collection}:`, error);
}
}
20 changes: 20 additions & 0 deletions src/tools/mongodb/delete/deleteMany.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { checkIndexUsage } from "../../../helpers/indexCheck.js";

export class DeleteManyTool extends MongoDBToolBase {
protected name = "delete-many";
Expand All @@ -23,6 +24,25 @@ export class DeleteManyTool extends MongoDBToolBase {
filter,
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();

// Check if delete operation uses an index if enabled
if (this.config.indexCheck) {
await checkIndexUsage(provider, database, collection, "deleteMany", async () => {
return provider.runCommandWithCheck(database, {
explain: {
delete: collection,
deletes: [
{
q: filter || {},
limit: 0, // 0 means delete all matching documents
},
],
},
verbosity: "queryPlanner",
});
});
}

const result = await provider.deleteMany(database, collection, filter);

return {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/mongodb/metadata/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class ExplainTool extends MongoDBToolBase {
}
case "count": {
const { query } = method.arguments;
result = await provider.mongoClient.db(database).command({
result = await provider.runCommandWithCheck(database, {
explain: {
count: collection,
query,
Expand Down
10 changes: 10 additions & 0 deletions src/tools/mongodb/mongodbTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ export abstract class MongoDBToolBase extends ToolBase {
],
isError: true,
};
case ErrorCodes.ForbiddenCollscan:
return {
content: [
{
type: "text",
text: error.message,
},
],
isError: true,
};
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/tools/mongodb/read/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { EJSON } from "bson";
import { checkIndexUsage } from "../../../helpers/indexCheck.js";

export const AggregateArgs = {
pipeline: z.array(z.record(z.string(), z.unknown())).describe("An array of aggregation stages to execute"),
Expand All @@ -23,6 +24,16 @@ export class AggregateTool extends MongoDBToolBase {
pipeline,
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();

// Check if aggregate operation uses an index if enabled
if (this.config.indexCheck) {
await checkIndexUsage(provider, database, collection, "aggregate", async () => {
return provider
.aggregate(database, collection, pipeline, {}, { writeConcern: undefined })
.explain("queryPlanner");
});
}

const documents = await provider.aggregate(database, collection, pipeline).toArray();

const content: Array<{ text: string; type: "text" }> = [
Expand Down
15 changes: 15 additions & 0 deletions src/tools/mongodb/read/count.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { z } from "zod";
import { checkIndexUsage } from "../../../helpers/indexCheck.js";

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

protected async execute({ database, collection, query }: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();

// Check if count operation uses an index if enabled
if (this.config.indexCheck) {
await checkIndexUsage(provider, database, collection, "count", async () => {
return provider.runCommandWithCheck(database, {
explain: {
count: collection,
query,
},
verbosity: "queryPlanner",
});
});
}

const count = await provider.count(database, collection, query);

return {
Expand Down
9 changes: 9 additions & 0 deletions src/tools/mongodb/read/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { SortDirection } from "mongodb";
import { EJSON } from "bson";
import { checkIndexUsage } from "../../../helpers/indexCheck.js";

export const FindArgs = {
filter: z
Expand Down Expand Up @@ -39,6 +40,14 @@ export class FindTool extends MongoDBToolBase {
sort,
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();

// Check if find operation uses an index if enabled
if (this.config.indexCheck) {
await checkIndexUsage(provider, database, collection, "find", async () => {
return provider.find(database, collection, filter, { projection, limit, sort }).explain("queryPlanner");
});
}

const documents = await provider.find(database, collection, filter, { projection, limit, sort }).toArray();

const content: Array<{ text: string; type: "text" }> = [
Expand Down
22 changes: 22 additions & 0 deletions src/tools/mongodb/update/updateMany.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { DbOperationArgs, MongoDBToolBase } from "../mongodbTool.js";
import { ToolArgs, OperationType } from "../../tool.js";
import { checkIndexUsage } from "../../../helpers/indexCheck.js";

export class UpdateManyTool extends MongoDBToolBase {
protected name = "update-many";
Expand Down Expand Up @@ -32,6 +33,27 @@ export class UpdateManyTool extends MongoDBToolBase {
upsert,
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
const provider = await this.ensureConnected();

// Check if update operation uses an index if enabled
if (this.config.indexCheck) {
await checkIndexUsage(provider, database, collection, "updateMany", async () => {
return provider.runCommandWithCheck(database, {
explain: {
update: collection,
updates: [
{
q: filter || {},
u: update,
upsert: upsert || false,
multi: true,
},
],
},
verbosity: "queryPlanner",
});
});
}

const result = await provider.updateMany(database, collection, filter, update, {
upsert,
});
Expand Down
Loading
Loading