Skip to content

feat(mcp): add an allow-listed index handle and ToolContext for code tools - #758

Merged
vishal-bala merged 9 commits into
mainfrom
feat/mcp-code-tools/01-tool-context
Oct 30, 2026
Merged

vishal-bala merged 9 commits into
mainfrom
feat/mcp-code-tools/01-tool-context

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Custom tool profiles cover what YAML can express: a built-in search with some arguments frozen. Logic beyond that, such as re-ranking results, counting matches or combining several queries, needs Python. This stack adds code tools: async functions registered as MCP tools. This first PR adds the only thing such a function will receive to reach Redis, a ToolContext. Nothing in it is reachable by users yet.

The design point is that a code tool never holds the real AsyncSearchIndex, the server or a Redis client. The real index has write methods, create/delete/clear and a .client property, so handing it over would make every promise about read-only policy, resource limits and index scoping depend on the tool author's discipline.

Changes

redisvl/mcp/context.py adds three classes:

  • IndexHandle offers query, batch_query, search, batch_search, aggregate, fetch, exists and info, plus document_id(key), which turns the full key a search result carries as id back into a document id without a Redis call. Every method is spelled out rather than forwarded through __getattr__, so a method added to AsyncSearchIndex later stays unreachable until someone adds it here on purpose.
  • WritableIndexHandle adds load, drop_documents and expire_documents, for tools that declare they write. load returns document ids rather than the full keys the index returns, so its output feeds the other methods directly.
  • ToolContext bundles the handle with a copy of the binding's schema, its vectorizer, an embed(text) helper and claims, a deep read-only copy of the current request's verified token claims, or None when auth is off or the request has no token.

Every handle method that reaches Redis routes through server.run_guarded, so each Redis call takes a slot under the process-wide concurrency cap and is bounded by the binding's request timeout. The cap is taken per call, not per tool, so a tool that queries several times never blocks itself.

Every call stays inside the bound index:

  • Searches and aggregations run against the index's own name, and info takes no index name.
  • Records are addressed by document id, which the index turns into a key under its own prefix. Nothing takes a full Redis key, so load has no keys argument, and the handle offers drop_documents and expire_documents rather than drop_keys and expire_keys.
  • query and batch_query refuse a SQLQuery, because its FROM clause, not the bound index, decides what it reads.

Without these, a tool bound to one index could read or rewrite another's documents, including a tenant-scoped one.

The writable handle leaves out update_by_filter and drop_by_filter. Both work through their matches in batches, so a request timeout could cancel either partway and leave a partial write.

Notes

The handle is a guardrail, not a sandbox. A code tool is the operator's own code, and Python can still reach private attributes. The aim is to make the bounded, policy-checked path the only obvious one.

An index whose key prefix overlaps another's shares keys with it. The handle addresses keys through the index's prefix, so it cannot tell such indexes apart. A prefix that is empty, or made only of separator characters such as :, turns document ids into full Redis keys, so every key is in reach. This is documented rather than refused: such an index is a deliberate configuration, and the built-in tools already reach every key on it.

On an index with several prefixes, document ids address records under the first prefix, as AsyncSearchIndex.key does. document_id raises for a key under another prefix rather than guessing, because fetch and the writes could not reach that record anyway.

ToolContext.schema is a copy because the index holds the same object. A tool that edited a shared schema would retarget every later call on the binding, built-in tools included.

The tests pin the exact public method sets of both handles. They also check every forwarded call against the real AsyncSearchIndex signature, so a renamed upstream parameter fails here rather than at a tool's first call.

Release Notes

Code tools reach Redis only through a ToolContext, whose index handle stays inside the bound index, applies the concurrency cap and request timeout to every call, and offers writes, addressed by document id, only to tools that declare them.

Next Steps

Stacked PRs build on this one: the tool wrapper that constructs a ToolContext per call, then server.code_tool(...), then the module loader.

Reviewer Brief

Start here: redisvl/mcp/context.py, IndexHandle and WritableIndexHandle. Every Redis call a code tool can make is one of their methods, so these two classes are the whole boundary.

Where it could fail

  • redisvl/mcp/context.py:115: document_id turns a result's key into the id that fetch and the writes take; check it agrees with AsyncSearchIndex.key for prefixes that end in the separator, are empty, or are only separators.
  • redisvl/mcp/context.py:148: load must never accept a full key and must return ids under the first prefix; a key reaching drop_documents or expire_documents unprefixed would touch another index.
  • redisvl/mcp/context.py:203: claims are read only with auth on and returned as a deep read-only copy; check nothing mutable from the token survives.

