Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ef28ac0
RAAE-1396: Create MCP framework for RedisVL
vishal-bala Mar 12, 2026
e267c8f
Ensure server shutdown disconnects
vishal-bala Mar 12, 2026
91709b2
Exclude MCP module from import sanity-check
vishal-bala Mar 12, 2026
41eb2fb
Ensure startup failures clean up server resources
vishal-bala Mar 12, 2026
24fec66
Clear index state after MCP server shutdown
vishal-bala Mar 12, 2026
f197652
Fix MCP vectorizer cleanup on shutdown failure
vishal-bala Mar 13, 2026
3040ef6
Merge branch 'main' into feat/RAAE-1396/mcp-framework
vishal-bala Mar 24, 2026
1242d7a
Update implementation based on plan
vishal-bala Mar 25, 2026
cb3c543
Add filter type-hints; allow float values in `Num`
vishal-bala Mar 25, 2026
7529d61
Implement MCP search-records tool
vishal-bala Mar 25, 2026
9bdbee3
Add Codex config files to .gitignore
vishal-bala Mar 25, 2026
708d284
Merge branch 'feat/RAAE-1395-redisvl-mcp' into feat/RAAE-1397/search-…
vishal-bala Mar 25, 2026
473614a
Adapt type-hints for Python 3.9
vishal-bala Mar 25, 2026
9db4c13
Configure search in config, tool just takes query
vishal-bala Mar 25, 2026
aa93dd2
Python 3.9 compat
vishal-bala Mar 25, 2026
d8f5e3d
Merge branch 'feat/RAAE-1396/mcp-framework' into feat/RAAE-1397/searc…
vishal-bala Mar 25, 2026
08ca214
Merge branch 'feat/RAAE-1395-redisvl-mcp' into feat/RAAE-1396/mcp-fra…
vishal-bala Mar 25, 2026
962427e
Implement upsert-records tool for RedisVL MCP
vishal-bala Mar 25, 2026
ac3cc90
Cache hybrid support checks and validate fallback search params
vishal-bala Mar 25, 2026
c748db2
Python 3.9 compat
vishal-bala Mar 25, 2026
32f4a07
Classify malformed search results as internal errors
vishal-bala Mar 25, 2026
f475a1f
fix(mcp): validate hash vectors before serialization
vishal-bala Mar 25, 2026
11f5d07
fix(mcp): validate records before embedding
vishal-bala Mar 26, 2026
c7b2154
Fix native hybrid linear defaults for RRF
vishal-bala Mar 26, 2026
e32ad64
feat: add task-oriented MCP CLI command
vishal-bala Mar 26, 2026
9441c1d
fix: remove eager MCP imports for Python 3.9
vishal-bala Mar 26, 2026
20e6dd2
fix: preserve MCP read-only env defaults
vishal-bala Mar 26, 2026
d29d307
feat(mcp): gate requests on server lifecycle
vishal-bala Apr 2, 2026
b16f33c
Merge remote-tracking branch 'origin/feat/RAAE-1396/mcp-framework' in…
vishal-bala Apr 2, 2026
384e528
Merge remote-tracking branch 'origin/feat/RAAE-1397/search-tool' into…
vishal-bala Apr 2, 2026
352406b
fix(mcp): deep copy upsert records before load
vishal-bala Apr 2, 2026
e705fbb
Merge remote-tracking branch 'origin/feat/RAAE-1398/upsert-tool' into…
vishal-bala Apr 2, 2026
8aef518
Merge branch 'feat/RAAE-1395-redisvl-mcp' into feat/RAAE-1398/upsert-…
vishal-bala Apr 8, 2026
8ca8a62
Merge branch 'feat/RAAE-1398/upsert-tool' into feat/RAAE-1399-mcp-cli
vishal-bala Apr 8, 2026
9433eec
fix(cli): avoid double startup in MCP serve path
vishal-bala Apr 8, 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ dmypy.json
# Cython debug symbols
cython_debug/

# Codex
.codex/

# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
Expand Down
1 change: 1 addition & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ reranker
cache
message_history
router
cli
```

4 changes: 3 additions & 1 deletion docs/user_guide/cli.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"\n",
"Before running this notebook, be sure to\n",
"1. Have installed ``redisvl`` and have that environment active for this notebook.\n",
"2. Have a running Redis instance with Redis Search enabled"
"2. Have a running Redis instance with Redis Search enabled\n",
"\n",
"For complete command syntax and options, see the [CLI Reference](../api/cli.rst)."
]
},
{
Expand Down
6 changes: 6 additions & 0 deletions docs/user_guide/how_to_guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ How-to guides are **task-oriented** recipes that help you accomplish specific go
- [Choose a Storage Type](../05_hash_vs_json.ipynb) -- Hash vs JSON formats and nested data
:::

:::{grid-item-card} 💻 CLI Operations

- [Manage Indices with the CLI](../cli.ipynb) -- create, inspect, and delete indices from your terminal
:::

::::

## Quick Reference
Expand All @@ -53,6 +58,7 @@ How-to guides are **task-oriented** recipes that help you accomplish specific go
| Improve search accuracy | [Rerank Search Results](../06_rerankers.ipynb) |
| Optimize index performance | [Optimize Indexes with SVS-VAMANA](../09_svs_vamana.ipynb) |
| Decide on storage format | [Choose a Storage Type](../05_hash_vs_json.ipynb) |
| Manage indices from terminal | [Manage Indices with the CLI](../cli.ipynb) |

```{toctree}
:hidden:
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ dependencies = [
]

