Skip to content

feat(mcp): register and execute custom tool profiles - #677

Merged
vishal-bala merged 1 commit into
feat/mcp-custom-tool-profilesfrom
feat/mcp-profile-tools
Sep 2, 2026
Merged

feat(mcp): register and execute custom tool profiles#677
vishal-bala merged 1 commit into
feat/mcp-custom-tool-profilesfrom
feat/mcp-profile-tools

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

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.


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.

Reviewed by Cursor Bugbot for commit 7cac8c7. Bugbot is set up for automated code reviews on this repo. Configure here.

@vishal-bala
vishal-bala force-pushed the feat/mcp-profile-tools branch from 4132493 to e03783b Compare August 14, 2026 14:57
@vishal-bala
vishal-bala requested a review from nkanu17 August 20, 2026 07:48
@vishal-bala
vishal-bala marked this pull request as ready for review August 20, 2026 07:48
@vishal-bala
vishal-bala requested a review from limjoobin August 26, 2026 10:31
Base automatically changed from feat/mcp-profile-config to feat/mcp-custom-tool-profiles September 2, 2026 08:59
@vishal-bala
vishal-bala force-pushed the feat/mcp-profile-tools branch from e03783b to d08ffef Compare September 2, 2026 08:59

@limjoobin limjoobin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
vishal-bala force-pushed the feat/mcp-profile-tools branch from d08ffef to 7cac8c7 Compare September 2, 2026 12:01
@vishal-bala
vishal-bala merged commit 38adaa3 into feat/mcp-custom-tool-profiles Sep 2, 2026
55 checks passed
@vishal-bala
vishal-bala deleted the feat/mcp-profile-tools branch September 2, 2026 12:19
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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants