diff --git a/redisvl/cli/main.py b/redisvl/cli/main.py index 1353192f..dbed65f3 100644 --- a/redisvl/cli/main.py +++ b/redisvl/cli/main.py @@ -14,6 +14,7 @@ def _usage(): "rvl []\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", ] @@ -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) diff --git a/redisvl/cli/mcp.py b/redisvl/cli/mcp.py new file mode 100644 index 00000000..b013b7ff --- /dev/null +++ b/redisvl/cli/mcp.py @@ -0,0 +1,136 @@ +"""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 [--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", + default=None, + ) + + 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, + ) + 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 + 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) diff --git a/tests/unit/test_cli_mcp.py b/tests/unit/test_cli_mcp.py new file mode 100644 index 00000000..c20b91b4 --- /dev/null +++ b/tests/unit/test_cli_mcp.py @@ -0,0 +1,301 @@ +import builtins +import importlib +import sys +import types +from collections import namedtuple + +import pytest + +from redisvl.cli.main import RedisVlCLI, _usage + + +def _import_cli_mcp(): + sys.modules.pop("redisvl.cli.mcp", None) + return importlib.import_module("redisvl.cli.mcp") + + +def _make_version_info(major, minor, micro=0): + version_info = namedtuple( + "VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"] + ) + return version_info(major, minor, micro, "final", 0) + + +def _install_fake_redisvl_mcp(monkeypatch, settings_factory, server_factory): + fake_module = types.ModuleType("redisvl.mcp") + fake_module.MCPSettings = settings_factory + fake_module.RedisVLMCPServer = server_factory + monkeypatch.setitem(sys.modules, "redisvl.mcp", fake_module) + return fake_module + + +def test_usage_includes_mcp(): + assert "mcp" in _usage() + + +def test_cli_dispatches_mcp_command_lazily(monkeypatch): + calls = [] + fake_module = types.ModuleType("redisvl.cli.mcp") + + class FakeMCP(object): + def __init__(self): + calls.append(list(sys.argv)) + + fake_module.MCP = FakeMCP + monkeypatch.setitem(sys.modules, "redisvl.cli.mcp", fake_module) + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml"]) + + cli = RedisVlCLI.__new__(RedisVlCLI) + + with pytest.raises(SystemExit) as exc_info: + RedisVlCLI.mcp(cli) + + assert exc_info.value.code == 0 + assert calls == [["rvl", "mcp", "--config", "/tmp/mcp.yaml"]] + + +def test_mcp_command_rejects_unsupported_python(monkeypatch, capsys): + monkeypatch.delitem(sys.modules, "redisvl.mcp", raising=False) + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.setattr(sys, "version_info", _make_version_info(3, 9, 18)) + original_import = builtins.__import__ + + def missing_mcp_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "redisvl.mcp" or name.startswith("redisvl.mcp."): + raise ModuleNotFoundError(name) + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", missing_mcp_import) + + module = _import_cli_mcp() + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml"]) + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + out = capsys.readouterr() + + assert exc_info.value.code == 1 + assert "3.10" in out.err or "3.10" in out.out + + +def test_mcp_command_reports_missing_optional_dependencies(monkeypatch, capsys): + monkeypatch.delitem(sys.modules, "redisvl.mcp", raising=False) + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.setattr(sys, "version_info", _make_version_info(3, 11, 0)) + + original_import = builtins.__import__ + + def missing_mcp_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "redisvl.mcp" or name.startswith("redisvl.mcp."): + raise ModuleNotFoundError(name) + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", missing_mcp_import) + + module = _import_cli_mcp() + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml"]) + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + out = capsys.readouterr() + + assert exc_info.value.code == 1 + assert "redisvl[mcp]" in out.err or "redisvl[mcp]" in out.out + + +def test_mcp_help_includes_description_and_example(monkeypatch, capsys): + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--help"]) + + module = _import_cli_mcp() + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + out = capsys.readouterr() + + assert exc_info.value.code == 0 + assert "Expose a configured Redis index to MCP clients" in out.out + assert "Use this command when wiring RedisVL into an MCP client" in out.out + assert ( + "uvx --from redisvl[mcp] rvl mcp --config /path/to/mcp_config.yaml" in out.out + ) + + +def test_mcp_command_preserves_env_read_only_when_flag_is_omitted(monkeypatch): + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.delitem(sys.modules, "redisvl.mcp", raising=False) + monkeypatch.setattr(sys, "version_info", _make_version_info(3, 11, 0)) + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml"]) + + calls = [] + + class FakeSettings(object): + @classmethod + def from_env(cls, config=None, read_only=None): + calls.append(("settings", config, read_only)) + return cls() + + class FakeServer(object): + def __init__(self, settings): + self.settings = settings + + async def startup(self): + calls.append(("startup",)) + + async def run(self, transport="stdio"): + calls.append(("run", transport)) + + async def shutdown(self): + calls.append(("shutdown",)) + + _install_fake_redisvl_mcp(monkeypatch, FakeSettings, FakeServer) + module = _import_cli_mcp() + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + assert exc_info.value.code == 0 + assert calls == [ + ("settings", "/tmp/mcp.yaml", None), + ("startup",), + ("run", "stdio"), + ("shutdown",), + ] + + +def test_mcp_command_runs_startup_then_stdio_then_shutdown(monkeypatch): + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.delitem(sys.modules, "redisvl.mcp", raising=False) + monkeypatch.setattr(sys, "version_info", _make_version_info(3, 11, 0)) + monkeypatch.setattr( + sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml", "--read-only"] + ) + + calls = [] + + class FakeSettings(object): + def __init__(self, config, read_only=False): + self.config = config + self.read_only = read_only + + @classmethod + def from_env(cls, config=None, read_only=None): + calls.append(("settings", config, read_only)) + return cls(config=config, read_only=read_only) + + class FakeServer(object): + def __init__(self, settings): + self.settings = settings + + async def startup(self): + calls.append(("startup", self.settings.config, self.settings.read_only)) + + async def run(self, transport="stdio"): + calls.append(("run", transport)) + + async def shutdown(self): + calls.append(("shutdown",)) + + _install_fake_redisvl_mcp(monkeypatch, FakeSettings, FakeServer) + module = _import_cli_mcp() + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + assert exc_info.value.code == 0 + assert calls == [ + ("settings", "/tmp/mcp.yaml", True), + ("startup", "/tmp/mcp.yaml", True), + ("run", "stdio"), + ("shutdown",), + ] + + +def test_mcp_command_reports_startup_failures(monkeypatch, capsys): + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.delitem(sys.modules, "redisvl.mcp", raising=False) + monkeypatch.setattr(sys, "version_info", _make_version_info(3, 11, 0)) + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml"]) + + calls = [] + + class FakeSettings(object): + @classmethod + def from_env(cls, config=None, read_only=None): + calls.append(("settings", config, read_only)) + return cls() + + class FakeServer(object): + def __init__(self, settings): + self.settings = settings + + async def startup(self): + calls.append(("startup",)) + raise RuntimeError("boom") + + async def run(self, transport="stdio"): + calls.append(("run", transport)) + + async def shutdown(self): + calls.append(("shutdown",)) + + _install_fake_redisvl_mcp(monkeypatch, FakeSettings, FakeServer) + module = _import_cli_mcp() + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + out = capsys.readouterr() + + assert exc_info.value.code == 1 + assert calls == [("settings", "/tmp/mcp.yaml", None), ("startup",)] + assert "boom" in out.err or "boom" in out.out + + +def test_mcp_command_shuts_down_when_run_fails(monkeypatch, capsys): + monkeypatch.delitem(sys.modules, "redisvl.cli.mcp", raising=False) + monkeypatch.delitem(sys.modules, "redisvl.mcp", raising=False) + monkeypatch.setattr(sys, "version_info", _make_version_info(3, 11, 0)) + monkeypatch.setattr(sys, "argv", ["rvl", "mcp", "--config", "/tmp/mcp.yaml"]) + + calls = [] + + class FakeSettings(object): + @classmethod + def from_env(cls, config=None, read_only=None): + calls.append(("settings", config, read_only)) + return cls() + + class FakeServer(object): + def __init__(self, settings): + self.settings = settings + + async def startup(self): + calls.append(("startup",)) + + async def run(self, transport="stdio"): + calls.append(("run", transport)) + raise RuntimeError("run failed") + + async def shutdown(self): + calls.append(("shutdown",)) + + _install_fake_redisvl_mcp(monkeypatch, FakeSettings, FakeServer) + module = _import_cli_mcp() + + with pytest.raises(SystemExit) as exc_info: + module.MCP() + + out = capsys.readouterr() + + assert exc_info.value.code == 1 + assert calls == [ + ("settings", "/tmp/mcp.yaml", None), + ("startup",), + ("run", "stdio"), + ("shutdown",), + ] + assert "run failed" in out.err or "run failed" in out.out