Not verified: FastMCP 2.x (tests ran on 3.1.0 and 3.3.1, and get_access_token was checked in the 2.x source only); cluster mode for drop_documents and expire_documents across slots.

Beyond CI: mutants that drop the prefix check, load's id mapping, the SQLQuery refusal or the auth gate each fail a unit test; on Redis 8, a JSON FilterQuery with return_fields returned the full key, which document_id mapped back to the id.

Reviewed 6ee4146, fixes in 6fc1aff · Claude Code (claude-opus-5-5) · lenses: correctness, security, design, tests, docs, Redis behaviour


Note

Medium Risk
Adds a new security boundary for tenant/index scoping and auth claims exposure to user-defined tools; behavior is guarded and tested but mistakes here could widen Redis access for code tools.

Overview
Introduces ToolContext and explicit IndexHandle / WritableIndexHandle wrappers so future MCP code tools never get the raw AsyncSearchIndex, server, or Redis client.

Every index operation is an allow-listed method that goes through server.run_guarded with the binding’s request timeout; write APIs (load, drop_documents, expire_documents) appear only when the tool is declared as writing. query / batch_query reject SQLQuery, records are addressed by document id (with document_id to map search keys back), and load returns ids rather than full Redis keys. Filter-wide bulk writes are omitted to avoid partial work under timeout.

ToolContext.for_request supplies a deep-copied schema, the binding vectorizer plus embed(text), and read-only JWT claims when auth is on (claims are not fetched on unauthenticated servers, preserving older FastMCP versions). Unit tests pin the public API surface, guard routing, SQL refusal, and claims immutability.

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

…tools

A code tool reaches Redis only through a ToolContext. Its index is a handle
that spells out every method it offers and routes each one through
run_guarded, so the concurrency cap and the binding's request timeout apply
to every call. Write methods exist only on the handle given to a tool that
declares it writes, and exclude the filter-wide bulk operations a timeout
could cancel partway. Claims are a deep read-only copy of the verified token.
The write methods took full Redis keys, so a tool bound to one index could
write, delete or expire keys belonging to any other, including a
tenant-scoped one. Records are now addressed only by document id, which the
index turns into a key under its own prefix: load no longer takes keys,
and drop_keys and expire_keys give way to drop_documents and
expire_documents. query and batch_query refuse a SQLQuery, whose FROM
clause rather than the bound index decides what it reads. Each request
also gets its own copy of the schema, which the index shares, so a tool
that edits it cannot retarget later calls.
@vishal-bala
vishal-bala added this pull request to stack #762 October 14, 2026 09:01
@vishal-bala vishal-bala added the auto:minor Increment the minor version when merged label Oct 15, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T13:50:10.523769Z 0ff72fe Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@vishal-bala
vishal-bala requested a review from limjoobin October 27, 2026 13:47

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread redisvl/mcp/context.py Resolved

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread redisvl/mcp/context.py Resolved

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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 023c0c6. Configure here.

Comment thread redisvl/mcp/context.py Resolved

@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, this PR establishes an index-scoped Redis interface for Python MCP tools in the RedisVL MCP server.

@vishal-bala
vishal-bala merged commit f77cca9 into main Oct 30, 2026
58 checks passed
@vishal-bala
vishal-bala deleted the feat/mcp-code-tools/01-tool-context branch October 30, 2026 13:44
vishal-bala added a commit that referenced this pull request Oct 30, 2026
Rebased onto main after #758, #759 and #760 were squash-merged. The tree is the same as the verified merge of main into the previous head (8a22c82).
vishal-bala added a commit that referenced this pull request Oct 30, 2026
## Motivation

`server.code_tool(...)` from the previous PR only helps when the
caller's own code constructs the server. The common deployment is `rvl
mcp --config`, which imports no user code. This PR lets the config name
modules for the server to import. Because importing a module runs its
code with the server's privileges, nothing is imported unless the
operator opts in separately.

## Changes

`@code_tool(...)`, exported from `redisvl.mcp`, takes the same arguments
as `server.code_tool`. It checks the function and attaches its spec as
an attribute, without registering it anywhere:

```python
# myapp/support_tools.py
from redisvl.mcp import ToolContext, code_tool


@code_tool(index="tickets", namespace="support")
async def count_open_tickets(ctx: ToolContext, product: str) -> dict:
    """Count open support tickets for a product."""
    ...
```

```yaml
server:
  redis_url: redis://localhost:6379
  allow_code_tools: true
  code_tool_modules: [myapp.support_tools]
