feat(mcp): add a locked-filter merge primitive to search-records - #675
Open
vishal-bala wants to merge 2 commits into
Open
feat(mcp): add a locked-filter merge primitive to search-records#675vishal-bala wants to merge 2 commits into
vishal-bala wants to merge 2 commits into
Conversation
Groundwork for custom tool profiles, landed on its own because it is the
security crux of that feature and deserves review attention that it would
not get buried in a larger change. Nothing calls the new parameters yet.
`merge_locked_filter(locked, caller)` AND-combines an author-locked filter
expression with a caller-supplied one so the caller can only narrow within
the locked scope and never widen past it. That rests on two things:
`FilterExpression.__and__` parenthesizing the combination, so a caller's
`or`/`not` nests inside the AND rather than reaching the top level; and
every filter value staying inside its own clause, which the text escaping
already on this branch provides.
A raw string caller filter is refused outright. Strings skip the DSL's
field validation and have no safe composition with an expression --
combining them means concatenation, where a crafted value can close the
locked group.
`_reject_escapable_filter` is a backstop behind that, not the primary
defense: a well-formed expression built from escaped values cannot escape,
so anything it rejects means a value reached the query string raw. It
walks the rendering rather than counting delimiters, because several
things look like structure and are not -- `\(` is an escaped literal, a
numeric range renders exclusive bounds as `[(5 +inf]` where `(` is a
marker, and a tag clause scopes its alternatives in braces, so
`@category:{sports|health}` is one clause rather than a union. Escape
pairs are consumed rather than tested against the previous character, so
`\\|` is not misread as a protected delimiter.
`limit_cap` bounds the result window. It is applied inside
`_validate_request` rather than by the caller because an omitted `limit`
only resolves to the binding default at that point -- capping just the
explicit value would let the default sail past the cap. An explicit
request above the cap is rejected; an omitted one is capped silently,
since the caller never named a number.
Both parameters are keyword-only on `search_records` and never reach the
model: `register_search_tool` registers an inner wrapper with its own
fixed signature, so the advertised MCP input schema is unchanged.
This was referenced Aug 7, 2026
vishal-bala
marked this pull request as ready for review
August 12, 2026 09:05
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bee8dd2. Configure here.
Review catch on the escape backstop, and a real hole: `@rating:[4 | @category:{secret}]` was accepted. The `[...]` span is skipped so that an exclusive bound's `(` -- as in `[(5 +inf]` -- is not counted as an opening group. But skipping the span wholesale also skipped any `|` inside it, so a union could hide behind brackets and pass the guard. The comment justifying that read "values inside are numbers, so no `|` can hide here." That is true of a well-formed expression, and it is exactly the assumption this function is not allowed to make: its own docstring says it exists for the case where a value reached the query string unescaped. Under that premise an unescaped `[` containing a `|` is precisely the breakout it should refuse. Suppressing paren depth is the only reason to skip a span, so the skip is now narrowed to that: `|` detection stays active inside it. A legitimate numeric or geo range holds numbers, so nothing legitimate is affected -- covered by a test over inclusive and both exclusive-bound forms, since narrowing the skip is the kind of change that could start rejecting real ranges. Removing the new check makes the added case fail, so the guard is load-bearing rather than decorative.
vishal-bala
force-pushed
the
feat/mcp-locked-filter-merge
branch
from
August 13, 2026 08:04
dd72321 to
561da31
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.

Stack position: 1 of 3. Base is the
feat/mcp-custom-tool-profilesintegration branch, notmain— see the sequencing note at the bottom.Groundwork for custom tool profiles, split out on its own because it is the security crux of that feature and deserves attention it would not get buried in a 1,900-line PR. Nothing calls the new parameters yet.
What it does
merge_locked_filter(locked, caller)AND-combines an author-locked filter expression with a caller-supplied one, so a caller can only narrow within the locked scope and never widen past it. That rests on two things:FilterExpression.__and__parenthesizing the combination, so a caller'sor/notnests inside the AND rather than reaching the top level; and every filter value staying inside its own clause, which the text escaping already onmainprovides.A raw string caller filter is refused outright — strings skip the DSL's field validation and have no safe composition with an expression, since combining them means concatenation.
The backstop, and why it walks the rendering
_reject_escapable_filtersits behind that as a backstop, not the primary defense: a well-formed expression built from escaped values cannot escape, so anything it rejects means a value reached the query string raw.It walks the rendered string rather than counting delimiters, because several things look like structure and are not:
\(is an escaped literal, not a group[(5 +inf], where(is a marker@category:{sports|health}is one clause rather than a unionEscape pairs are consumed rather than tested against the previous character, so
\\|is not misread as a protected delimiter. Both directions of that were wrong in earlier drafts, which is the main reason this is its own PR.limit_capApplied inside
_validate_requestrather than by the caller, because an omittedlimitonly resolves to the binding default at that point — capping just the explicit value would let the default sail past the cap. An explicit request above the cap is rejected; an omitted one is capped silently, since the caller never named a number.Not exposed to the model
Both parameters are keyword-only on
search_recordsand never reach the advertised MCP schema:register_search_toolregisters an inner wrapper with its own fixed signature. I verified that rather than assuming it.Verification
make check-types: cleanlikepattern (multi-word AND,%fuzzy, wildcards, in-clause|) while keeping the locked clause intact on an injection attemptSequencing
This targets the integration branch so that
mainnever receives a config surface that validates but enforces nothing — see the next PR in the stack for why that matters. The integration branch currently also carries #668; once that merges it rebases ontomainand drops out.Note
Medium Risk
Security-sensitive filter composition for future profile enforcement; behavior is well-tested and not yet exposed to models, but mistakes in merge/backstop logic could allow filter bypass once profiles land.
Overview
Adds server-only hooks on
search_recordsfor upcoming custom tool profiles:locked_filterAND-combined with the caller’sfilter, andlimit_capapplied in_validate_request. Neither is exposed on the registered MCP tool wrapper.merge_locked_filterkeeps the author lock always in force so callers can only narrow. Object filters are merged viaFilterExpression.__and__; raw string caller filters are rejected._reject_escapable_filterwalks the caller’s rendered query as a backstop (top-level|, unbalanced groups/braces, pipes inside numeric ranges, etc.) so crafted renderings cannot break out of the locked AND.limit_cap: explicit limits above the cap fail withINVALID_REQUEST; omitted limits are silentlymin(default_limit, cap)so binding defaults cannot bypass the ceiling.Extensive unit tests cover merge shapes, escape cases, and
search_recordsintegration.Reviewed by Cursor Bugbot for commit 561da31. Bugbot is set up for automated code reviews on this repo. Configure here.