feat(mcp): register and execute custom tool profiles - #677
Merged
Conversation
vishal-bala
force-pushed
the
feat/mcp-profile-tools
branch
from
August 14, 2026 14:57
4132493 to
e03783b
Compare
vishal-bala
marked this pull request as ready for review
August 20, 2026 07:48
Base automatically changed from
feat/mcp-profile-config
to
feat/mcp-custom-tool-profiles
September 2, 2026 08:59
vishal-bala
force-pushed
the
feat/mcp-profile-tools
branch
from
September 2, 2026 08:59
e03783b to
d08ffef
Compare
limjoobin
approved these changes
Sep 2, 2026
limjoobin
left a comment
Contributor
There was a problem hiding this comment.
LGTM. Functionally, this PR registers the configured custom tool profiles as real MCP tools implemented as a wrapper around search_records, complete with startup validation and operational guardrails.
Also, there is a failing test in the test suite. Seems like it is coming from a misconfigured AzureOpenAI API endpoint rather than the implementation in this PR breaking anything. Feature-wise, we are good to go on this PR. Still, worth a look into the failing test case though!
Completes the stack: the machine that honors the profile config models, and the point at which a `custom_tools:` entry becomes a real tool. `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. 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, since 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. Two operational hazards get warnings rather than silence. Tools register once per process, but a profile bakes its locked filter, projection, and signature in at registration time -- so a restart that reloads a *changed* config would 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 also now names `custom_tools` as a possible cause. Adds the integration coverage that exercises profiles against real Redis, the concept and how-to documentation, and unit tests for registration, execution, description building, and per-binding lock isolation. The restart-warning path and `_register_tools` idempotency are covered here too, since both only became load-bearing once profiles existed.
vishal-bala
force-pushed
the
feat/mcp-profile-tools
branch
from
September 2, 2026 12:01
d08ffef to
7cac8c7
Compare
vishal-bala
added a commit
that referenced
this pull request
Sep 2, 2026
## 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.
```yaml
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:
```bash
uv run pytest tests/unit/test_mcp tests/integration/test_mcp -q
```
2. Add a `custom_tools` entry to a server configuration and confirm the
tool is advertised with only its exposed arguments:
```bash
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.
<!-- CURSOR_SUMMARY -->
---
> [!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.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
38adaa3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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: 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_toolbuilds each profile's wrapper signature dynamically and hands it to FastMCP, which derives the advertised input schema from that signature and marks itadditionalProperties: 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
filterannotation 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. Alimitcap is published asField(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
existson a field withoutINDEXMISSING, 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_toolsas 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
make check-types: cleanAfter this merges
The integration branch holds #675 + #676 + this, and squash-merges to
mainas 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:
TokenEscaperdoes not escape|, so a scalar claim likeacme|evilwould 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.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_toolsentries that publish curatedsearch-recordswrappers under custom names, with locked filters/projections and exposed params driving a dynamic FastMCP signature (additionalProperties: false).Runtime: New
profiles.pyregisters each profile at startup (pinned index, parsed locked filter, limit caps, object-only caller filters), delegates tosearch_recordswithlocked_filter/limit_cap, and validates locked fields against the inspected index schema before registration. Server wiresregister_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.
Reviewed by Cursor Bugbot for commit 7cac8c7. Bugbot is set up for automated code reviews on this repo. Configure here.