```

At startup, the server imports each listed module before any binding
connects. Every function marked with `@code_tool` is validated and
registered alongside tools from `server.code_tool`, under the same
startup checks.

Startup refuses, naming the module or setting:

| Refused when | Why |
|---|---|
| `code_tool_modules` is set without `allow_code_tools: true` (default
`false`) | The import runs operator code; the opt-in keeps a
profiles-only config from running any Python. Checked at config load. |
| A module entry is blank, padded or listed twice | Almost certainly a
typo. |
| A module cannot be imported, or raises while importing | Reported by
module name, with the cause chained. |
| A module defines no `@code_tool` function | A misspelled module name
should not quietly produce a server with fewer tools. |
| A decorator is applied over `@code_tool` | `functools.wraps` copies
the marker while the spec still calls the inner function, so the outer
decorator would silently never run. |

The marker lives on the function, not in a module-level registry.
Importing a module therefore registers nothing anywhere, and two servers
in one process stay independent, as do tests that import a tool module.
The loader considers only functions and reads the marker from each
function's own `__dict__`, so a module attribute with a permissive
`__getattr__`, such as a lazy module proxy, is never consulted. A tool
one listed module re-exports from another registers once.

The docs gain "Code Tools Loaded From Modules" and a troubleshooting
entry. They state that `rvl` does not put the working directory on the
import path: modules must be installed or on `PYTHONPATH`, and under
`uvx` installed in the environment it builds.

## Notes

The loader does not edit `sys.path`. Adding the config's directory would
make import resolution depend on where the config file happens to live.

I verified the end-to-end flow manually with `rvl mcp --transport
streamable-http`, a scratch module on `PYTHONPATH` and the server
started from outside the project directory. A FastMCP client listed
`support-count-open-tickets` with only `product` in its schema, and a
call returned the expected count.

## Release Notes

`rvl mcp` can load code tools from Python modules. Mark async functions
with `@code_tool` from `redisvl.mcp`, list their modules under
`server.code_tool_modules`, and set `server.allow_code_tools: true`,
which defaults to `false` because importing a module runs its code with
the server's privileges. Modules must be importable by the server
process.

## Next Steps

- Update the custom-tools proposal to record where v1.1 departs from it:
the `code_tool` names, no `ctx.guarded` (the handle applies the limits
itself), id-only writes and no filter-wide bulk writes, no code tools on
tenant-scoped indexes, async tools only, and the whole-call timeout.

## Reviewer Brief

**Start here:** `redisvl/mcp/tools/code.py:269`, `load_code_tools`. It
imports the modules a config names, so it is where configuration becomes
running code.

**Where it could fail**
- `redisvl/mcp/config.py:266`: `allow_code_tools` is the only gate on
importing operator code; check no settings or environment path can set
`code_tool_modules` around it.
- `redisvl/mcp/tools/code.py:269`: the loader reads the `@code_tool`
marker from each function's own `__dict__` and refuses a decorator
wrapped over it; check a tool re-exported from a second module registers
once.

**Not verified:** importing modules under `uvx`, which the guide's
advice rests on reasoning about how `uvx` builds its environment;
FastMCP 2.x.

**Since the last brief:** `927124e` rebases the PR onto `main` as one
commit after #758, #759 and #760 were squash-merged; the code is
unchanged from the reviewed head plus its fixes.

<sub>Reviewed `6ff481b`, fixes in `cc9249f` · Claude Code
(claude-opus-5-5) · lenses: correctness, security, design, tests, docs,
Redis behaviour</sub>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> When **`allow_code_tools`** is enabled, listed modules run with the
MCP server process privileges at import time; misconfiguration or
untrusted modules are the main exposure, though defaults stay off and
config validation fails closed on bad entries.
> 
> **Overview**
> **`rvl mcp --config`** can now register custom code tools without
constructing **`RedisVLMCPServer`** in Python. Add **`@code_tool`**
(exported from **`redisvl.mcp`**, same options as
**`server.code_tool`**) on async functions; list modules under
**`server.code_tool_modules`** and set **`server.allow_code_tools:
true`** (defaults **`false`**).
> 
> At startup—**before** any Redis binding connects—the server imports
each listed module via **`load_code_tools`**, collects functions marked
with **`__redisvl_mcp_tool__`**, and registers them through the same
validation path as tools added with **`server.code_tool`** (name
collisions, index binding, read-only/write rules, tenant
**`lock.inject`** routes). Config load rejects **`code_tool_modules`**
without the opt-in, duplicate or blank module names, failed imports,
modules with no **`@code_tool`** functions, and decorators wrapped
outside **`@code_tool`**.
> 
> Docs add module-based registration, **`PYTHONPATH`** / **`uvx`**
import notes, and troubleshooting for module load failures.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
927124e. 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

auto:minor Increment the minor version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants