Skip to content

feat(mcp): load code tools from modules named in the config - #761

Merged
vishal-bala merged 1 commit into
mainfrom
feat/mcp-code-tools/04-module-loader
Oct 30, 2026
Merged

vishal-bala merged 1 commit into
mainfrom
feat/mcp-code-tools/04-module-loader

Conversation

@vishal-bala

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

Copy link
Copy Markdown
Collaborator

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:

# 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."""
    ...
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.

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


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.

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

@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
@vishal-bala
vishal-bala marked this pull request as ready for review October 27, 2026 13:47
@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:49:30.849987Z 9ef37cc 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:48

@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 adds the @code_tool decorator and config-based module loading, allowing rvl mcp to register custom code tools.

Just needs a rebase and we are good to go!

Base automatically changed from feat/mcp-code-tools/03-server-code-tool to main October 30, 2026 15:18
vishal-bala added a commit that referenced this pull request Oct 30, 2026
## Motivation

This PR makes code tools usable from a server started in the caller's
own Python process. It builds on the tool wrapper from the previous PR:

```python
from redisvl.mcp import MCPSettings, RedisVLMCPServer, ToolContext
from redisvl.query import CountQuery
from redisvl.query.filter import Tag

server = RedisVLMCPServer(MCPSettings(config="/path/to/mcp_config.yaml"))


@server.code_tool(index="tickets")
async def count_open_tickets(ctx: ToolContext, product: str) -> dict:
    """Count open support tickets for a product."""
    open_for_product = (Tag("status") == "open") & (Tag("product") == product)
    return {"open": await ctx.index.query(CountQuery(open_for_product))}
```

The model sees `count-open-tickets`, taking `product`, and never the
context or the index.

## Changes

### Registration

`server.code_tool(...)` checks the function at decoration and returns it
unchanged. Registering after the first startup raises `RuntimeError`,
because tools register once per process. Code tools register after
profiles.

The method is named `code_tool` rather than `custom_tool`, which the
proposal used. `custom_tools:` already means profiles in the config, and
every message and setting calls these code tools.

### Startup checks

Each tool is paired with its binding once the config is loaded, before
any binding connects. Startup fails, naming the tool, when:

| Refused when | Why |
|---|---|
| `index` is missing with several bindings, or names no binding | The
model never chooses a code tool's index, so it must be settled in
advance. |
| The name is already used by a profile or another code tool | Built-in
and reserved names are already refused at decoration. |
| The tool writes, and the server or the binding is read-only | It would
fail on every call it exists for. The message says which of the two is
read-only. |
| The tool's binding is over a Redis index that a profile scopes with
`lock.inject` | A code tool can run any query its handle allows, so
nothing shows that it applies the tenant scope. Bindings are grouped by
`redis_name`, so a sibling binding over the same documents counts. |

The last check extends `_verify_no_unscoped_route_to_injected_indexes`
rather than duplicating its grouping, so the error lists a code tool
alongside any other unscoped route.

A restart that would pair the registered code tools with different
bindings now fails, rather than taking the warning path other
tool-surface changes take. The old pairing would stay live, and for
operator code that is not something a log line should report.

### Docs and exports

`ToolContext`, `IndexHandle` and `WritableIndexHandle` are exported from
`redisvl.mcp`; the guide shows the `cast` a type checker needs to see a
writing tool's methods. The concepts page gains a Code Tools section
covering what the context exposes and withholds, the rails applied to
every call, and what fails at startup. The how-to guide gains "Code
Tools Registered From Python", with a read tool and a guarded delete,
plus a troubleshooting entry. The tool-surface and tenant-scoping
sections now mention code tools, and a line saying custom tools never
involve Python now says that of profiles specifically.

## Notes

Integration tests run against Redis 8:

- a writing tool loads through its handle;
- startup refuses a code tool on a tenant-scoped index;
- two concurrent calls of a tool that queries twice finish under a
concurrency cap of one. That would deadlock if the cap were held across
the tool's whole body.

A failed `ctx.index.load` reaches the client as a non-retryable
`internal_error`, even for a lost connection, because
`AsyncSearchIndex.load` wraps every error in `RedisVLError`. The
built-in `upsert-records` behaves the same way, so the docs state it
rather than this PR changing the error mapping; that change belongs in
its own PR, for both tools.

## Release Notes

Register an async Python function as an MCP tool with
`server.code_tool(...)` when you run the server from your own process.
It reaches Redis only through a `ToolContext` whose handle stays inside
the bound index, bounds every call, and offers writes only to tools
declared with `writes=True`.

## Next Steps

The final PR in the stack lets `rvl mcp` load code tools from modules
named in the config, behind an explicit opt-in.

## Reviewer Brief

**Start here:** `redisvl/mcp/server.py:457`, `_resolve_code_tools`. It
pairs each code tool with a binding once the config is loaded, and every
startup refusal for code tools happens there or in the injection check
it feeds.

**Where it could fail**
- `redisvl/mcp/server.py:503`: a code tool on any binding over a
`redis_name` that a profile scopes with `lock.inject` must be refused,
sibling bindings included; aliases and overlapping, empty or
separator-only prefixes are outside this check by design.
- `redisvl/mcp/server.py:645`: a restart compares the new pairing with
the recorded one; an unchanged config must compare equal, or every
restart of a server with code tools fails.
- `redisvl/mcp/server.py:260`: registration must be refused once startup
has begun, or a tool added while bindings connect is silently left out.

**Not verified:** FastMCP 2.x; an in-process restart against a live
Redis, which only the unit tests exercise.

**Beyond CI:** the guide's writing-tool example passes mypy as
published; a mutant that stops recording the pairing fails the restart
test; the Sphinx build at the #761 head adds no warnings to these pages.

<sub>Reviewed `09f1886`, fixes in `87e692f` · Claude Code
(claude-opus-5-5) · lenses: correctness, security, design, tests, docs,
Redis behaviour</sub>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes MCP tool surface and tenant-isolation startup rules;
misconfiguration is fail-closed, but code tools still run operator
Python with process privileges behind a bounded handle.
> 
> **Overview**
> Adds **code tools**: async Python functions you register with
**`@server.code_tool(...)`** before starting a server from your own
process, for logic YAML profiles cannot express.
> 
> **Registration and lifecycle:** The decorator validates the function
at decoration time and registers tools once at first startup (after
profiles). Registering during or after startup is rejected. An
in-process restart that changes which code tools exist or which index
they bind to now **fails with `RuntimeError`** instead of silently
keeping the old pairing.
> 
> **Startup policy:** Each tool is resolved to a binding (required when
multiple indexes exist), checked for name clashes with profiles, and
refused if **`writes=True`** on a read-only server or binding.
Tenant-scoped indexes with **`lock.inject`** also refuse any code tool
on that Redis index, extending the existing “no unscoped route” check
because the server cannot prove operator code applies the tenant filter.
> 
> **Public API:** **`ToolContext`**, **`IndexHandle`**, and
**`WritableIndexHandle`** are exported from **`redisvl.mcp`**. Docs add
a Code Tools section (concepts + how-to) and troubleshooting, and
tenant-scoping text now mentions code tools as alternate routes to the
same data.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
d6e71eb. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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
vishal-bala force-pushed the feat/mcp-code-tools/04-module-loader branch from 8a22c82 to 927124e Compare October 30, 2026 15:22
@vishal-bala
vishal-bala merged commit 6a81e13 into main Oct 30, 2026
58 checks passed
@vishal-bala
vishal-bala deleted the feat/mcp-code-tools/04-module-loader branch October 30, 2026 15:42
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