Skip to content

feat(mcp): declarative custom tool profiles over search-records - #711

Merged
vishal-bala merged 3 commits into
mainfrom
feat/mcp-custom-tool-profiles
Sep 2, 2026
Merged

feat(mcp): declarative custom tool profiles over search-records#711
vishal-bala merged 3 commits into
mainfrom
feat/mcp-custom-tool-profiles

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

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-records is 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-records with 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_tools list 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.

custom_tools:
  - name: search-resolved-tickets
    based_on: search-records
    index: tickets
    description: >
      Search resolved customer support tickets by relevance.
      Use this to find how a similar problem was fixed before.
    lock:
      return_fields: [subject, resolution, created_at]
      filter: { field: status, op: eq, value: resolved }
    params:
      limit: { expose: true, max: 10 }
      filter: { expose: true }

lock holds what the author decides; params holds the exposure policy for what the model may still pass. An argument absent from params stays exposed, so a profile that only locks a filter keeps the rest of the built-in's contract. index is 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 == resolved and a model-supplied category filter, the executed query is status == 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 or or not nests 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 additionalProperties false. 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.max bounds 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. Hiding limit turns the cap into a fixed result count. The cap may not exceed the bound index's own runtime.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, a params key that is not a real argument, a cap on any argument other than limit or above the binding's ceiling, hiding query, locking return_fields while 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 in lock fails loudly instead of producing a tool that reads as locked and enforces nothing.

Secondary changes

  • Concept documentation for profiles, the merge rule and the validation set, in docs/concepts/mcp.md.
  • A worked configuration example in docs/user_guide/how_to_guides/mcp.md.
  • search_records gains two keyword-only parameters, locked_filter and limit_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 main here 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.filter object is accepted at config load and fails later at startup with a less specific message, duplicate entries in lock.return_fields are accepted, and the offset + limit bound names an argument that a profile hiding offset gives 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_tools block is added.

  1. Verify the MCP suites against a real Redis:
uv run pytest tests/unit/test_mcp tests/integration/test_mcp -q
  1. Add a custom_tools entry to a server configuration and confirm the tool is advertised with only its exposed arguments:
rvl mcp --config mcp.yaml --transport stdio

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-records under 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_tools block behaves exactly as before.


Note

Medium Risk
Changes MCP retrieval contracts and adds security-sensitive locked-filter composition, but profiles reuse the existing search-records path 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_tools entry specializes search-records with a chosen name/description, a pinned index, lock (frozen filter and/or return_fields), and params (per-argument expose and optional limit.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 to search_records with in-process locked_filter and limit_cap.

Filter behavior for profiles: caller filters are always locked AND caller via new merge_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.

**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 vishal-bala added auto:release Create a release when this PR is merged auto:minor Increment the minor version when merged labels Sep 2, 2026
@vishal-bala
vishal-bala marked this pull request as ready for review September 2, 2026 12:51
@vishal-bala
vishal-bala merged commit f744209 into main Sep 2, 2026
58 checks passed
@vishal-bala
vishal-bala deleted the feat/mcp-custom-tool-profiles branch September 2, 2026 13:10
@applied-ai-release-bot

Copy link
Copy Markdown

🚀 PR was released in v0.27.0 🚀

@applied-ai-release-bot applied-ai-release-bot Bot added the released This issue/pull request has been released. label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:minor Increment the minor version when merged auto:release Create a release when this PR is merged released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant