Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ Listing the tenant claim under `auth.required_claims` is a cheap outer layer: th

## Code Tools

A **code tool** is an async Python function registered as an MCP tool. It is for logic a profile cannot express, such as re-ranking results, counting matches, or combining several queries into one answer. You register it on the server object from your own Python process:
A **code tool** is an async Python function registered as an MCP tool. It is for logic a profile cannot express, such as re-ranking results, counting matches, or combining several queries into one answer. There are two ways to register one. When you start the server from your own Python process, register it on the server object:

```python
from redisvl.mcp import MCPSettings, RedisVLMCPServer, ToolContext
Expand All @@ -290,6 +290,17 @@ The function's first parameter is the context and the rest are the tool's argume

`index` pins the binding the tool works on. It may be omitted only when exactly one index is configured, and the model never chooses it.

When the server starts from `rvl mcp`, no code of yours runs unless the config names it. Mark each function with `@code_tool` from `redisvl.mcp`, which takes the same arguments as `code_tool`, and list its module in the config:

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

The server imports each module at startup and registers every function in it marked with `@code_tool`. Importing a module runs its code with the privileges of the process that started the server, so `code_tool_modules` is refused unless `allow_code_tools` is `true` as well. The setting defaults to `false`, which keeps a config that only uses profiles from running any Python. The modules must be importable by the server process: an installed package, or a directory on `PYTHONPATH`. `rvl` does not add the working directory to the import path.

### What a Code Tool Can Reach

A code tool never receives the server, the real `AsyncSearchIndex`, or a Redis client. It receives a `ToolContext` with:
Expand Down Expand Up @@ -318,7 +329,7 @@ The handle is a guardrail, not a sandbox. A code tool is your own code, running

The function is checked when it is decorated: it must be async, take a `ToolContext` first, annotate every other parameter, and have a description. Registering a tool after the server has started is refused, because tools register once per process.

The rest is checked at startup, against the loaded config: an `index` that is missing with several indexes configured or that names no binding; a name another custom tool already uses; a tool declared with `writes=True` on a read-only binding or server; and a code tool on a Redis index that a profile scopes with `lock.inject`. A code tool can run any query its handle allows, so nothing would show that it applies the tenant scope. Bind it to another index, or serve it from a separate server.
A module listed in `code_tool_modules` that cannot be imported, or that defines no `@code_tool` function, fails startup, so a misspelled module name does not quietly produce a server with fewer tools. The rest is checked at startup, against the loaded config: an `index` that is missing with several indexes configured or that names no binding; a name another custom tool already uses; a tool declared with `writes=True` on a read-only binding or server; and a code tool on a Redis index that a profile scopes with `lock.inject`. A code tool can run any query its handle allows, so nothing would show that it applies the tenant scope. Bind it to another index, or serve it from a separate server.

## Why Use MCP Instead of Direct RedisVL Calls

Expand Down
50 changes: 50 additions & 0 deletions docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,52 @@ Rules worth knowing:

A code tool bound to an index that a profile scopes with `lock.inject` refuses to start, because nothing shows that it applies the tenant scope. For the full contract, read the code tools section in {doc}`/concepts/mcp`.

### Code Tools Loaded From Modules

To serve code tools from `rvl mcp`, put them in a module, mark each with `@code_tool`, and name the module in the config. `@code_tool` takes the same arguments as `server.code_tool`, but it only marks the function: nothing registers it unless its module is listed in `code_tool_modules`. When you build the server in Python, use `server.code_tool` instead.

```python
# myapp/support_tools.py
from redisvl.mcp import ToolContext, code_tool
from redisvl.query import CountQuery
from redisvl.query.filter import Tag
@code_tool(index="tickets", namespace="support")
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))}
```

```yaml
server:
redis_url: redis://localhost:6379
allow_code_tools: true
code_tool_modules: [myapp.support_tools]
indexes:
tickets:
redis_name: support-tickets
search:
type: fulltext
runtime:
text_field_name: body
```

```bash
PYTHONPATH=/path/to/project rvl mcp --config /path/to/mcp_config.yaml
```

The client sees `support-count-open-tickets`, taking `product`.

Rules worth knowing:

- **`allow_code_tools: true` is required.** Importing a module runs its code with the server's privileges, so a config that lists `code_tool_modules` without it fails to load. It defaults to `false`.
- **The module must be importable.** Install it as a package or put its directory on `PYTHONPATH`; `rvl` does not add the working directory to the import path. Under `uvx`, the module and anything it imports must be installed in the environment `uvx` builds, for example with `--with` naming your package.
- **Only marked functions register.** Helpers in the same module are not published, and a tool imported into a second listed module registers once.
- **A module that adds nothing fails startup.** One that cannot be imported, or that has no `@code_tool` function, is reported by name.

## Tool Contracts

RedisVL MCP exposes a small, implementation-owned contract.
Expand Down Expand Up @@ -866,3 +912,7 @@ The token is verified but its claim is unusable: missing, empty, padded with whi
### Code Tool Fails at Startup

The error names the tool. Either its `index` is missing while several indexes are configured or names no configured binding, or another custom tool already uses its name, or it is registered with `writes=True` on a binding or server that is read-only. A code tool registered after the server has started is refused with a `RuntimeError`; register every tool before calling `run`, `run_async` or `startup`. A restart in the same process also fails with a `RuntimeError` if the code tools, or the indexes they bind to, have changed; restart the process instead.

### Code Tool Module Fails to Load

A config that lists `server.code_tool_modules` fails to load unless `server.allow_code_tools` is `true`. With the opt-in set, startup fails when a listed module cannot be imported, either because it is not on the import path or because importing it raised, and when it defines no function marked with `@code_tool`. The error names the module. Check that the server process can import it by running `python -c "import myapp.support_tools"` in the same environment and with the same `PYTHONPATH`, from a directory outside the project. Run from inside the project, `python -c` imports from the working directory, which `rvl` does not, so the check could pass while the server fails.
2 changes: 2 additions & 0 deletions redisvl/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception
from redisvl.mcp.server import RedisVLMCPServer
from redisvl.mcp.settings import MCPSettings
from redisvl.mcp.tools.code import code_tool

__all__ = [
"IndexHandle",
Expand All @@ -16,4 +17,5 @@
"WritableIndexHandle",
"load_mcp_config",
"map_exception",
"code_tool",
]
26 changes: 26 additions & 0 deletions redisvl/mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,32 @@ class MCPServerConfig(BaseModel):
builtin_tools: dict[str, Literal["enabled", "disabled"]] = Field(
default_factory=dict
)
# Importing a module runs its code with the server's privileges, so naming
# modules does nothing until the operator opts in separately.
code_tool_modules: list[str] = Field(default_factory=list)
allow_code_tools: bool = False

@model_validator(mode="after")
def _validate_code_tool_modules(self) -> "MCPServerConfig":
"""Require the opt-in, and a usable module name, for each code module."""
if self.code_tool_modules and not self.allow_code_tools:
raise ValueError(
"server.code_tool_modules imports Python code, which runs with "
"the server's privileges; set server.allow_code_tools: true to "
"allow it"
)
seen: set[str] = set()
for module in self.code_tool_modules:
if not module or module != module.strip():
raise ValueError(
f"server.code_tool_modules entry {module!r} is not a " "module name"
)
if module in seen:
raise ValueError(
f"server.code_tool_modules lists '{module}' more than once"
)
seen.add(module)
return self

@model_validator(mode="after")
def _validate_builtin_tools(self) -> "MCPServerConfig":
Expand Down
21 changes: 15 additions & 6 deletions redisvl/mcp/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
import logging
from collections.abc import Callable
from collections.abc import Callable, Iterable
from contextlib import asynccontextmanager
from enum import Enum, auto
from importlib import import_module
Expand All @@ -20,6 +20,7 @@
CodeToolSpec,
_ToolFn,
build_code_tool_spec,
load_code_tools,
register_code_tool,
)
from redisvl.mcp.tools.list_indexes import register_list_indexes_tool
Expand Down Expand Up @@ -454,15 +455,19 @@ def _verify_injection_has_a_token(self, profiles: Any) -> None:
"streamable-http) so the configured auth applies."
)

def _resolve_code_tools(self, config: Any) -> tuple[tuple[CodeToolSpec, str], ...]:
def _resolve_code_tools(
self, config: Any, loaded: Iterable[CodeToolSpec] = ()
) -> tuple[tuple[CodeToolSpec, str], ...]:
"""Pair each code tool with its binding, refusing any that cannot hold.
A code tool is declared in Python, so the rules the config model applies
to profiles are applied here instead, against the loaded config.
Covers tools registered with :meth:`code_tool` and those ``loaded``
from ``server.code_tool_modules`` alike. A code tool is declared in
Python, so the rules the config model applies to profiles are applied
here instead, against the loaded config.
"""
taken = {profile.name for profile in config.custom_tools}
resolved: list[tuple[CodeToolSpec, str]] = []
for spec in self._code_tools:
for spec in [*self._code_tools, *loaded]:
if spec.name in taken:
raise ValueError(
f"code tool name '{spec.name}' is already used by another "
Expand Down Expand Up @@ -904,7 +909,11 @@ async def _initialize_runtime_resources(self) -> None:
# Before any binding connects: both depend only on the loaded config,
# so an unworkable one should not first need a reachable Redis.
self._verify_injection_has_a_token(self.config.custom_tools)
self._resolved_code_tools = self._resolve_code_tools(self.config)
# Imported here, before any binding connects, so a module that fails to
# import is reported without first needing a reachable Redis.
self._resolved_code_tools = self._resolve_code_tools(
self.config, load_code_tools(self.config.server.code_tool_modules)
)
self._verify_no_unscoped_route_to_injected_indexes(
self.config, self._resolved_code_tools
)
Expand Down
91 changes: 90 additions & 1 deletion redisvl/mcp/tools/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"""

import asyncio
import importlib
import inspect
import typing
from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Any, TypeVar

Expand All @@ -27,6 +28,12 @@
from redisvl.mcp.context import ToolContext
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception

# Where `code_tool` leaves a function's spec for the loader to find. An attribute on
# the function rather than a module-level registry, so importing a module
# registers nothing anywhere: two servers in one process, and a test that
# imports a tool module, stay independent of each other.
_SPEC_ATTRIBUTE = "__redisvl_mcp_tool__"

_ToolFn = TypeVar("_ToolFn", bound=Callable[..., Any])

_ACCEPTED_KINDS = (
Expand Down Expand Up @@ -230,3 +237,85 @@ async def code_tool(**kwargs: Any) -> Any:
code_tool.__annotations__ = dict(spec.annotations)

server.tool(name=spec.name, description=spec.description)(code_tool)


def code_tool(
*,
name: str | None = None,
namespace: str | None = None,
index: str | None = None,
writes: bool = False,
scope: str | None = None,
description: str | None = None,
) -> Callable[[_ToolFn], _ToolFn]:
"""Mark an async function as a code tool for a server to load.
Takes the same arguments as ``RedisVLMCPServer.code_tool``. The function
is checked here and returned unchanged; it is registered only on a server
whose config lists its module under ``server.code_tool_modules``.
"""

def decorator(fn: _ToolFn) -> _ToolFn:
spec = build_code_tool_spec(
fn,
name=name,
namespace=namespace,
index=index,
writes=writes,
scope=scope,
description=description,
)
setattr(fn, _SPEC_ATTRIBUTE, spec)
return fn

return decorator


def load_code_tools(modules: Iterable[str]) -> list[CodeToolSpec]:
"""Import each module and return the code tools it defines, in order.
A module that cannot be imported, or that defines no tools, fails rather
than contributing nothing, so a typo in a module name is not silently a
server with fewer tools. A tool one module re-exports from another counts
once.
"""
specs: list[CodeToolSpec] = []
seen: set[Callable[..., Any]] = set()
for module_name in modules:
try:
module = importlib.import_module(module_name)
except Exception as exc:
raise ValueError(
f"server.code_tool_modules entry '{module_name}' could not be "
f"imported: {exc}"
) from exc

# Functions only, and read from their own __dict__: a module attribute
# with a permissive __getattr__ (a lazy module proxy, a mock) would
# otherwise answer for any name, or import something just to say no.
found = [
value
for value in vars(module).values()
if inspect.isfunction(value)
and isinstance(value.__dict__.get(_SPEC_ATTRIBUTE), CodeToolSpec)
]
if not found:
raise ValueError(
f"server.code_tool_modules entry '{module_name}' defines no code "
"tools; mark each one with @code_tool"
)
for fn in found:
spec = fn.__dict__[_SPEC_ATTRIBUTE]
if spec.fn is not fn:
# functools.wraps copies __dict__, so a decorator applied over
# @code_tool carries the marker while the spec still calls the
# inner function: the outer decorator would silently never run.
raise ValueError(
f"server.code_tool_modules entry '{module_name}': "
f"'{fn.__qualname__}' wraps a code tool in another decorator; "
"apply @code_tool outermost"
)
if fn not in seen:
seen.add(fn)
specs.append(spec)
return specs
48 changes: 47 additions & 1 deletion tests/integration/test_mcp/test_code_tools.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Code tools registered with ``server.code_tool``, against a real Redis.
"""Code tools, registered in Python or loaded from modules, against a real Redis.
The unit suites own the validation and wrapping matrices. What lives here is
what depends on a started server: that a tool's handle reaches the real index,
Expand All @@ -8,6 +8,10 @@
"""

import asyncio
import importlib
import sys
import textwrap
import uuid
from pathlib import Path

import pytest
Expand Down Expand Up @@ -65,8 +69,12 @@ def factory(
max_concurrency: int = 16,
custom_tools: list[dict] | None = None,
auth: dict | None = None,
tool_modules: list[str] | None = None,
) -> str:
server: dict = {"redis_url": redis_url}
if tool_modules is not None:
server["code_tool_modules"] = tool_modules
server["allow_code_tools"] = True
if auth is not None:
server["auth"] = auth
server["builtin_tools"] = {
Expand Down Expand Up @@ -215,3 +223,41 @@ async def ticket_counts(ctx: ToolContext) -> dict:
)

assert results == [{"open": 2, "resolved": 1}] * 2


@pytest.fixture
def tool_module(tmp_path, monkeypatch):
"""Write an importable tool module, unloading it afterwards."""
monkeypatch.syspath_prepend(str(tmp_path))
written: list[str] = []

def write(source: str) -> str:
name = f"code_tools_{uuid.uuid4().hex}"
(tmp_path / f"{name}.py").write_text(textwrap.dedent(source))
# The directory may already be cached as scanned by an earlier import.
importlib.invalidate_caches()
written.append(name)
return name

yield write

for name in written:
sys.modules.pop(name, None)


async def test_a_tool_loaded_from_a_module_is_served(server_for, tool_module):
module = tool_module("""
from redisvl.mcp import ToolContext, code_tool
from redisvl.query import CountQuery
from redisvl.query.filter import Tag
@code_tool(namespace="support")
async def count_open(ctx: ToolContext) -> dict:
"Count open support tickets."
return {"open": await ctx.index.query(CountQuery(Tag("status") == "open"))}
""")
server = server_for(tool_modules=[module])

await server.startup()

assert await _call(server, "support-count-open") == {"open": 2}
Loading
Loading