Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
083a547
feat(mcp): validate and wrap async functions as code tools
vishal-bala Oct 13, 2026
9717e52
fix(mcp): refuse a keyword-only context and re-check read-only per call
vishal-bala Oct 14, 2026
9977626
fix(mcp): check a code tool's input schema where it is declared, and …
vishal-bala Oct 20, 2026
74978d4
fix(mcp): require the read scope for writing code tools, and match to…
vishal-bala Oct 29, 2026
a147942
test(mcp): bound the queued-timeout test so a lost timeout fails inst…
vishal-bala Oct 30, 2026
ecf774a
fix(mcp): refuse a code tool scope with surrounding whitespace
vishal-bala Oct 30, 2026
df42826
feat(mcp): register code tools from Python with server.custom_tool
vishal-bala Oct 13, 2026
8c3a96b
fix(mcp): name the API code_tool and refuse a restart that changes co…
vishal-bala Oct 14, 2026
faacb23
docs(mcp): say scopes need auth, qualify key isolation, and export th…
vishal-bala Oct 20, 2026
2ad118d
docs(mcp): a writing code tool needs the read scope as well
vishal-bala Oct 29, 2026
4cbe8d1
docs(mcp): document load's ids and document_id, with an end-to-end ro…
vishal-bala Oct 30, 2026
e44db98
fix(mcp): refuse code tools registered while the server is starting, …
vishal-bala Oct 30, 2026
4da4dc1
docs(mcp): ctx.claims is None on a server without authentication
vishal-bala Oct 30, 2026
73a62d8
docs(mcp): a JSON FilterQuery needs return_fields to keep result keys
vishal-bala Oct 30, 2026
6f00412
docs(mcp): document restarts, load errors and prefix limits for code …
vishal-bala Oct 30, 2026
d6e71eb
Merge branch 'main' of https://github.com/redis/redis-vl-python into …
vishal-bala Oct 30, 2026
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
61 changes: 57 additions & 4 deletions docs/concepts/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ For configuration and the gateway boundary, see {doc}`/user_guide/how_to_guides/

## Tool Surface

RedisVL MCP exposes up to three built-in tools, plus any configured [custom tool profiles](#custom-tool-profiles):
RedisVL MCP exposes up to three built-in tools, plus any configured [custom tool profiles](#custom-tool-profiles) and [code tools](#code-tools):

- `list-indexes` enumerates the configured logical indexes for discovery
- `search-records` searches a selected index using that index's server-owned search mode
Expand Down Expand Up @@ -158,7 +158,7 @@ The discovery payload is deliberately minimal:

The built-in tools expose the index generically, which leaves the model doing query engineering on every call: pick the index, understand the schema, build a filter, choose return fields. A **profile** moves those decisions into config. It is `search-records` with some arguments pre-filled and frozen and the rest still exposed, published under a name and description of your choosing.

Profiles are pure configuration. You add a `custom_tools` entry to the same YAML the server already loads and restart it; there is no Python to write.
Profiles are pure configuration. You add a `custom_tools` entry to the same YAML the server already loads and restart it; a profile needs no Python. For logic a profile cannot express, write a [code tool](#code-tools) instead.

```yaml
custom_tools:
Expand Down Expand Up @@ -243,14 +243,14 @@ Injection is checked at startup wherever the configuration alone can show it wou