[project.optional-dependencies]
mcp = [
"fastmcp>=2.0.0 ; python_version >= '3.10'",
"pydantic-settings>=2.0",
]
mistralai = ["mistralai>=1.0.0"]
openai = ["openai>=1.1.0"]
nltk = ["nltk>=3.8.1,<4"]
Expand Down
7 changes: 0 additions & 7 deletions redisvl/cli/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,6 @@ def __init__(self):
parser = argparse.ArgumentParser(usage=self.usage)

parser.add_argument("command", help="Subcommand to run")
parser.add_argument(
"-f",
"--format",
help="Output format for info command",
type=str,
default="rounded_outline",
)
parser = add_index_parsing_options(parser)

args = parser.parse_args(sys.argv[2:])
Expand Down
7 changes: 7 additions & 0 deletions redisvl/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def _usage():
"rvl <command> [<args>]\n",
"Commands:",
"\tindex Index manipulation (create, delete, etc.)",
"\tmcp Run the RedisVL MCP server",
"\tversion Obtain the version of RedisVL",
"\tstats Obtain statistics about an index",
]
Expand Down Expand Up @@ -42,6 +43,12 @@ def index(self):
Index()
exit(0)

def mcp(self):
from redisvl.cli.mcp import MCP

MCP()
exit(0)

def version(self):
Version()
exit(0)
Expand Down
135 changes: 135 additions & 0 deletions redisvl/cli/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""CLI entrypoint for the RedisVL MCP server."""

import argparse
import asyncio
import inspect
import sys


class _MCPArgumentParser(argparse.ArgumentParser):
"""ArgumentParser variant that reports usage errors with exit code 1."""

def error(self, message):
self.print_usage(sys.stderr)
self.exit(1, "%s: error: %s\n" % (self.prog, message))


class MCP:
"""Command handler for `rvl mcp`."""

description = "Expose a configured Redis index to MCP clients for search and optional upsert operations."
epilog = (
"Use this command when wiring RedisVL into an MCP client.\n\n"
"Example:\n"
" uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml"
)
usage = "\n".join(
[
"rvl mcp --config <path> [--read-only]\n",
"\n",
]
)

def __init__(self):
"""Parse CLI arguments and run the MCP server command."""
parser = _MCPArgumentParser(
usage=self.usage,
description=self.description,
epilog=self.epilog,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--config", help="Path to MCP config file", required=True)
parser.add_argument(
"--read-only",
help="Disable the upsert tool",
action="store_true",
dest="read_only",
)

args = parser.parse_args(sys.argv[2:])
self._run(args)
raise SystemExit(0)

def _run(self, args):
"""Validate the environment, build the server, and serve stdio requests."""
try:
self._ensure_supported_python()
settings_cls, server_cls = self._load_mcp_components()
settings = settings_cls.from_env(
config=args.config,
read_only=args.read_only,
Comment thread
vishal-bala marked this conversation as resolved.
)
server = server_cls(settings)
self._run_awaitable(self._serve(server))
except KeyboardInterrupt:
raise SystemExit(0)
except Exception as exc:
self._print_error(str(exc))
raise SystemExit(1)

@staticmethod
def _ensure_supported_python():
"""Fail fast when the current interpreter cannot support MCP extras."""
if sys.version_info < (3, 10):
version = "%s.%s.%s" % (
sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro,
)
raise RuntimeError(
"RedisVL MCP CLI requires Python 3.10 or newer. "
"Current runtime is Python %s." % version
)

@staticmethod
def _load_mcp_components():
"""Import optional MCP dependencies only on the `rvl mcp` code path."""
try:
from redisvl.mcp import MCPSettings, RedisVLMCPServer
except (ImportError, ModuleNotFoundError) as exc:
raise RuntimeError(
"RedisVL MCP support requires optional dependencies. "
"Install them with `pip install redisvl[mcp]`.\n"
"Original error: %s" % exc
)

return MCPSettings, RedisVLMCPServer

@staticmethod
def _run_awaitable(awaitable):
"""Bridge the synchronous CLI entrypoint to async server lifecycle code."""
return asyncio.run(awaitable)

async def _serve(self, server):
"""Run startup, stdio serving, and shutdown on one event loop."""
started = False

try:
await server.startup()
started = True

# Prefer FastMCP's async transport path so startup, serving, and
# shutdown all share the same event loop.
run_async = getattr(server, "run_async", None)
if callable(run_async):
await run_async(transport="stdio")
else:
result = server.run(transport="stdio")
if inspect.isawaitable(result):
await result
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
finally:
if started:
try:
result = server.shutdown()
if inspect.isawaitable(result):
await result
except RuntimeError as exc:
# KeyboardInterrupt during stdio shutdown can leave FastMCP
# tearing down after the loop is already closing.
if "Event loop is closed" not in str(exc):
raise

@staticmethod
def _print_error(message):
"""Emit user-facing command errors to stderr."""
print(message, file=sys.stderr)
9 changes: 2 additions & 7 deletions redisvl/cli/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from redisvl.index import SearchIndex
from redisvl.schema.schema import IndexSchema
from redisvl.utils.log import get_logger
from redisvl.utils.utils import lazy_import

logger = get_logger("[RedisVL]")

Expand Down Expand Up @@ -42,10 +41,6 @@ class Stats:

def __init__(self):
parser = argparse.ArgumentParser(usage=self.usage)

parser.add_argument(
"-f", "--format", help="Output format", type=str, default="rounded_outline"
)
parser = add_index_parsing_options(parser)
args = parser.parse_args(sys.argv[2:])
try:
Expand All @@ -61,7 +56,7 @@ def stats(self, args: Namespace):
rvl stats -i <index_name> | -s <schema_path>
"""
index = self._connect_to_index(args)
_display_stats(index.info(), output_format=args.format)
_display_stats(index.info())

