-
Notifications
You must be signed in to change notification settings - Fork 129
fix: add guards against possible memory overflow in find and aggregate tools MCP-21 #536
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
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
4e7d98c
fix: add guards against possible memory overflow
himanshusinghs 250299b
chore: fix existing tests
himanshusinghs f1be251
chore: tests for the new behavior
himanshusinghs eff03a8
chore: add missing constants files
himanshusinghs 7d670e8
Apply suggestion from @Copilot
himanshusinghs 8e8c3aa
chore: minor typo
himanshusinghs 6bd5638
fix: removes default limit from find tool schema
himanshusinghs 937908b
chore: add an accuracy test for find tool
himanshusinghs 9d9b9f8
chore: PR feedback
himanshusinghs 13d8408
chore: abort cursor iteration on request timeouts
himanshusinghs f09b4f4
chore: use correct arg in agg tool
himanshusinghs 7354562
chore: export tool matchers
himanshusinghs 819ed01
chore: accuracy test fixes
himanshusinghs 21f1d3e
Merge remote-tracking branch 'origin/main' into fix/MCP-21-avoid-memo…
himanshusinghs 25e0367
chore: PR feedback about generous config defaults
himanshusinghs 67d3ea8
Merge remote-tracking branch 'origin/main' into fix/MCP-21-avoid-memo…
himanshusinghs 8601c05
chore: fix tests after merge
himanshusinghs 955b7d8
chore: account for cursor close errors
himanshusinghs bca4bbe
chore: remove unnecessary call
himanshusinghs 811474e
chore: revert export changes
himanshusinghs e3a87b3
chore: remove eager prediction of overflow
himanshusinghs e1c95bd
chore: initialise cursor variables
himanshusinghs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import { calculateObjectSize } from "bson"; | ||
import type { AggregationCursor, FindCursor } from "mongodb"; | ||
|
||
export function getResponseBytesLimit( | ||
toolResponseBytesLimit: number | undefined | null, | ||
configuredMaxBytesPerQuery: unknown | ||
): { | ||
cappedBy: "config.maxBytesPerQuery" | "tool.responseBytesLimit" | undefined; | ||
limit: number; | ||
} { | ||
const configuredLimit: number = parseInt(String(configuredMaxBytesPerQuery), 10); | ||
|
||
// Setting configured maxBytesPerQuery to negative, zero or nullish is | ||
// equivalent to disabling the max limit applied on documents | ||
const configuredLimitIsNotApplicable = Number.isNaN(configuredLimit) || configuredLimit <= 0; | ||
|
||
// It's possible to have tool parameter responseBytesLimit as null or | ||
// negative values in which case we consider that no limit is to be | ||
// applied from tool call perspective unless we have a maxBytesPerQuery | ||
// configured. | ||
const toolResponseLimitIsNotApplicable = typeof toolResponseBytesLimit !== "number" || toolResponseBytesLimit <= 0; | ||
|
||
if (configuredLimitIsNotApplicable) { | ||
return { | ||
cappedBy: toolResponseLimitIsNotApplicable ? undefined : "tool.responseBytesLimit", | ||
limit: toolResponseLimitIsNotApplicable ? 0 : toolResponseBytesLimit, | ||
}; | ||
} | ||
|
||
if (toolResponseLimitIsNotApplicable) { | ||
return { cappedBy: "config.maxBytesPerQuery", limit: configuredLimit }; | ||
} | ||
|
||
return { | ||
cappedBy: configuredLimit < toolResponseBytesLimit ? "config.maxBytesPerQuery" : "tool.responseBytesLimit", | ||
limit: Math.min(toolResponseBytesLimit, configuredLimit), | ||
}; | ||
} | ||
|
||
/** | ||
* This function attempts to put a guard rail against accidental memory overflow | ||
* on the MCP server. | ||
* | ||
* The cursor is iterated until we can predict that fetching next doc won't | ||
* exceed the derived limit on number of bytes for the tool call. The derived | ||
* limit takes into account the limit provided from the Tool's interface and the | ||
* configured maxBytesPerQuery for the server. | ||
*/ | ||
export async function collectCursorUntilMaxBytesLimit<T = unknown>({ | ||
cursor, | ||
toolResponseBytesLimit, | ||
configuredMaxBytesPerQuery, | ||
abortSignal, | ||
}: { | ||
cursor: FindCursor<T> | AggregationCursor<T>; | ||
toolResponseBytesLimit: number | undefined | null; | ||
configuredMaxBytesPerQuery: unknown; | ||
abortSignal?: AbortSignal; | ||
}): Promise<{ cappedBy: "config.maxBytesPerQuery" | "tool.responseBytesLimit" | undefined; documents: T[] }> { | ||
const { limit: maxBytesPerQuery, cappedBy } = getResponseBytesLimit( | ||
toolResponseBytesLimit, | ||
configuredMaxBytesPerQuery | ||
); | ||
|
||
// It's possible to have no limit on the cursor response by setting both the | ||
// config.maxBytesPerQuery and tool.responseBytesLimit to nullish or | ||
// negative values. | ||
if (maxBytesPerQuery <= 0) { | ||
return { | ||
cappedBy, | ||
documents: await cursor.toArray(), | ||
}; | ||
} | ||
|
||
let wasCapped: boolean = false; | ||
let totalBytes = 0; | ||
const bufferedDocuments: T[] = []; | ||
while (true) { | ||
if (abortSignal?.aborted) { | ||
break; | ||
} | ||
|
||
// If the cursor is empty then there is nothing for us to do anymore. | ||
const nextDocument = await cursor.tryNext(); | ||
if (!nextDocument) { | ||
break; | ||
} | ||
|
||
const nextDocumentSize = calculateObjectSize(nextDocument); | ||
if (totalBytes + nextDocumentSize >= maxBytesPerQuery) { | ||
wasCapped = true; | ||
break; | ||
} | ||
|
||
totalBytes += nextDocumentSize; | ||
bufferedDocuments.push(nextDocument); | ||
} | ||
|
||
return { | ||
cappedBy: wasCapped ? cappedBy : undefined, | ||
documents: bufferedDocuments, | ||
}; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
/** | ||
* A cap for the maxTimeMS used for FindCursor.countDocuments. | ||
* | ||
* The number is relatively smaller because we expect the count documents query | ||
* to be finished sooner if not by the time the batch of documents is retrieved | ||
* so that count documents query don't hold the final response back. | ||
*/ | ||
export const QUERY_COUNT_MAX_TIME_MS_CAP: number = 10_000; | ||
|
||
/** | ||
* A cap for the maxTimeMS used for counting resulting documents of an | ||
* aggregation. | ||
*/ | ||
export const AGG_COUNT_MAX_TIME_MS_CAP: number = 60_000; | ||
|
||
export const ONE_MB: number = 1 * 1024 * 1024; | ||
|
||
/** | ||
* A map of applied limit on cursors to a text that is supposed to be sent as | ||
* response to LLM | ||
*/ | ||
export const CURSOR_LIMITS_TO_LLM_TEXT = { | ||
"config.maxDocumentsPerQuery": "server's configured - maxDocumentsPerQuery", | ||
"config.maxBytesPerQuery": "server's configured - maxBytesPerQuery", | ||
"tool.responseBytesLimit": "tool's parameter - responseBytesLimit", | ||
} as const; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
type OperationCallback<OperationResult> = () => Promise<OperationResult>; | ||
|
||
export async function operationWithFallback<OperationResult, FallbackValue>( | ||
performOperation: OperationCallback<OperationResult>, | ||
fallback: FallbackValue | ||
): Promise<OperationResult | FallbackValue> { | ||
try { | ||
return await performOperation(); | ||
} catch { | ||
return fallback; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.