Skip to content

feat(mcp): register code tools from Python with server.code_tool - #760

Draft
vishal-bala wants to merge 6 commits into
feat/mcp-code-tools/02-code-tool-wrapperfrom
feat/mcp-code-tools/03-server-code-tool
Draft

vishal-bala wants to merge 6 commits into
feat/mcp-code-tools/02-code-tool-wrapperfrom
feat/mcp-code-tools/03-server-code-tool

Conversation

@vishal-bala

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

Copy link
Copy Markdown
Collaborator

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:

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.

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.

Review record

Reviewed at 392ccde by Claude Code (claude-opus-5-5) with pre-pr-review; fixes in beb1803.
Lenses: correctness (always); security (startup checks guarding tenant-scoped indexes); design (new public server.code_tool and MCP surface); tests (adds unit and integration tests); docs (two guide pages and docstrings); Redis behaviour (the guides state how keys map to an index). The lenses ran over the whole stack, and findings were attributed to the PR that introduced the line.

  • 13 findings raised here, 5 kept after challenge.
  • Fixed: the guides said a caller needs tickets.triage without saying scopes apply only with authentication (concepts/mcp.md:312, how_to_guides/mcp.md:456, server.py:246); the how-to said a tool "cannot touch another index's keys", which overlapping or empty prefixes break (how_to_guides/mcp.md:461); the documented writing tool failed mypy because the handle types were not exported (__init__.py, with a cast note in the guide); an InjectSpec docstring promised code tools the injection guarantees they never get (auth.py:250); a read-tool integration test duplicated the concurrency-cap test (test_code_tools.py:121, deleted).
  • Rejected: the concepts page's restart-warning sentence predates the stack, and the code-tool RuntimeError explains itself; the index-pinning rule repeated from profile validation (about 12 lines, no failure); "already used by another custom tool" wording for two code tools; decorator docstrings without a Raises line; duplicate async-only tests (each pins a different entry point); test_custom_tool_* test names; the tenant-route argument covered only by an integration test that CI runs; no docs/api page for redisvl.mcp (predates the stack).
  • Open for the reviewer: none.

Verified: pytest tests/unit/test_mcp (566 passed); pytest -n 1 tests/integration/test_mcp (83 passed, 2 skipped, Redis 8); mypy redisvl/mcp (no issues); mypy on the guide's writing tool (clean with the cast, attr-defined without it); Sphinx build at the #761 tip (no warnings from these sections; one broken Transport Security anchor predates the stack).
Docs consulted: https://redis.io/docs/latest/commands/ft.create/; https://redis.io/docs/latest/commands/ft.info/.
Not checked: aliases and overlapping prefixes stay outside the tenant-index check by design; FastMCP 2.x (tested on 3.1.0 and 3.3.1).

server.custom_tool(...) registers an async function as an MCP tool on a
server started from the caller's own process. At startup each tool is paired
with its binding and refused when it cannot hold: a missing or unknown index,
a name another custom tool uses, writes on a read-only binding or server, or
any binding over a Redis index that a profile scopes with lock.inject, since
nothing shows a code tool applies that scope. Code tools register after
profiles and count towards the tool-surface fingerprint. ToolContext is now
exported from redisvl.mcp, and the concepts and how-to guides document both.
…de tools

server.custom_tool becomes server.code_tool, matching allow_code_tools and the
docs, and no longer reading as a way to register a profile, which custom_tools
already means. A restart that would pair the registered code tools with
different bindings now fails instead of warning, since the old pairing would
stay live. A writing tool on a read-only server says so rather than blaming
the index. The docs describe the id-only writes and the SQLQuery refusal,
replace an example that assumed the record carried its own id, and name
code tools in the tool surface.
@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

This branch has not been deployed

No deployments
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.

1 participant