def _connect_to_index(self, args: Namespace) -> SearchIndex:
# connect to redis
Expand All @@ -85,7 +80,7 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex:
return index


def _display_stats(index_info, output_format="rounded_outline"):
def _display_stats(index_info):
# Extracting the statistics
stats_data = [(key, str(index_info.get(key))) for key in STATS_KEYS]

Expand Down
4 changes: 2 additions & 2 deletions redisvl/extensions/cache/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
"""

from collections.abc import Mapping
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Optional

from redis import Redis # For backwards compatibility in type checking
from redis.cluster import RedisCluster

from redisvl.redis.connection import RedisConnectionFactory
from redisvl.types import AsyncRedisClient, SyncRedisClient, SyncRedisCluster
from redisvl.types import AsyncRedisClient, SyncRedisClient


class BaseCache:
Expand Down
2 changes: 1 addition & 1 deletion redisvl/extensions/router/semantic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Type, Union
from typing import Any, Dict, List, Optional, Type, Union

import redis.commands.search.reducers as reducers
import yaml
Expand Down
18 changes: 11 additions & 7 deletions redisvl/index/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,13 @@ def _validate_query(self, query: BaseQuery) -> None:
def _validate_hybrid_query(self, query: Any) -> None:
"""Validate that a hybrid query can be executed."""
try:
from redis.commands.search.hybrid_result import HybridResult
from redis.commands.search.hybrid_result import ( # noqa: F401
HybridResult as _HybridResult,
)

from redisvl.query.hybrid import HybridQuery

del _HybridResult # Only imported to check availability
except (ImportError, ModuleNotFoundError):
raise ImportError(_HYBRID_SEARCH_ERROR_MESSAGE)

Expand Down Expand Up @@ -894,14 +898,14 @@ def load(
batch_size=batch_size,
validate=self._validate_on_load,
)
except SchemaValidationError as e:
except SchemaValidationError:
# Log the detailed validation error with actionable information
logger.error("Data validation failed during load operation")
raise
except Exception as e:
except Exception as exc:
# Wrap other errors as general RedisVL errors
logger.exception("Error while loading data to Redis")
raise RedisVLError(f"Failed to load data: {str(e)}") from e
raise RedisVLError(f"Failed to load data: {str(exc)}") from exc

def fetch(self, id: str) -> Optional[Dict[str, Any]]:
"""Fetch an object from Redis by id.
Expand Down Expand Up @@ -1840,14 +1844,14 @@ def add_field(d):
batch_size=batch_size,
validate=self._validate_on_load,
)
except SchemaValidationError as e:
except SchemaValidationError:
# Log the detailed validation error with actionable information
logger.error("Data validation failed during load operation")
raise
except Exception as e:
except Exception as exc:
# Wrap other errors as general RedisVL errors
logger.exception("Error while loading data to Redis")
raise RedisVLError(f"Failed to load data: {str(e)}") from e
raise RedisVLError(f"Failed to load data: {str(exc)}") from exc

async def fetch(self, id: str) -> Optional[Dict[str, Any]]:
"""Asynchronously etch an object from Redis by id. The id is typically
Expand Down
13 changes: 1 addition & 12 deletions redisvl/index/storage.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
from collections.abc import Collection
from typing import (
Any,
Awaitable,
Callable,
Dict,
Iterable,
List,
Optional,
Tuple,
Union,
cast,
)
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union

from pydantic import BaseModel, ValidationError
from redis import __version__ as redis_version
Expand Down
14 changes: 14 additions & 0 deletions redisvl/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from redisvl.mcp.config import MCPConfig, load_mcp_config
from redisvl.mcp.errors import MCPErrorCode, RedisVLMCPError, map_exception
from redisvl.mcp.server import RedisVLMCPServer
from redisvl.mcp.settings import MCPSettings

__all__ = [
"MCPConfig",
"MCPErrorCode",
"MCPSettings",
"RedisVLMCPError",
"RedisVLMCPServer",
"load_mcp_config",
"map_exception",
]
Loading
Loading