feat(mcp): declarative custom tool profiles over search-records - #711
Merged
Conversation
**Stack position: 1 of 3.** Base is the `feat/mcp-custom-tool-profiles`
integration branch, not `main` — 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'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 `main` provides.
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_filter` sits 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
- a numeric range renders exclusive bounds as `[(5 +inf]`, where `(` is
a marker
- 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. Both
directions of that were wrong in earlier drafts, which is the main
reason this is its own PR.
## `limit_cap`
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.
## Not exposed to the model
Both parameters are keyword-only on `search_records` and never reach the
advertised MCP schema: `register_search_tool` registers an inner wrapper
with its own fixed signature. I verified that rather than assuming it.
## Verification
- MCP unit tests: 276 passing
- `make check-types`: clean
- The backstop accepts every legitimate `like` pattern (multi-word AND,
`%` fuzzy, wildcards, in-clause `|`) while keeping the locked clause
intact on an injection attempt
## Sequencing
This targets the integration branch so that `main` never 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 onto `main` and drops out.
<!-- CURSOR_SUMMARY -->
---
> [!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_records` for upcoming custom
tool profiles: **`locked_filter`** AND-combined with the caller’s
`filter`, and **`limit_cap`** applied in `_validate_request`. Neither is
exposed on the registered MCP tool wrapper.
>
> **`merge_locked_filter`** keeps the author lock always in force so
callers can only narrow. Object filters are merged via
`FilterExpression.__and__`; raw string caller filters are rejected.
**`_reject_escapable_filter`** walks 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 with
`INVALID_REQUEST`; omitted limits are silently `min(default_limit, cap)`
so binding defaults cannot bypass the ceiling.
>
> Extensive unit tests cover merge shapes, escape cases, and
`search_records` integration.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
561da31. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
**Stack position: 2 of 3.** Base is #675. The declarative surface for custom tool profiles: what an author may put in a `custom_tools:` YAML entry, and every way that entry can be wrong. A profile is `search-records` with some arguments frozen by the author and the rest still exposed. `lock` holds what the author decides; `params` holds the exposure and cap policy for what the model may still pass. Anything unlisted stays exposed, so a profile that only locks a filter keeps the rest of the built-in's contract. A locked projection is the one implicit case: locking it means the model cannot also choose one. ## Read this before approving: it must not reach `main` alone Nothing consumes these models yet — registration and execution land in #676. That ordering is deliberate, because this is the complete authoring contract and is fully testable on its own, so it reads before the machine that honors it. But **this must not be merged to `main` by itself.** A `custom_tools:` block would validate here and then register nothing, with no warning and no error — exactly the silent-misconfiguration failure these models otherwise exist to prevent. That is the whole reason the stack targets an integration branch instead of `main`. ## Validation Front-loaded to config load wherever the information exists there, since the alternative is a tool that looks locked and enforces nothing. Covered: - a name that collides with a built-in, uses a reserved `redisvl-`/`redisvl_` prefix (both separators, since the name pattern permits either), or falls outside the character set MCP hosts commonly accept - a duplicate tool name - a missing `index` when several bindings exist, or one naming an unknown binding - a `params` key that is not a real argument - `max` on anything but `limit` - a cap above the bound index's own `runtime.max_limit`, which could never be satisfied - hiding `query`, which a search profile needs - locking `return_fields` while also exposing them Field checks that need the inspected schema stay at startup, in #676. All three models set `extra="forbid"`. A misspelled key would otherwise be dropped in silence, which is the worst outcome available here: `lock: {return_field: [...]}` would read as a locked projection while enforcing nothing at all. ## Verification - MCP unit tests: 304 passing - `make check-types`: clean <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Config-only change with no runtime wiring yet—safe in isolation but merging without follow-up would silently ignore authored profiles; validation rules affect how operators author MCP tools. > > **Overview** > Adds a **`custom_tools`** YAML surface and Pydantic models so authors can declare **search profile** tools: specialized wrappers around **`search-records`** with frozen **`lock`** values (filter, `return_fields`), per-argument **`params`** exposure and **`limit`** caps, optional index pinning, and strict naming rules (built-in collisions, `redisvl-` / `redisvl_` prefixes, MCP-friendly name pattern). > > **`MCPConfig`** now loads and validates profiles at config time: duplicate names, index requirements when multiple bindings exist, unknown param keys, forbidden policies (hidden `query`, `max` on non-`limit` args, lock vs expose conflicts for `return_fields`), and `params.limit.max` above the binding’s **`runtime.max_limit`**. Helpers **`param_exposed`**, **`param_max`**, and **`resolved_profile_index`** encode how a future registrar should expose arguments. Schema-dependent lock checks are deferred to startup in a follow-up PR; **nothing registers these tools yet**, so `custom_tools` alone would validate but not publish tools until registration lands. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 95485e5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
**Stack position: 3 of 3.** Base is #676. This completes the feature — the point at which a `custom_tools:` entry becomes a real tool. ## How locked arguments are actually unreachable `register_profile_tool` builds each profile's wrapper signature dynamically and hands it to FastMCP, which derives the advertised input schema from that signature and marks it `additionalProperties: false`. That is what makes a locked or hidden argument genuinely unreachable rather than merely undocumented — the model cannot name an argument the schema does not contain. I verified empirically that FastMCP derives its schema from a dynamic `__signature__`, since the whole design rests on it. The wrapper still re-checks exposure per call rather than trusting the schema alone. The `filter` annotation is an object type, never a string, so a raw filter string is refused by the advertised schema; the wrapper refuses one too, because the schema is the client's contract and the wrapper is the server's. A `limit` cap is published as `Field(le=cap)` so the ceiling is visible to the model rather than only enforced on rejection. ## Startup validation Catches what config load could not, because it needs the inspected schema: a locked projection or filter naming a field the bound index does not have, a locked `exists` on a field without `INDEXMISSING`, or one on a vector field. It runs before registration so a bad profile fails startup instead of leaving a half-registered tool set behind. ## Two operational hazards that would otherwise be silent Tools register once per process, but a profile bakes its locked filter, projection, and signature in at registration time. A restart that reloads a *changed* config would therefore keep enforcing the old profiles. The dangerous direction is an operator tightening a lock and believing the restart applied it, so the server fingerprints the config its tools were built from and warns when that no longer matches. The empty-surface warning from #668 now also names `custom_tools` as a possible cause. ## Also included Integration coverage against real Redis, the concept and how-to documentation, and unit tests for registration, execution, description building, and per-binding lock isolation. ## Verification - MCP unit tests: 356 passing - `make check-types`: clean ## After this merges The integration branch holds #675 + #676 + this, and squash-merges to `main` as one "custom tool profiles" commit. Phase 2 (auth-claim tenant injection) and v1.1 (code tools) are separate follow-ups and not in this stack. One thing recorded for phase 2: `TokenEscaper` does not escape `|`, so a scalar claim like `acme|evil` would render `@tenant_id:{acme|evil}` — a cross-tenant OR. Harmless here because the value is ANDed under the lock, but claim injection must validate claim *characters*, not just type. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Profiles enforce data scoping via locked filters and schema narrowing, but incorrect locks or misunderstood AND-composition could surprise operators; tools still register once per process so config changes need a full restart. > > **Overview** > Adds **custom tool profiles**: YAML `custom_tools` entries that publish curated `search-records` wrappers under custom names, with **locked** filters/projections and **exposed** params driving a dynamic FastMCP signature (`additionalProperties: false`). > > **Runtime:** New `profiles.py` registers each profile at startup (pinned index, parsed locked filter, limit caps, object-only caller filters), delegates to `search_records` with `locked_filter` / `limit_cap`, and validates locked fields against the inspected index schema before registration. **Server** wires `register_profile_tools`, extends the tool-surface fingerprint/warnings for profile config drift, and documents the feature in concepts and how-to guides. > > **Tests:** Integration tests against Redis plus broad unit coverage for filter AND-scoping, schema advertisement, limits, auth, and multi-profile isolation. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7cac8c7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
vishal-bala
marked this pull request as ready for review
September 2, 2026 12:51
|
🚀 PR was released in |
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.
Motivation
The built-in MCP tools expose the index generically, which leaves the model doing query engineering on every call: pick the index, understand the schema, build a filter in the JSON DSL, choose return fields. That work was already done by whoever designed the index, and re-deriving it per call has three costs. Tool selection is less accurate, because a generic
search-recordsis harder for a model to choose correctly than a domain-named tool. Invariants are unenforceable, because "always filter on resolved tickets" is a prompt rather than a boundary, and a model that forgets it produces wrong results rather than an error. And there is nowhere to put application rules such as a redacted field list or a result cap, so they live in a system prompt or nowhere.A profile closes that gap without any Python. It is the built-in
search-recordswith some arguments pre-filled and frozen by the author and the rest still offered to the model, published under a name and description the author chooses.Changes
Declarative profiles in the server config
A
custom_toolslist in the same YAML the server already loads registers additional tools at startup. Each entry names the built-in it specialises, pins the index it targets, and supplies the name and description the model sees.lockholds what the author decides;paramsholds the exposure policy for what the model may still pass. An argument absent fromparamsstays exposed, so a profile that only locks a filter keeps the rest of the built-in's contract.indexis pinned by the top-level key rather than offered as an argument, and may be omitted only when exactly one index is configured.Locked filters combine rather than override
A locked filter is AND-combined with any filter the model supplies, so the model narrows within the locked scope and cannot widen past it. Given a profile locking
status == resolvedand a model-suppliedcategoryfilter, the executed query isstatus == resolved AND category == X, and there is no request shape that removes the status clause.Two properties make that hold. A compound model-supplied expression renders parenthesised, so an
orornotnests inside the locked AND instead of reaching the top level. And every filter value stays inside its own clause, which is what stops a crafted value closing its clause and appending query syntax; a value that still renders as something able to break out of the enclosing AND is refused rather than combined.For the same reason a profile accepts only the object form of a filter from the model. A raw filter string is rejected both by the advertised input schema and by the tool itself, because a string bypasses the DSL's field validation and has no safe composition with an expression.
Locked arguments are unreachable, not merely undocumented
Each profile's wrapper is built with a signature carrying only its exposed arguments, and the MCP input schema is derived from that signature with
additionalPropertiesfalse. A locked or hidden argument therefore has no name the model can pass. The wrapper re-checks exposure per call rather than relying on the schema alone.params.limit.maxbounds the result count. It applies whether the model names a limit or omits one, so an omitted limit is capped rather than falling through to the binding default, and an explicit request above the cap is rejected. Hidinglimitturns the cap into a fixed result count. The cap may not exceed the bound index's ownruntime.max_limit, which is checked at startup.Misconfiguration fails at startup
Validation runs at config load where the information is available there, and at startup once each index has been inspected. Covered: a name colliding with a built-in or using a reserved prefix, a duplicate tool name, a missing or unknown
index, aparamskey that is not a real argument, a cap on any argument other thanlimitor above the binding's ceiling, hidingquery, lockingreturn_fieldswhile also exposing them, and a locked filter or projection naming a field the bound index does not have. Unrecognised keys are rejected, so a typo inlockfails loudly instead of producing a tool that reads as locked and enforces nothing.Secondary changes
docs/concepts/mcp.md.docs/user_guide/how_to_guides/mcp.md.search_recordsgains two keyword-only parameters,locked_filterandlimit_cap, which are supplied in-process and never reach the advertised schema.Notes
The three commits on this branch were each opened, reviewed and merged as their own pull requests against it (#675, #676 and #677). This branch is their aggregation, and the code reaches
mainhere for the first time. Reviewing the commits individually gives the same three-way split: the filter-merge primitive, the config models, then registration and execution.A profile resolves to a built-in call and nothing further, so it inherits the concurrency cap, request timeout, read-only policy, auth scoping and error mapping already applied to
search-records. That is the reason the feature carries no new execution surface: the path a custom tool travels is the path the built-ins already travel.Tools register once per process. A profile bakes its locked filter, projection and signature in at registration, so a restart that reloads a changed configuration keeps enforcing the previous profiles. The server fingerprints the configuration its tools were built from and warns when the two no longer match; changing the tool surface requires a new process.
Three validation gaps are known and deliberately left: an empty
lock.filterobject is accepted at config load and fails later at startup with a less specific message, duplicate entries inlock.return_fieldsare accepted, and theoffset + limitbound names an argument that a profile hidingoffsetgives the caller no way to supply. All three produce a worse error than necessary rather than incorrect behaviour.Next Steps
No provisioning is required, and no existing configuration changes behaviour: profiles are inert until a
custom_toolsblock is added.custom_toolsentry to a server configuration and confirm the tool is advertised with only its exposed arguments:Release Notes
The RedisVL MCP server can now publish custom tool profiles: additional tools defined entirely in the server's YAML configuration, with no Python required. A profile specialises the built-in
search-recordsunder a name and description you choose, pinning the index it targets and freezing whichever arguments you want fixed.An author can lock the filter, the returned field set and a maximum result count, and choose per argument whether the model may pass it. A locked filter is AND-combined with any filter the model supplies, so the model can narrow the search but cannot widen or remove the locked clause; locked and hidden arguments are absent from the tool's advertised input schema, so there is no argument name for the model to pass. Profiles execute through the same path as the built-in tool and inherit its concurrency limit, request timeout, read-only enforcement, auth scoping and error contract.
Configuration errors fail at server startup with an actionable message rather than at the first tool call, and unrecognised keys in a profile are rejected rather than ignored.
This is additive. Existing configurations are unaffected, and a server with no
custom_toolsblock behaves exactly as before.Note
Medium Risk
Changes MCP retrieval contracts and adds security-sensitive locked-filter composition, but profiles reuse the existing
search-recordspath with startup validation and broad test coverage; default configs are unchanged.Overview
Adds YAML-defined custom tool profiles so operators can publish domain-named search tools without Python. Each
custom_toolsentry specializessearch-recordswith a chosen name/description, a pinnedindex,lock(frozen filter and/orreturn_fields), andparams(per-argumentexposeand optionallimit.max).At startup the server validates profile config (names, indexes, caps vs
max_limit, schema-backed locked fields) and registers wrappers whose MCP schemas only list exposed arguments; locked/hidden args are omitted from the signature and ignored at runtime. Profiles delegate tosearch_recordswith in-processlocked_filterandlimit_cap.Filter behavior for profiles: caller filters are always
locked AND callervia newmerge_locked_filter(plus a rendering backstop); profiles advertise object-only filters and reject raw strings. Omitted limits are capped silently; explicit limits above the cap fail validation.Documentation covers profiles in concepts and the how-to guide. Tool-surface fingerprinting and warnings now include
custom_tools; empty-tool warnings mention profiles.Reviewed by Cursor Bugbot for commit 38adaa3. Bugbot is set up for automated code reviews on this repo. Configure here.