- authentication is not enabled, on any transport, including an unauthenticated loopback HTTP bind and any `--allow-unauthenticated` bind;
- authentication is configured but the server runs over `stdio`, which is never authenticated (checked when the server starts through `rvl mcp` or `run_async`; an embedder that calls `startup()` directly is not, and every call is then refused at request time instead);
- the Redis index is also reachable without the tenant scope: through `search-records`, through `upsert-records` unless every binding over it is read-only, or through another custom tool on it that does not inject exactly the same entries, since a tool scoped by another field, or by the same field from another claim, reads across the tenants this one separates. Bindings that share a `redis_name` count as one index;
- the Redis index is also reachable without the tenant scope: through `search-records`, through `upsert-records` unless every binding over it is read-only, through another custom tool on it that does not inject exactly the same entries, since a tool scoped by another field, or by the same field from another claim, reads across the tenants this one separates, or through a [code tool](#code-tools) bound to it, since the server cannot see what a code tool queries. Bindings that share a `redis_name` count as one index;
- the injected field is absent from the bound index, is not a tag field, or is declared `NOINDEX`;
- an `inject` list is empty, names one field twice, or names a field that `lock.filter` also constrains;
- `required` is anything but `true`, or `from` is anything but `claim`.

An injected field must be a tag. Text equality is a phrase match over tokenised text, and text is tokenised on punctuation, so the phrase `acme-corp` would also match `acme-corp-eu`.

The tool set registers once per process. If a restart reloads a configuration that differs from the registered one, the server normally logs a warning and keeps the old tools. When injection is configured on either side of the change, startup fails instead, because keeping the old tenant scoping in force is not something a log line should report.
The tool set registers once per process. If a restart reloads a configuration that differs from the registered one, the server normally logs a warning and keeps the old tools. When injection is configured on either side of the change, startup fails instead, because keeping the old tenant scoping in force is not something a log line should report. Startup also fails when the change alters the code tools or the bindings they resolve to, since operator code would otherwise keep running against the old pairing.

#### Threat Model

Expand All @@ -267,6 +267,59 @@ Where tenants share one index, the injected filter is the only isolation boundar

Listing the tenant claim under `auth.required_claims` is a cheap outer layer: the verifier then rejects a token that lacks the claim before any tool runs. That check confirms only that the claim is present. Its value is still validated by the profile on every call.

## 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:

```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 function's first parameter is the context and the rest are the tool's arguments, which the model supplies. The model sees the arguments and their types, and never the context. The docstring is the description the model chooses tools by, so a tool without one is refused. The tool's name is the function's name with hyphens for underscores (`count-open-tickets`), or `name=`; `namespace=` prefixes it. The same naming rules as profiles apply.

`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.

### 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:

| Attribute | What it is |
|---|---|
| `ctx.index` | A handle on the bound index offering `query`, `batch_query`, `search`, `batch_search`, `aggregate`, `fetch`, `exists` and `info`. A tool declared with `writes=True` also gets `load`, `drop_documents` and `expire_documents`. `document_id(key)` turns the full key a search result carries as `id` back into a document id. On JSON storage, a `FilterQuery` without `return_fields` returns whole documents, whose own `id` field replaces the key, so set `return_fields` when you need it. |
| `ctx.schema` | The bound index's schema. |
| `ctx.vectorizer` and `ctx.embed(text)` | The binding's vectorizer, if it has one, and a helper that embeds text with it. |
| `ctx.claims` | A read-only copy of the current request's verified token claims, or `None` when the server has no authentication or the request has no token. |

Every call stays inside the bound index. Searches and aggregations run against its name, and records are addressed by document id, which the index turns into a key under its own prefix. So nothing on the handle takes a full Redis key, `load` returns document ids, and `query` refuses a `SQLQuery`, whose `FROM` clause names its own index. Either could otherwise reach another index's documents, including a tenant-scoped one. An index whose key prefix overlaps another's shares keys with it, which the handle cannot tell 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. On an index with several prefixes, document ids address records under the first one only.

Everything else on the index is out of reach: creating, dropping or clearing it, `info` for another index, `paginate`, and the filter-wide bulk writes `update_by_filter` and `drop_by_filter`, which a request timeout could cancel partway through.

The server applies the same rails as for a built-in, without the author asking for them:

- Every `ctx.index` call takes a slot under the server-wide concurrency cap and is bounded by the binding's `runtime.request_timeout_seconds`. The cap is taken per call, so a tool that queries several times never blocks itself.
- The whole tool call is bounded by the same timeout, so a tool cannot hold a request open indefinitely. A timeout returns a retryable `backend_unavailable` error.
- A caller needs the server's read scope, and the write scope as well for a tool declared with `writes=True`, whose handle can both read and write. A tool can require an extra scope with `scope=`, which a caller must hold as well, so a code tool can be stricter than the built-ins but never looser. Like every scope, it is enforced only when the server has authentication configured.
- An exception the tool raises becomes a stable MCP error: `ValueError` becomes `invalid_request`, a Redis error from a search, aggregation or fetch a retryable `backend_unavailable`, and most other exceptions `internal_error`. That includes any failure of `ctx.index.load`, which reports even a lost connection as a non-retryable `internal_error`. Raise `RedisVLMCPError` to choose the code and message yourself.

The handle is a guardrail, not a sandbox. A code tool is your own code, running with the privileges of the process that started the server, and Python can still reach private attributes. What the handle does is make the bounded, policy-checked path the only obvious one.

### What Fails for a Code Tool

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.

## Why Use MCP Instead of Direct RedisVL Calls

Use RedisVL MCP when you want a standard tool boundary for agent frameworks or assistants that already speak MCP.
Expand Down
62 changes: 60 additions & 2 deletions docs/user_guide/how_to_guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,14 +407,68 @@ What the client sees for `search-customer-kb`:
- A description whose field hints list `content` but not `org_id`.
- Results from its own tenant only. A `filter` naming `org_id` ANDs with the injected value, so naming another tenant returns nothing. Results still carry `org_id`, always with the caller's own value, unless you lock `return_fields` to leave it out.

The server refuses to start while anything else can reach the same Redis index without the tenant scope: `search-records`, `upsert-records` unless every binding over the index is `read_only`, or another custom tool on that index whose `lock.inject` differs, including one with none. Bindings that share a `redis_name` are the same index, so a vector tool and a fulltext tool over one set of documents must inject the same scope. Each of those routes would hand every caller a way round the profile, and a write could retag another tenant's document as the writer's own. Ingest documents outside the server, stamping `org_id` exactly as the identity provider emits it.
The server refuses to start while anything else can reach the same Redis index without the tenant scope: `search-records`, `upsert-records` unless every binding over the index is `read_only`, another custom tool on that index whose `lock.inject` differs, including one with none, or any code tool bound to it. Bindings that share a `redis_name` are the same index, so a vector tool and a fulltext tool over one set of documents must inject the same scope. Each of those routes would hand every caller a way round the profile, and a write could retag another tenant's document as the writer's own. Ingest documents outside the server, stamping `org_id` exactly as the identity provider emits it.

A tool meant to read across tenants, such as an internal support search, belongs on a separate server with its own authentication. Every tool on one server sits behind the same read scope, so any tenant could call it.

Listing the tenant claim under `required_claims` makes the verifier reject a token without it before any tool runs. That checks presence only: the profile still validates the value on every call, and refuses a missing, empty, list-valued or otherwise unusable claim with a `forbidden` error before any query runs.

The server refuses to start an injecting profile without authentication, over `stdio`, or on a field the index does not hold as an indexed tag. The `stdio` check applies when the server starts through `rvl mcp` or `run_async`. For what the guarantee covers and what it rests on, read the threat model in {doc}`/concepts/mcp`.

### Code Tools Registered From Python

When a tool needs logic a profile cannot express, write it as an async function and register it on the server before you run it. This suits a server you start from your own Python process:

```python
from typing import cast

from redisvl.mcp import MCPSettings, RedisVLMCPServer, ToolContext, WritableIndexHandle
from redisvl.query import FilterQuery
from redisvl.query.filter import Tag

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


@server.code_tool(index="tickets")
async def open_tickets_for_product(ctx: ToolContext, product: str, limit: int = 5) -> dict:
"""List open support tickets for a product."""
query = FilterQuery(
filter_expression=(Tag("status") == "open") & (Tag("product") == product),
return_fields=["subject", "created_at"],
num_results=limit,
)
return {"tickets": await ctx.index.query(query)}


@server.code_tool(index="tickets", writes=True, scope="tickets.triage")
async def delete_spam_ticket(ctx: ToolContext, ticket_id: str) -> dict:
"""Delete a support ticket that triage has marked as spam."""
index = cast(WritableIndexHandle, ctx.index)
ticket = await index.fetch(ticket_id)
if ticket is None or ticket.get("status") != "spam":
raise ValueError(f"ticket {ticket_id} is not marked as spam")
return {"deleted": await index.drop_documents(ticket_id)}


server.run(transport="streamable-http", host="127.0.0.1", port=8000)
```

What the client sees:

- `open-tickets-for-product`, taking `product` (required) and `limit`, described by its docstring.
- `delete-spam-ticket`, taking `ticket_id`. With authentication configured, a caller needs the server's read and write scopes and `tickets.triage`; without it, no scope is checked. Either way, the tool deletes nothing that is not already marked as spam.
- No argument for the context or the index, in either tool.

Rules worth knowing:

- **A code tool reaches Redis only through `ctx.index`.** It has the read methods, plus a handful of bounded writes for a tool registered with `writes=True`. Records are addressed by document id, never by full Redis key, so a tool stays under its index's key prefix. That does not separate indexes whose prefixes overlap, and a prefix that is empty or made only of separator characters covers every key. On an index with several prefixes, ids address the first prefix only. `load` returns document ids, and `ctx.index.document_id(hit["id"])` turns a search result's key into one. On JSON storage, give a `FilterQuery` `return_fields`, or a document's own `id` field replaces the key in its results. There is no Redis client and no way to create, drop or clear the index.
- **Writing tools need a cast for type checkers.** `ctx.index` is typed as `IndexHandle`. In a tool registered with `writes=True`, `cast(WritableIndexHandle, ctx.index)` gives a type checker the write methods, as `delete-spam-ticket` shows. Both types are exported from `redisvl.mcp`.
- **Every call is bounded.** Each `ctx.index` call takes a slot under the concurrency cap and the binding's `request_timeout_seconds`, and the whole tool call is bounded by the same timeout.
- **Raise to report a problem.** A `ValueError` reaches the client as `invalid_request`; raise `RedisVLMCPError` to choose the error code yourself.
- **Register before starting.** Tools register once, at the first startup, so a tool registered afterwards is refused.

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`.

## Tool Contracts

RedisVL MCP exposes a small, implementation-owned contract.
Expand Down Expand Up @@ -803,8 +857,12 @@ A profile with `lock.inject` refuses to start when authentication is not enabled

### Claim Injection Refuses an Unscoped Route

A profile with `lock.inject` refuses to start while its Redis index is also reachable through `search-records`, through `upsert-records` on any writable binding over it, or through another custom tool on it whose `lock.inject` differs, including one with none. Bindings that share a `redis_name` are the same index. The error names the Redis index and the bindings over it, each route it found, and each tool's injected scope. Disable the built-ins under `server.builtin_tools`, mark every binding over the index `read_only`, and give every custom tool on the index the same `lock.inject`. Move a tool meant to read across tenants to a separate server with its own authentication.
A profile with `lock.inject` refuses to start while its Redis index is also reachable through `search-records`, through `upsert-records` on any writable binding over it, through another custom tool on it whose `lock.inject` differs, including one with none, or through a code tool bound to it. Bindings that share a `redis_name` are the same index. The error names the Redis index and the bindings over it, each route it found, and each tool's injected scope. Disable the built-ins under `server.builtin_tools`, mark every binding over the index `read_only`, and give every custom tool on the index the same `lock.inject`. Bind a code tool to another index. Move a tool meant to read across tenants to a separate server with its own authentication.

### Claim Injection Fails Every Request With `forbidden`

The token is verified but its claim is unusable: missing, empty, padded with whitespace, not a single string, or containing a control character or backtick. The error names the claim and the tool. A misspelled `claim` name in the config is the usual cause, since a JWT claim name such as `https://acme.example/org` must match exactly.

### 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.
4 changes: 4 additions & 0 deletions redisvl/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
from redisvl.mcp.config import MCPConfig, MCPTransportSecurityConfig, load_mcp_config
from redisvl.mcp.context import IndexHandle, ToolContext, WritableIndexHandle
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception
from redisvl.mcp.server import RedisVLMCPServer
from redisvl.mcp.settings import MCPSettings

__all__ = [
"IndexHandle",
"MCPConfig",
"MCPErrorCode",
"MCPSettings",
"MCPTransportSecurityConfig",
"RedisVLMCPError",
"RedisVLMCPServer",
"ToolContext",
"WritableIndexHandle",
"load_mcp_config",
"map_exception",
]
Loading
Loading